diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..4e4a389 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,38 @@ +FROM mcr.microsoft.com/devcontainers/base:debian-12 + +ARG TARGETARCH + +COPY .terraform-version .opentofu-version .task-version /tmp/versions/ + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl git gnupg jq perl unzip; \ + rm -rf /var/lib/apt/lists/*; \ + case "${TARGETARCH}" in \ + amd64) architecture="amd64" ;; \ + arm64) architecture="arm64" ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + terraform_version="$(tr -d '[:space:]' < /tmp/versions/.terraform-version)"; \ + tofu_version="$(tr -d '[:space:]' < /tmp/versions/.opentofu-version)"; \ + task_version="$(tr -d '[:space:]' < /tmp/versions/.task-version)"; \ + curl --fail --location --silent --show-error --output "/tmp/terraform_${terraform_version}_linux_${architecture}.zip" "https://releases.hashicorp.com/terraform/${terraform_version}/terraform_${terraform_version}_linux_${architecture}.zip"; \ + curl --fail --location --silent --show-error --output /tmp/terraform_SHA256SUMS "https://releases.hashicorp.com/terraform/${terraform_version}/terraform_${terraform_version}_SHA256SUMS"; \ + (cd /tmp && grep "terraform_${terraform_version}_linux_${architecture}.zip" terraform_SHA256SUMS | sha256sum --check --status); \ + unzip -q "/tmp/terraform_${terraform_version}_linux_${architecture}.zip" -d /usr/local/bin; \ + curl --fail --location --silent --show-error --output "/tmp/tofu_${tofu_version}_linux_${architecture}.zip" "https://github.com/opentofu/opentofu/releases/download/v${tofu_version}/tofu_${tofu_version}_linux_${architecture}.zip"; \ + curl --fail --location --silent --show-error --output /tmp/tofu_SHA256SUMS "https://github.com/opentofu/opentofu/releases/download/v${tofu_version}/tofu_${tofu_version}_SHA256SUMS"; \ + (cd /tmp && grep "tofu_${tofu_version}_linux_${architecture}.zip" tofu_SHA256SUMS | sha256sum --check --status); \ + unzip -q "/tmp/tofu_${tofu_version}_linux_${architecture}.zip" tofu -d /usr/local/bin; \ + curl --fail --location --silent --show-error --output "/tmp/task_linux_${architecture}.tar.gz" "https://github.com/go-task/task/releases/download/v${task_version}/task_linux_${architecture}.tar.gz"; \ + curl --fail --location --silent --show-error --output /tmp/task_checksums.txt "https://github.com/go-task/task/releases/download/v${task_version}/task_checksums.txt"; \ + (cd /tmp && grep "task_linux_${architecture}.tar.gz" task_checksums.txt | sha256sum --check --status); \ + tar -xzf "/tmp/task_linux_${architecture}.tar.gz" -C /usr/local/bin task; \ + terraform version; \ + tofu version; \ + task --version; \ + rm -rf /tmp/terraform_* /tmp/tofu_* /tmp/task_* /tmp/versions; \ + mkdir -p /home/vscode/.cache/terraform/providers; \ + chown -R vscode:vscode /home/vscode/.cache + +ENV TF_PLUGIN_CACHE_DIR=/home/vscode/.cache/terraform/providers diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f3b485c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "Terraform StackGuardian Modules", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "remoteUser": "vscode", + "containerEnv": { + "TF_PLUGIN_CACHE_DIR": "/home/vscode/.cache/terraform/providers" + }, + "customizations": { + "vscode": { + "extensions": [ + "hashicorp.terraform", + "opentofu.vscode-opentofu" + ] + } + } +} diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..ccc395a --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,31 @@ +name: Checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run checks in the devcontainer + uses: devcontainers/ci@v0.3 + with: + push: never + runCmd: | + task check + task validate + task test diff --git a/.gitignore b/.gitignore index b398f40..09d427f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,13 @@ override.tf.json # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan # example: *tfplan* +*.tfplan # Ignore CLI configuration files .terraformrc terraform.rc +# Local environment files can contain API tokens. +.env + **/.DS_Store diff --git a/.opentofu-version b/.opentofu-version new file mode 100644 index 0000000..e0a6b34 --- /dev/null +++ b/.opentofu-version @@ -0,0 +1 @@ +1.12.5 diff --git a/.task-version b/.task-version new file mode 100644 index 0000000..9eb3dd3 --- /dev/null +++ b/.task-version @@ -0,0 +1 @@ +3.45.5 diff --git a/.terraform-version b/.terraform-version new file mode 100644 index 0000000..f01291b --- /dev/null +++ b/.terraform-version @@ -0,0 +1 @@ +1.5.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 80747ff..dc5c287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + +### Changed +- Move AWS region, Azure subscription and tenant IDs, and GCP project IDs into their respective `cloud_connectors` entries instead of using root cloud-provider variables. +- Rebuild the scoped role-v4 permission document from the observed v4 positional path shape, including exact and nested workflow-group paths and matching wildcard arrays. +- Deprecate static AWS and Azure authentication. Static identity modules and static cloud connector kinds now require explicit `allow_static_credentials = true` acknowledgement and emit an apply-time warning. +- Replace the unsafe Taskfile example-copy initialization workflow with non-mutating formatting, source-contract, and isolated validation tasks compatible with Terraform 1.5.7. + +### Added +- Add pinned OpenTofu 1.12.5 native tests for role-v4 permission construction and static cloud connector acknowledgement behavior, using isolated mocked plan runs. + +## [2.0.0] - 2026-08-12 + +### Breaking +- Pin Terraform Core to `1.5.7` and upgrade bounded provider ranges: StackGuardian 1.12, AWS 6, AzureRM 5, AzureAD 3.9, and Google 7. +- Replace legacy camelCase/hyphenated inputs, untyped connector objects, root static credential duplicates, and leaf StackGuardian API credential inputs with v2 flat snake_case contracts. +- Migrate roles to `stackguardian_rolev4` and assignments to `roles = [role_name]`. Existing role state requires the documented state remove/import procedure. +- Replace global AWS policy attachments and authoritative GCP service-account IAM policy management with narrowly scoped attachments/members. Existing state needs the documented import procedure before apply. + +### Changed +- Correct Azure connector settings to the provider's `arm_tenant_id`, `arm_subscription_id`, `arm_client_id`, and `arm_client_secret` schema. +- Use production StackGuardian OIDC issuer/audience defaults for AWS, Azure, and GCP; expose their configuration inputs. +- Retain configurable permissive defaults: AWS `ReadOnlyAccess`, Azure subscription `Contributor`, and GCP `roles/owner`. These defaults are documented as high-risk where applicable. +- Add validation, sensitive flags, finite Azure secret lifetime, typed VCS credentials, typed cloud connectors, and GCP same-type state moves. + ### Added - Comprehensive README.md with detailed documentation - Input validation for variables diff --git a/README.md b/README.md index 9cfaa69..d931998 100644 --- a/README.md +++ b/README.md @@ -1,327 +1,100 @@ -# StackGuardian Terraform Modules +# StackGuardian Terraform Modules v2 -A comprehensive collection of Terraform modules for onboarding and managing StackGuardian platform resources. This repository provides everything you need to set up team access, cloud connectors, workflow groups, and role-based access control (RBAC) for your StackGuardian organization. +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. -## 🚀 Overview +## Prerequisites -StackGuardian is a cloud infrastructure management platform that helps organizations manage their Infrastructure as Code (IaC) deployments across multiple cloud providers. This Terraform module collection automates the setup of: +- Terraform **1.5.7** exactly. +- StackGuardian provider `>= 1.12.0, < 2.0.0`. +- 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. -- **Workflow Groups** - Organize deployments by environment (Dev, Test, Staging, Prod) -- **Cloud Connectors** - Secure connections to AWS, Azure, and GCP -- **VCS Connectors** - Integration with GitHub, GitLab, and Bitbucket -- **Roles & Permissions** - Custom roles with granular permissions -- **User/Group Management** - Assign roles to users and groups -- **OIDC Setup** - Optional OpenID Connect provider configuration +Use `terraform.tfvars.example` as a schema reference. Put secret values in `TF_VAR_*` variables or a secret manager, not version control. -## 📋 Prerequisites +## Static Credential Deprecation -- [Terraform](https://www.terraform.io/downloads.html) >= 1.0 -- StackGuardian account with API access -- Cloud provider accounts (AWS/Azure/GCP) if using cloud connectors -- VCS provider access tokens (GitHub/GitLab/Bitbucket) if using VCS connectors +`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. -## 🏗️ Module Architecture - -``` -terraform-stackguardian-modules/ -├── main.tf # Root module orchestration -├── variables.tf # Input variables -├── provider.tf # Provider configurations -├── terraform.tfvars # Example configuration -├── stackguardian_workflow_group/ # Workflow group module -├── stackguardian_connector_cloud/ # Cloud connector module -├── stackguardian_connector_vcs/ # VCS connector module -├── stackguardian_role/ # Role management module -├── stackguardian_role_assignment/ # Role assignment module -├── aws_oidc/ # AWS OIDC setup module -├── aws_rbac/ # AWS RBAC setup module -├── azure_oidc/ # Azure OIDC setup module -└── gcp_oidc/ # GCP OIDC setup module -``` - -## 🚀 Quick Start - -### 1. Clone the Repository - -```bash -git clone -cd terraform-stackguardian-modules -``` - -### 2. Configure Variables - -Copy the example configuration and customize it for your organization: - -```bash -cp terraform.tfvars.example terraform.tfvars -``` - -Edit `terraform.tfvars` with your StackGuardian credentials and desired configuration: +Static authentication requires an explicit acknowledgement. The root acknowledgement is per connector and cannot enable another connector: ```hcl -# StackGuardian Platform Credentials -api_key = "sgu-your-api-key-here" -org_name = "your-org-name" - -# Workflow Groups (environments) -workflow_groups = ["TeamX-Dev", "TeamX-Test", "TeamX-Staging", "TeamX-Prod"] - -# Cloud Connectors cloud_connectors = [{ - name = "aws-connector-1" - connector_type = "AWS_RBAC" - role_arn = "arn:aws:iam::123456789012:role/StackGuardianRole" - aws_role_external_id = "your-org:random-string" + 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" }] - -# VCS Connectors -vcs_connectors = { - vcs_github = { - kind = "GITHUB_COM" - name = "github-connector" - config = [{ - github_creds = { - githubCreds = "username:personal_access_token" - github_com_url = "https://api.github.com" - github_http_url = "https://github.com" - } - }] - } -} - -# Role Configuration -role_name = "TeamX-Role" -template_list = ["opentofu-aws-vpc"] - -# User Assignment -user_or_group = "user@example.com" -entity_type = "EMAIL" -``` - -### 3. Initialize and Apply - -```bash -# Initialize Terraform -terraform init - -# Plan the deployment -terraform plan - -# Apply the configuration -terraform apply -``` - -## 📚 Module Documentation - -### Core Modules - -#### `stackguardian_workflow_group` -Creates workflow groups for organizing deployments by environment. - -**Inputs:** -- `workflow_group_name` - Name of the workflow group -- `api_key` - StackGuardian API key -- `org_name` - StackGuardian organization name - -**Outputs:** -- `workflow_groups` - Created workflow group name - -#### `stackguardian_connector_cloud` -Sets up cloud provider connectors with various authentication methods. - -**Supported Connector Types:** -- `AWS_STATIC` - AWS access key/secret -- `AWS_RBAC` - AWS role with external ID -- `AWS_OIDC` - AWS role with OIDC -- `AZURE_STATIC` - Azure service principal -- `AZURE_OIDC` - Azure with OIDC -- `GCP_STATIC` - GCP service account - -**Key Inputs:** -- `cloud_connector_name` - Name of the connector -- `connector_type` - Type of connector (see above) -- `role_arn` - AWS role ARN (for AWS connectors) -- `role_external_id` - External ID for AWS RBAC - -#### `stackguardian_connector_vcs` -Integrates with version control systems. - -**Supported VCS Types:** -- `GITHUB_COM` - GitHub.com -- `GITLAB_COM` - GitLab.com -- `BITBUCKET_ORG` - Bitbucket.org - -#### `stackguardian_role` -Creates custom roles with specific permissions. - -**Key Inputs:** -- `role_name` - Name of the role -- `cloud_connectors` - List of accessible cloud connectors -- `vcs_connectors` - List of accessible VCS connectors -- `workflow_groups` - List of accessible workflow groups -- `template_list` - List of accessible templates - -#### `stackguardian_role_assignment` -Assigns roles to users or groups. - -**Key Inputs:** -- `user_or_group` - User email or group identifier -- `entity_type` - Either "EMAIL" or "GROUP" -- `role_name` - Role to assign - -### Cloud Setup Modules - -#### `aws_oidc` -Creates AWS IAM OIDC provider and role for StackGuardian. - -#### `aws_rbac` -Sets up AWS IAM role with external ID for RBAC authentication. - -#### `azure_oidc` -Configures Azure AD application and service principal for OIDC. - -#### `gcp_oidc` -Sets up GCP workload identity federation for OIDC authentication. - -## 🔧 Configuration Examples - -### Multi-Environment Setup - -```hcl -workflow_groups = [ - "frontend-dev", - "frontend-staging", - "frontend-prod", - "backend-dev", - "backend-staging", - "backend-prod" -] ``` -### Multiple Cloud Connectors +Standalone `aws_static` and `azure_static` modules also require `allow_static_credentials = true`. Terraform emits a deprecation warning during apply after acknowledgement. -```hcl -cloud_connectors = [ - { - name = "aws-dev" - connector_type = "AWS_RBAC" - role_arn = "arn:aws:iam::111111111111:role/StackGuardian-Dev" - aws_role_external_id = "myorg:dev-12345" - }, - { - name = "aws-prod" - connector_type = "AWS_RBAC" - role_arn = "arn:aws:iam::222222222222:role/StackGuardian-Prod" - aws_role_external_id = "myorg:prod-67890" - } -] -``` +## Cloud Onboarding -### Multiple VCS Connectors +The root creates the selected cloud identity and registers the generated identifiers with StackGuardian. It does not require you to manually supply an AWS role ARN, an Azure application client ID, or a GCP external-account configuration for OIDC connectors. -```hcl -vcs_connectors = { - vcs_github = { - kind = "GITHUB_COM" - name = "github-main" - config = [{ - github_creds = { - githubCreds = "username:personal_access_token" - github_com_url = "https://api.github.com" - github_http_url = "https://github.com" - } - }] - }, - vcs_gitlab = { - kind = "GITLAB_COM" - name = "gitlab-secondary" - config = [{ - gitlab_creds = { - gitlabCreds = "username:personal_access_token" - gitlabHttpUrl = "https://gitlab.com" - gitlabApiUrl = "https://gitlab.com/api/v4" - } - }] - } -} -``` +- AWS uses the standard AWS provider credential chain. Authenticate first with your normal profile or `aws sso login`, then customize `examples/aws-oidc.tfvars.example` and run `task onboard:aws`. The task creates the `api.app.stackguardian.io` IAM OIDC provider when it is absent, or imports an existing unmanaged provider instead of attempting to recreate it. +- Azure uses the active Azure CLI identity. Authenticate with `az login`, set the connector's `azure_subscription_id` and `azure_tenant_id` to match the active account, customize `examples/azure-oidc.tfvars.example`, and run `task onboard:azure`. +- GCP uses gcloud application-default credentials. Run `gcloud auth application-default login`, configure a `GCP_OIDC` connector with its `gcp_project_id` and workload identity names, and apply a reviewed plan. -## 🔐 Security Best Practices +Both onboarding tasks use `TF_VAR_stackguardian_api_key` when it is set. Otherwise they prompt for the StackGuardian API token without saving it to a file. Tasks write full plans to the working directory, apply that exact plan, and retain it for inspection: `onboard-aws.tfplan`, `onboard-azure.tfplan`, `destroy-aws.tfplan`, or `destroy-azure.tfplan`. These ignored files can be overridden with `ONBOARD_PLAN_FILE`. A full plan applies every resource declared by the selected vars file, including workflow groups, roles, and assignments. Override `ONBOARD_VARS_FILE` or `ONBOARD_CONNECTOR_NAME` when using a differently named connector fixture. -### API Key Management -- Store API keys in environment variables or secure secret management systems -- Never commit API keys to version control -- Use different API keys for different environments +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. -### Cloud Connector Security -- Use RBAC or OIDC instead of static credentials when possible -- Follow principle of least privilege for IAM roles -- Regularly rotate access keys and external IDs -- Use separate AWS accounts/Azure subscriptions for different environments +## V2 Inputs -### VCS Integration -- Use personal access tokens with minimal required scopes -- Regularly rotate VCS tokens -- Consider using organization-level tokens for team access +| 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` | -## 🚨 Troubleshooting +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. -### Common Issues +## Permissions And Defaults -**Provider Authentication Errors** -```bash -Error: Invalid API key or organization name -``` -- Verify your `api_key` and `org_name` in terraform.tfvars -- Ensure the API key has sufficient permissions +- `aws_static` is deprecated, needs IAM user/key permissions, and stores a generated static key in state. Use `aws_rbac` or `aws_oidc` when possible. +- `aws_rbac` and `aws_oidc` need IAM role/policy/OIDC permissions. `policy_arn` defaults to `ReadOnlyAccess`; override it for least privilege. RBAC keeps the two historical trusted StackGuardian accounts by default. +- `azure_static` is deprecated. It and `azure_oidc` create an Entra application and assign `Contributor` at subscription scope by default. This is high privilege; use `role_definition_name` to reduce it. Static passwords expire after `8760h` by default; prefer `azure_oidc`. +- `gcp_oidc` needs service-account, workload-identity, and project IAM permissions. `project_role` defaults to high-privilege `roles/owner`; override it for production. Validate the configured issuer, audience, and exact `/orgs/` subject against a real StackGuardian token before applying. +- 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. -**Cloud Connector Failures** -```bash -Error: Unable to assume role -``` -- Check that the role ARN is correct -- Verify the external ID matches your StackGuardian organization -- Ensure the role trust policy allows StackGuardian to assume it +## Role v4 State Migration -**VCS Connector Issues** -```bash -Error: Invalid VCS credentials -``` -- Verify your VCS credentials format -- Check that tokens have required permissions -- Ensure URLs are correct for your VCS provider +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: -### Debug Mode -Enable Terraform debug logging: ```bash -export TF_LOG=DEBUG -terraform apply +terraform state rm 'module.stackguardian_role.stackguardian_role.role' +terraform import 'module.stackguardian_role.stackguardian_rolev4.role' '' +terraform plan ``` -## 🤝 Contributing - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +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. -## 📄 License +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. -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +## Module Usage -## 🆘 Support +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. -- [StackGuardian Documentation](https://docs.stackguardian.io/) -- [StackGuardian Community](https://community.stackguardian.io/) -- [Terraform Provider Documentation](https://registry.terraform.io/providers/StackGuardian/stackguardian/latest/docs) +## Local Checks -## 🏷️ Version Compatibility +Run `task check` for formatting. Run `task validate` for isolated `terraform init -backend=false` and `terraform validate` checks; it copies configurations to a temporary directory and requires network access for provider downloads. Terraform is pinned to `1.5.7` in `.terraform-version`; tasks use `tfenv` when available, otherwise they check the installed `terraform` binary and print installation guidance when it does not match. -| Module Version | StackGuardian Provider | Terraform Version | -|---------------|----------------------|------------------| -| 1.x.x | 1.1.0-rc5 | >= 1.0 | +Run `task test` for native OpenTofu tests. The checked-in `.opentofu-version` pins OpenTofu 1.12.5; tasks use `tofuenv` when available, otherwise they check the installed `tofu` binary and print installation guidance when it does not match. The task copies tested modules to a temporary directory, relaxes only the copied Terraform 1.5.7 version constraint, and resolves the StackGuardian provider from the Terraform Registry because it is not mirrored by the OpenTofu Registry. Tests use mocked StackGuardian providers and plan-only runs, so they do not apply cloud infrastructure or call the StackGuardian API. Cloud applies and remote API behavior remain integration tests. ---- +## Dev Container And CI -**Made with ❤️ by the StackGuardian Community** +The `.devcontainer` image installs the versions pinned in `.terraform-version`, `.opentofu-version`, and `.task-version`, and is supported on Linux `amd64` and `arm64`. Open the repository in a Dev Container to use the same checks environment as CI. GitHub Actions runs `task check`, `task validate`, and `task test` through this devcontainer for pull requests and pushes to `main`. diff --git a/Taskfile.yml b/Taskfile.yml index d9bd406..3601768 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,21 +1,300 @@ version: '3' +silent: true + tasks: + preflight:terraform: + internal: true + cmds: + - | + #!/bin/bash + set -euo pipefail + expected="$(tr -d '[:space:]' < .terraform-version)" + if command -v tfenv >/dev/null 2>&1; then + if ! tfenv install "$expected"; then + printf '%s\n' "WARNING: tfenv could not install Terraform $expected; checking the Terraform binary already on PATH." >&2 + elif ! tfenv use "$expected"; then + printf '%s\n' "WARNING: tfenv could not select Terraform $expected; checking the Terraform binary already on PATH." >&2 + fi + fi + if ! command -v terraform >/dev/null 2>&1; then + printf '%s\n' "Terraform $expected is required but was not found on PATH." >&2 + if command -v tfenv >/dev/null 2>&1; then + printf '%s\n' "Install it with: tfenv install $expected && tfenv use $expected" >&2 + else + printf '%s\n' "Install tfenv, or install Terraform $expected and add it to PATH." >&2 + fi + exit 1 + fi + actual="$(terraform version -json | jq -r '.terraform_version')" + if [ "$actual" != "$expected" ]; then + printf '%s\n' "Terraform version $actual is active; this repository requires $expected." >&2 + if command -v tfenv >/dev/null 2>&1; then + printf '%s\n' "Run: tfenv install $expected && tfenv use $expected" >&2 + else + printf '%s\n' "Install Terraform $expected or install tfenv, then run: tfenv install $expected && tfenv use $expected" >&2 + fi + exit 1 + fi + preflight:tofu: + internal: true + cmds: + - | + #!/bin/bash + set -euo pipefail + expected="$(tr -d '[:space:]' < .opentofu-version)" + if command -v tofuenv >/dev/null 2>&1; then + if ! tofuenv install "$expected"; then + printf '%s\n' "WARNING: tofuenv could not install OpenTofu $expected; checking the OpenTofu binary already on PATH." >&2 + elif ! tofuenv use "$expected"; then + printf '%s\n' "WARNING: tofuenv could not select OpenTofu $expected; checking the OpenTofu binary already on PATH." >&2 + fi + fi + if ! command -v tofu >/dev/null 2>&1; then + printf '%s\n' "OpenTofu $expected is required but was not found on PATH." >&2 + if command -v tofuenv >/dev/null 2>&1; then + printf '%s\n' "Install it with: tofuenv install $expected && tofuenv use $expected" >&2 + else + printf '%s\n' "Install tofuenv, or install OpenTofu $expected and add it to PATH." >&2 + fi + exit 1 + fi + actual="$(tofu version -json | jq -r '.terraform_version')" + if [ "$actual" != "$expected" ]; then + printf '%s\n' "OpenTofu version $actual is active; this repository requires $expected." >&2 + if command -v tofuenv >/dev/null 2>&1; then + printf '%s\n' "Run: tofuenv install $expected && tofuenv use $expected" >&2 + else + printf '%s\n' "Install OpenTofu $expected or install tofuenv, then run: tofuenv install $expected && tofuenv use $expected" >&2 + fi + exit 1 + fi fmt: - desc: Runs terraform fmt in all subdirectories + desc: Formats root and in-scope Terraform modules + deps: [preflight:terraform] cmds: - | #!/bin/bash - # Run in the current directory first terraform fmt - # Find all subdirectories and run terraform fmt in each - for dir in $(ls -d */); - do - echo -e "\nFormatting directory: $dir" - (cd "$dir" && terraform fmt) + for dir in aws_static aws_rbac aws_oidc azure_static azure_oidc gcp_oidc stackguardian_connector_cloud stackguardian_connector_vcs stackguardian_role stackguardian_role_assignment stackguardian_workflow_group; do + terraform -chdir="$dir" fmt + done + check: + desc: Checks Terraform formatting + deps: [preflight:terraform] + cmds: + - | + #!/bin/bash + set -euo pipefail + terraform fmt -check + for dir in aws_static aws_rbac aws_oidc azure_static azure_oidc gcp_oidc stackguardian_connector_cloud stackguardian_connector_vcs stackguardian_role stackguardian_role_assignment stackguardian_workflow_group; do + terraform -chdir="$dir" fmt -check + done + test: + desc: Runs isolated OpenTofu native tests with mocked providers + deps: [preflight:tofu] + cmds: + - | + #!/bin/bash + set -euo pipefail + 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" + tofu -chdir="$target" init -backend=false + tofu -chdir="$target" test done - init: - desc: Initialize this stack for local testing + onboard:aws: + desc: Creates AWS OIDC resources and registers the connector with StackGuardian + deps: [preflight:terraform] + vars: + ONBOARD_VARS_FILE: '{{default "examples/aws-oidc.tfvars.example" .ONBOARD_VARS_FILE}}' + ONBOARD_CONNECTOR_NAME: '{{default "aws-oidc" .ONBOARD_CONNECTOR_NAME}}' + ONBOARD_PLAN_FILE: '{{default "onboard-aws.tfplan" .ONBOARD_PLAN_FILE}}' + ONBOARD_CONFIRM: '{{default "" .ONBOARD_CONFIRM}}' cmds: - - terraform init --upgrade - - cp terraform.tfvars.example terraform.auto.tfvars && terraform validate \ No newline at end of file + - | + #!/bin/bash + set -euo pipefail + if ! command -v aws >/dev/null 2>&1; then + printf '%s\n' "AWS CLI is required. Install it and authenticate with: aws sso login --profile " >&2 + exit 1 + fi + if ! aws sts get-caller-identity >/dev/null; then + printf '%s\n' "No usable AWS CLI credentials were found. Authenticate first, for example: aws sso login --profile " >&2 + exit 1 + fi + if [ -z "${TF_VAR_stackguardian_api_key:-}" ]; then + read -r -s -p "StackGuardian API token: " stackguardian_api_key + printf '\n' + export TF_VAR_stackguardian_api_key="$stackguardian_api_key" + fi + connector_name="{{.ONBOARD_CONNECTOR_NAME}}" + plan_file="{{.ONBOARD_PLAN_FILE}}" + confirmation="{{.ONBOARD_CONFIRM}}" + printf '%s\n' "AWS onboarding will create or reuse the account-level StackGuardian OIDC provider, create the IAM role and policy attachment for $connector_name, and register the StackGuardian connector." + printf '%s\n' "Variables file: {{.ONBOARD_VARS_FILE}}" + printf '%s\n' "Plan file: $plan_file" + if [ "$confirmation" != "$connector_name" ]; then + read -r -p "Type $connector_name to continue: " confirmation + fi + if [ "$confirmation" != "$connector_name" ]; then + printf '%s\n' "Confirmation did not match; no resources were created." >&2 + exit 1 + fi + oidc_provider_arn="arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):oidc-provider/api.app.stackguardian.io" + oidc_provider_address="module.aws_oidc[\"$connector_name\"].aws_iam_openid_connect_provider.oidc_provider" + terraform init + if aws iam get-open-id-connect-provider --open-id-connect-provider-arn "$oidc_provider_arn" >/dev/null 2>&1 && ! terraform state list | grep -Fqx "$oidc_provider_address"; then + terraform import -input=false -var-file="{{.ONBOARD_VARS_FILE}}" "$oidc_provider_address" "$oidc_provider_arn" + fi + terraform plan -input=false -var-file="{{.ONBOARD_VARS_FILE}}" -out="$plan_file" + terraform apply -input=false "$plan_file" + onboard:azure: + desc: Creates Azure OIDC resources and registers the connector with StackGuardian + deps: [preflight:terraform] + vars: + ONBOARD_VARS_FILE: '{{default "examples/azure-oidc.tfvars.example" .ONBOARD_VARS_FILE}}' + ONBOARD_CONNECTOR_NAME: '{{default "azure-oidc" .ONBOARD_CONNECTOR_NAME}}' + ONBOARD_PLAN_FILE: '{{default "onboard-azure.tfplan" .ONBOARD_PLAN_FILE}}' + ONBOARD_CONFIRM: '{{default "" .ONBOARD_CONFIRM}}' + cmds: + - | + #!/bin/bash + set -euo pipefail + if ! command -v az >/dev/null 2>&1; then + printf '%s\n' "Azure CLI is required. Install it and authenticate with: az login --tenant " >&2 + exit 1 + fi + subscription_id="$(az account show --query id --output tsv 2>/dev/null || true)" + tenant_id="$(az account show --query tenantId --output tsv 2>/dev/null || true)" + if [ -z "$subscription_id" ] || [ -z "$tenant_id" ]; then + printf '%s\n' "No active Azure CLI subscription was found. Authenticate first with: az login --tenant " >&2 + exit 1 + fi + if [ -z "${TF_VAR_stackguardian_api_key:-}" ]; then + read -r -s -p "StackGuardian API token: " stackguardian_api_key + printf '\n' + export TF_VAR_stackguardian_api_key="$stackguardian_api_key" + fi + connector_name="{{.ONBOARD_CONNECTOR_NAME}}" + plan_file="{{.ONBOARD_PLAN_FILE}}" + confirmation="{{.ONBOARD_CONFIRM}}" + printf '%s\n' "Azure onboarding will create the Entra application, service principal, federated credential, subscription role assignment, and StackGuardian connector for $connector_name." + printf '%s\n' "Variables file: {{.ONBOARD_VARS_FILE}}" + printf '%s\n' "Plan file: $plan_file" + if [ "$confirmation" != "$connector_name" ]; then + read -r -p "Type $connector_name to continue: " confirmation + fi + if [ "$confirmation" != "$connector_name" ]; then + printf '%s\n' "Confirmation did not match; no resources were created." >&2 + exit 1 + fi + terraform init + export ARM_SUBSCRIPTION_ID="$subscription_id" + export ARM_TENANT_ID="$tenant_id" + export AZUREAD_TENANT_ID="$tenant_id" + terraform plan -input=false -var-file="{{.ONBOARD_VARS_FILE}}" -out="$plan_file" + terraform apply -input=false "$plan_file" + destroy:aws: + desc: Destroys an AWS OIDC role and StackGuardian connector, retaining any shared OIDC provider + deps: [preflight:terraform] + vars: + ONBOARD_VARS_FILE: '{{default "examples/aws-oidc.tfvars.example" .ONBOARD_VARS_FILE}}' + ONBOARD_CONNECTOR_NAME: '{{default "aws-oidc" .ONBOARD_CONNECTOR_NAME}}' + ONBOARD_DESTROY_CONFIRM: '{{default "" .ONBOARD_DESTROY_CONFIRM}}' + ONBOARD_PLAN_FILE: '{{default "destroy-aws.tfplan" .ONBOARD_PLAN_FILE}}' + cmds: + - | + #!/bin/bash + set -euo pipefail + if ! command -v aws >/dev/null 2>&1 || ! aws sts get-caller-identity >/dev/null; then + printf '%s\n' "A logged-in AWS CLI is required. Authenticate first, for example: aws sso login --profile " >&2 + exit 1 + fi + if [ -z "${TF_VAR_stackguardian_api_key:-}" ]; then + read -r -s -p "StackGuardian API token: " stackguardian_api_key + printf '\n' + export TF_VAR_stackguardian_api_key="$stackguardian_api_key" + fi + connector_name="{{.ONBOARD_CONNECTOR_NAME}}" + confirmation="{{.ONBOARD_DESTROY_CONFIRM}}" + if [ "$confirmation" != "$connector_name" ]; then + read -r -p "Type $connector_name to destroy its AWS OIDC role and StackGuardian connector: " confirmation + fi + if [ "$confirmation" != "$connector_name" ]; then + printf '%s\n' "Confirmation did not match; no resources were destroyed." >&2 + exit 1 + fi + plan_file="{{.ONBOARD_PLAN_FILE}}" + terraform init + terraform plan -destroy -input=false -var-file="{{.ONBOARD_VARS_FILE}}" -out="$plan_file" + terraform apply -input=false "$plan_file" + destroy:azure: + desc: Destroys an Azure OIDC identity and StackGuardian connector + deps: [preflight:terraform] + vars: + ONBOARD_VARS_FILE: '{{default "examples/azure-oidc.tfvars.example" .ONBOARD_VARS_FILE}}' + ONBOARD_CONNECTOR_NAME: '{{default "azure-oidc" .ONBOARD_CONNECTOR_NAME}}' + ONBOARD_DESTROY_CONFIRM: '{{default "" .ONBOARD_DESTROY_CONFIRM}}' + ONBOARD_PLAN_FILE: '{{default "destroy-azure.tfplan" .ONBOARD_PLAN_FILE}}' + cmds: + - | + #!/bin/bash + set -euo pipefail + if ! command -v az >/dev/null 2>&1; then + printf '%s\n' "Azure CLI is required. Install it and authenticate with: az login --tenant " >&2 + exit 1 + fi + subscription_id="$(az account show --query id --output tsv 2>/dev/null || true)" + tenant_id="$(az account show --query tenantId --output tsv 2>/dev/null || true)" + if [ -z "$subscription_id" ] || [ -z "$tenant_id" ]; then + printf '%s\n' "No active Azure CLI subscription was found. Authenticate first with: az login --tenant " >&2 + exit 1 + fi + if [ -z "${TF_VAR_stackguardian_api_key:-}" ]; then + read -r -s -p "StackGuardian API token: " stackguardian_api_key + printf '\n' + export TF_VAR_stackguardian_api_key="$stackguardian_api_key" + fi + connector_name="{{.ONBOARD_CONNECTOR_NAME}}" + confirmation="{{.ONBOARD_DESTROY_CONFIRM}}" + if [ "$confirmation" != "$connector_name" ]; then + read -r -p "Type $connector_name to destroy its Azure OIDC identity and StackGuardian connector: " confirmation + fi + if [ "$confirmation" != "$connector_name" ]; then + printf '%s\n' "Confirmation did not match; no resources were destroyed." >&2 + exit 1 + fi + plan_file="{{.ONBOARD_PLAN_FILE}}" + terraform init + export ARM_SUBSCRIPTION_ID="$subscription_id" + export ARM_TENANT_ID="$tenant_id" + export AZUREAD_TENANT_ID="$tenant_id" + terraform plan -destroy -input=false -var-file="{{.ONBOARD_VARS_FILE}}" -out="$plan_file" + terraform apply -input=false "$plan_file" + validate: + desc: Initializes and validates isolated copies of in-scope configurations + deps: [preflight:terraform] + cmds: + - | + #!/bin/bash + set -euo pipefail + root="$(pwd)" + temp="$(mktemp -d)" + trap 'rm -rf "$temp"' EXIT + for dir in . aws_static aws_rbac aws_oidc azure_static azure_oidc gcp_oidc stackguardian_connector_cloud stackguardian_connector_vcs stackguardian_role stackguardian_role_assignment stackguardian_workflow_group; do + 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" + else + tar --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$root" "$dir" | tar -xf - -C "$temp" + fi + terraform -chdir="$target" init -backend=false + terraform -chdir="$target" validate + done diff --git a/aws_oidc/README.md b/aws_oidc/README.md new file mode 100644 index 0000000..61e26d9 --- /dev/null +++ b/aws_oidc/README.md @@ -0,0 +1,14 @@ +# AWS OIDC Identity + +Creates an AWS IAM OIDC provider, IAM role, and role-policy attachment. The caller needs IAM OIDC, role, and policy-attachment permissions. `policy_arn` defaults to `ReadOnlyAccess` and should be narrowed for production. + +```hcl +module "aws_oidc" { + source = "./aws_oidc" + aws_region = "eu-central-1" + iam_role_name = "StackGuardianOidcRole" + stackguardian_org_name = "example-org" +} +``` + +Outputs: `oidc_provider_arn`, `oidc_role_arn`. diff --git a/aws_oidc/main.tf b/aws_oidc/main.tf index d9e0802..26019d9 100644 --- a/aws_oidc/main.tf +++ b/aws_oidc/main.tf @@ -1,28 +1,26 @@ -# Step 1: Create an OpenID Connect provider in AWS IAM resource "aws_iam_openid_connect_provider" "oidc_provider" { - url = "https://api.app.stackguardian.io" # OIDC provider URL - client_id_list = ["https://api.app.stackguardian.io"] # OIDC client ID or the Audience id - thumbprint_list = [] + url = var.oidc_issuer_url + client_id_list = [var.oidc_audience] + thumbprint_list = var.oidc_thumbprint_list } -# Step 2: Create an IAM role that can be assumed by users authenticated through the OIDC provider resource "aws_iam_role" "oidc_role" { - name = var.role_name + name = var.iam_role_name assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { "Effect" : "Allow", "Principal" : { - "Federated" : "arn:aws:iam::${var.account_number}:oidc-provider/api.app.stackguardian.io" + "Federated" : aws_iam_openid_connect_provider.oidc_provider.arn }, "Action" : "sts:AssumeRoleWithWebIdentity", "Condition" : { "StringEquals" : { - "api.app.stackguardian.io:aud" = "https://api.app.stackguardian.io" + "api.app.stackguardian.io:aud" = var.oidc_audience }, "StringLike" : { - "api.app.stackguardian.io:sub" = "/orgs/${var.org_name}" + "api.app.stackguardian.io:sub" = "/orgs/${var.stackguardian_org_name}" } } } @@ -30,8 +28,7 @@ resource "aws_iam_role" "oidc_role" { }) } -resource "aws_iam_policy_attachment" "sg_role_policy" { - name = "${var.role_name}-policy" - policy_arn = var.aws_policy - roles = [aws_iam_role.oidc_role.name] +resource "aws_iam_role_policy_attachment" "sg_role_policy" { + role = aws_iam_role.oidc_role.name + policy_arn = var.policy_arn } diff --git a/aws_oidc/output.tf b/aws_oidc/output.tf deleted file mode 100644 index bc69d18..0000000 --- a/aws_oidc/output.tf +++ /dev/null @@ -1,7 +0,0 @@ -output "oidc_provider_arn" { - value = aws_iam_openid_connect_provider.oidc_provider.arn -} - -output "oidc_role_arn" { - value = aws_iam_role.oidc_role.arn -} \ No newline at end of file diff --git a/aws_oidc/outputs.tf b/aws_oidc/outputs.tf new file mode 100644 index 0000000..d40d0df --- /dev/null +++ b/aws_oidc/outputs.tf @@ -0,0 +1,9 @@ +output "oidc_provider_arn" { + description = "ARN of the AWS IAM OIDC provider." + value = aws_iam_openid_connect_provider.oidc_provider.arn +} + +output "oidc_role_arn" { + description = "ARN of the AWS IAM role trusted by the OIDC provider." + value = aws_iam_role.oidc_role.arn +} diff --git a/aws_oidc/provider.tf b/aws_oidc/provider.tf new file mode 100644 index 0000000..958eda3 --- /dev/null +++ b/aws_oidc/provider.tf @@ -0,0 +1,10 @@ +terraform { + required_version = "= 1.5.7" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.58.0, < 7.0.0" + } + } +} diff --git a/aws_oidc/variables.tf b/aws_oidc/variables.tf index 40e6987..479f8b4 100644 --- a/aws_oidc/variables.tf +++ b/aws_oidc/variables.tf @@ -1,24 +1,43 @@ -variable "region" { +variable "aws_region" { type = string - description = "the region for deploying the resources" + description = "AWS region used by the provider." + + validation { + condition = can(regex("^[a-z]{2}(-gov)?-[a-z]+-\\d$", var.aws_region)) + error_message = "aws_region must be a valid AWS region." + } } -variable "role_name" { +variable "iam_role_name" { type = string - description = "name of the aws role thats getting created" + description = "Name of the IAM role created for StackGuardian OIDC." } -variable "org_name" { +variable "stackguardian_org_name" { type = string - description = "the name of the StackGuardian Organization" + description = "StackGuardian organization name used in the OIDC subject." } -variable "account_number" { - type = number - description = "the value of the account number" +variable "policy_arn" { + type = string + description = "IAM policy attached to the OIDC role. ReadOnlyAccess is a permissive default." + default = "arn:aws:iam::aws:policy/ReadOnlyAccess" } -variable "aws_policy" { +variable "oidc_issuer_url" { type = string - description = "arn of aws policy" + description = "StackGuardian OIDC issuer URL." + default = "https://api.app.stackguardian.io" +} + +variable "oidc_audience" { + type = string + description = "OIDC audience accepted by the IAM role." + default = "https://api.app.stackguardian.io" +} + +variable "oidc_thumbprint_list" { + type = list(string) + description = "Trusted TLS certificate thumbprints for the OIDC issuer." + default = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"] } diff --git a/aws_rbac/README.md b/aws_rbac/README.md new file mode 100644 index 0000000..4d29d21 --- /dev/null +++ b/aws_rbac/README.md @@ -0,0 +1,15 @@ +# AWS RBAC Identity + +Creates an IAM role trusted by StackGuardian accounts. The caller needs IAM role and policy-attachment permissions. `policy_arn` defaults to `ReadOnlyAccess`; supply a least-privilege policy for production. + +```hcl +module "aws_rbac" { + source = "./aws_rbac" + iam_role_name = "StackGuardianRole" + role_external_id = "example-org:external-id" +} +``` + +The default `trusted_account_ids` preserves the two historical StackGuardian accounts and is configurable. + +Output: `iam_role_arn`. diff --git a/aws_rbac/main.tf b/aws_rbac/main.tf index e78c416..b9aa4e2 100644 --- a/aws_rbac/main.tf +++ b/aws_rbac/main.tf @@ -1,5 +1,5 @@ -resource "aws_iam_role" "sg-test-role" { - name = var.aws_role_name +resource "aws_iam_role" "sg_role" { + name = var.iam_role_name description = "StackGuardianIntegrationRole" assume_role_policy = jsonencode({ Version = "2012-10-17" @@ -7,14 +7,12 @@ resource "aws_iam_role" "sg-test-role" { { Effect = "Allow" Principal = { - AWS = ["arn:aws:iam::476299211833:root", - "arn:aws:iam::163602625436:root" - ] + AWS = [for account_id in var.trusted_account_ids : "arn:aws:iam::${account_id}:root"] } Action = "sts:AssumeRole" Condition = { StringEquals = { - "sts:ExternalId" = var.role_external_id # Replace with your external ID + "sts:ExternalId" = var.role_external_id } } } @@ -22,8 +20,7 @@ resource "aws_iam_role" "sg-test-role" { }) } -resource "aws_iam_policy_attachment" "sg_role_policy" { - name = "${var.aws_role_name}-policy" - policy_arn = var.aws_policy - roles = [aws_iam_role.sg-test-role.name] +resource "aws_iam_role_policy_attachment" "sg_role_policy" { + role = aws_iam_role.sg_role.name + policy_arn = var.policy_arn } diff --git a/aws_rbac/outputs.tf b/aws_rbac/outputs.tf new file mode 100644 index 0000000..1da513f --- /dev/null +++ b/aws_rbac/outputs.tf @@ -0,0 +1,4 @@ +output "iam_role_arn" { + description = "ARN of the IAM role trusted by StackGuardian." + value = aws_iam_role.sg_role.arn +} diff --git a/aws_rbac/provider.tf b/aws_rbac/provider.tf index 57f6c66..958eda3 100644 --- a/aws_rbac/provider.tf +++ b/aws_rbac/provider.tf @@ -1,12 +1,10 @@ terraform { + required_version = "= 1.5.7" + required_providers { aws = { source = "hashicorp/aws" - version = "5.72.1" + version = ">= 6.58.0, < 7.0.0" } } } - -provider "aws" { - region = var.aws_region -} diff --git a/aws_rbac/variables.tf b/aws_rbac/variables.tf index 724dbee..f749f5e 100644 --- a/aws_rbac/variables.tf +++ b/aws_rbac/variables.tf @@ -1,22 +1,48 @@ variable "aws_region" { type = string + description = "AWS region used by the provider." default = "eu-central-1" - description = "AWS region" + + validation { + condition = can(regex("^[a-z]{2}(-gov)?-[a-z]+-\\d$", var.aws_region)) + error_message = "aws_region must be a valid AWS region." + } } -variable "aws_role_name" { +variable "iam_role_name" { type = string - description = "Name of the Role within AWS" + description = "Name of the IAM role created for StackGuardian." } variable "role_external_id" { - type = string - # Example 'my-stackguardian-org:abc12345' - description = "ExternalID for the Role in AWS - needs to start with the StackGuardian Organisation name." + type = string + description = "External ID required by the StackGuardian AWS RBAC connector." + sensitive = true + + validation { + condition = length(trimspace(var.role_external_id)) > 0 + error_message = "role_external_id must not be empty." + } } -variable "aws_policy" { +variable "policy_arn" { type = string - description = "ARN of the AWS Policy to be applied" + description = "IAM policy attached to the StackGuardian role. ReadOnlyAccess is a permissive default." default = "arn:aws:iam::aws:policy/ReadOnlyAccess" + + validation { + condition = can(regex("^arn:aws[a-z-]*:iam::(aws|\\d{12}):policy/.+$", var.policy_arn)) + error_message = "policy_arn must be a valid IAM policy ARN." + } +} + +variable "trusted_account_ids" { + type = list(string) + description = "AWS account IDs trusted to assume the role." + default = ["476299211833", "163602625436"] + + validation { + condition = length(var.trusted_account_ids) > 0 && alltrue([for account_id in var.trusted_account_ids : can(regex("^\\d{12}$", account_id))]) + error_message = "trusted_account_ids must contain one or more 12-digit AWS account IDs." + } } diff --git a/aws_static/README.md b/aws_static/README.md new file mode 100644 index 0000000..e674aff --- /dev/null +++ b/aws_static/README.md @@ -0,0 +1,16 @@ +# AWS Static Connector Identity + +> **Deprecated:** Static AWS credentials are retained in Terraform state and can appear in plan artifacts. Prefer `aws_rbac` or `aws_oidc`. + +Creates an IAM user and access key for an AWS static connector. The caller needs IAM user and access-key permissions; the generated secret is sensitive but remains in Terraform state. Creation requires an explicit acknowledgement. + +```hcl +module "aws_static" { + source = "./aws_static" + iam_user = "stackguardian-static" + aws_region = "eu-central-1" + allow_static_credentials = true +} +``` + +Outputs: `access_key_id`, `secret_access_key` (sensitive). diff --git a/aws_static/main.tf b/aws_static/main.tf index fbf1409..70ce682 100644 --- a/aws_static/main.tf +++ b/aws_static/main.tf @@ -1,16 +1,30 @@ resource "aws_iam_user" "new-user" { - name = var.iam_user # Change the user name as needed + name = var.iam_user + + lifecycle { + precondition { + condition = var.allow_static_credentials + error_message = "Static AWS credentials are deprecated. Set allow_static_credentials = true only when required; prefer aws_rbac or aws_oidc." + } + } } resource "aws_iam_access_key" "my_access_key" { user = aws_iam_user.new-user.name -} -output "access_key_id" { - value = aws_iam_access_key.my_access_key.id + lifecycle { + precondition { + condition = var.allow_static_credentials + error_message = "Static AWS credentials are deprecated. Set allow_static_credentials = true only when required; prefer aws_rbac or aws_oidc." + } + } } -output "secret_access_key" { - value = aws_iam_access_key.my_access_key.secret - sensitive = true # This will hide the secret in Terraform outputs +resource "terraform_data" "static_credentials_deprecation" { + count = var.allow_static_credentials ? 1 : 0 + input = "Static AWS credentials are deprecated; migrate to aws_rbac or aws_oidc." + + provisioner "local-exec" { + command = "printf '%s\\n' 'WARNING: Static AWS credentials are deprecated; migrate to aws_rbac or aws_oidc.'" + } } diff --git a/aws_static/outputs.tf b/aws_static/outputs.tf new file mode 100644 index 0000000..724f1cd --- /dev/null +++ b/aws_static/outputs.tf @@ -0,0 +1,10 @@ +output "access_key_id" { + description = "Generated IAM access key ID." + value = aws_iam_access_key.my_access_key.id +} + +output "secret_access_key" { + description = "Generated IAM secret access key. It is retained in Terraform state." + value = aws_iam_access_key.my_access_key.secret + sensitive = true +} diff --git a/aws_static/provider.tf b/aws_static/provider.tf index 9d9e325..958eda3 100644 --- a/aws_static/provider.tf +++ b/aws_static/provider.tf @@ -1,12 +1,10 @@ terraform { + required_version = "= 1.5.7" + required_providers { aws = { source = "hashicorp/aws" - version = "5.72.1" + version = ">= 6.58.0, < 7.0.0" } } } - -provider "aws" { - region = var.aws_region -} \ No newline at end of file diff --git a/aws_static/variables.tf b/aws_static/variables.tf index 9df860f..0f12134 100644 --- a/aws_static/variables.tf +++ b/aws_static/variables.tf @@ -2,10 +2,21 @@ variable "aws_region" { type = string default = "eu-central-1" description = "AWS region" + + validation { + condition = can(regex("^[a-z]{2}(-gov)?-[a-z]+-\\d$", var.aws_region)) + error_message = "aws_region must be a valid AWS region." + } } variable "iam_user" { type = string default = "example" - description = "name of the iam user created" -} \ No newline at end of file + description = "Name of the IAM user created. Its generated static secret is retained in Terraform state." +} + +variable "allow_static_credentials" { + type = bool + default = false + description = "Acknowledges deprecated static AWS credentials. Prefer aws_rbac or aws_oidc." +} diff --git a/azure_oidc/README.md b/azure_oidc/README.md new file mode 100644 index 0000000..02d968a --- /dev/null +++ b/azure_oidc/README.md @@ -0,0 +1,15 @@ +# Azure OIDC Identity + +Creates an Entra application, service principal, subscription assignment, and federated credential. The managing identity needs Microsoft Graph application-management plus subscription role-assignment privileges and can authenticate with `az login`. `role_definition_name` defaults to high-privilege `Contributor`. + +```hcl +module "azure_oidc" { + source = "./azure_oidc" + subscription_id = "00000000-0000-0000-0000-000000000000" + tenant_id = "00000000-0000-0000-0000-000000000000" + application_display_name = "stackguardian-oidc" + stackguardian_org_name = "example-org" +} +``` + +Outputs: `client_id`, `tenant_id`, `subscription_id`, `federated_credential_id`. diff --git a/azure_oidc/main.tf b/azure_oidc/main.tf index b7817f2..b65954c 100644 --- a/azure_oidc/main.tf +++ b/azure_oidc/main.tf @@ -1,31 +1,26 @@ data "azuread_client_config" "current" {} -data "azurerm_subscription" "current" {} -# Create Azure AD application resource "azuread_application" "app_registration" { - display_name = var.display_name + display_name = var.application_display_name owners = [data.azuread_client_config.current.object_id] } -# Create the Service Principal resource "azuread_service_principal" "sg_sp" { client_id = azuread_application.app_registration.client_id owners = [data.azuread_client_config.current.object_id] app_role_assignment_required = false } -# Assign Contributor role to the Service Principal at the subscription level -resource "azurerm_role_assignment" "example" { +resource "azurerm_role_assignment" "subscription" { principal_id = azuread_service_principal.sg_sp.object_id - role_definition_name = "Contributor" - scope = data.azurerm_subscription.current.id + role_definition_name = var.role_definition_name + scope = "/subscriptions/${var.subscription_id}" } -# Configure Workload Identity (Federated Credential) resource "azuread_application_federated_identity_credential" "sg_fed_id_creds" { application_id = azuread_application.app_registration.id - display_name = "sg-federated-identity" - audiences = ["https://api.app.stackguardian.io"] - issuer = "https://api.app.stackguardian.io" - subject = "/orgs/${var.sg_org_name}" + display_name = var.federated_credential_name + audiences = var.oidc_audiences + issuer = var.oidc_issuer + subject = coalesce(var.oidc_subject, "/orgs/${var.stackguardian_org_name}") } diff --git a/azure_oidc/outputs.tf b/azure_oidc/outputs.tf new file mode 100644 index 0000000..a7db426 --- /dev/null +++ b/azure_oidc/outputs.tf @@ -0,0 +1,19 @@ +output "client_id" { + description = "Client ID of the created Entra application." + value = azuread_application.app_registration.client_id +} + +output "tenant_id" { + description = "Tenant ID of the created Entra application." + value = var.tenant_id +} + +output "subscription_id" { + description = "Subscription scope of the role assignment." + value = var.subscription_id +} + +output "federated_credential_id" { + description = "ID of the StackGuardian federated identity credential." + value = azuread_application_federated_identity_credential.sg_fed_id_creds.id +} diff --git a/azure_oidc/provider.tf b/azure_oidc/provider.tf index 6b606c5..7dd8cc3 100644 --- a/azure_oidc/provider.tf +++ b/azure_oidc/provider.tf @@ -1,26 +1,14 @@ terraform { + required_version = "= 1.5.7" + required_providers { azurerm = { source = "hashicorp/azurerm" - version = "=4.6.0" + version = ">= 5.0.1, < 6.0.0" } azuread = { source = "hashicorp/azuread" - version = "=3.0.2" + version = ">= 3.9.0, < 4.0.0" } } } - -provider "azurerm" { - features { - - } - subscription_id = var.subscription_id - client_id = var.client_id - client_secret = var.client_secret - tenant_id = var.tenant_id -} - -provider "azuread" { - tenant_id = var.tenant_id -} \ No newline at end of file diff --git a/azure_oidc/variables.tf b/azure_oidc/variables.tf index 97d8a16..43e5400 100644 --- a/azure_oidc/variables.tf +++ b/azure_oidc/variables.tf @@ -1,25 +1,54 @@ variable "subscription_id" { - type = string + type = string + description = "Azure subscription ID where the role assignment is created." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.subscription_id)) + error_message = "subscription_id must be a UUID." + } } -variable "client_id" { - type = string +variable "tenant_id" { + type = string + description = "Azure tenant ID." } -variable "client_secret" { - type = string +variable "application_display_name" { + type = string + description = "Display name for the Entra application." } -variable "tenant_id" { - type = string +variable "stackguardian_org_name" { + type = string + description = "StackGuardian organization name used in the default OIDC subject." +} + +variable "role_definition_name" { + type = string + description = "Azure subscription role assigned to the created service principal. Contributor is a high-privilege default." + default = "Contributor" +} + +variable "oidc_issuer" { + type = string + description = "OIDC issuer URI." + default = "https://api.app.stackguardian.io" +} + +variable "oidc_audiences" { + type = list(string) + description = "OIDC audiences accepted by the federated credential." + default = ["https://api.app.stackguardian.io"] } -variable "display_name" { +variable "oidc_subject" { type = string - description = "Display name for the Azure AD application" + description = "OIDC subject accepted by the federated credential." + default = null } -variable "sg_org_name" { +variable "federated_credential_name" { type = string - description = "StackGuardian organization name" -} \ No newline at end of file + description = "Display name of the federated identity credential." + default = "sg-federated-identity" +} diff --git a/azure_static/README.md b/azure_static/README.md new file mode 100644 index 0000000..58b3105 --- /dev/null +++ b/azure_static/README.md @@ -0,0 +1,17 @@ +# Azure Static Identity + +> **Deprecated:** Static Azure credentials are retained in Terraform state and can appear in plan artifacts. Prefer `azure_oidc`. + +Creates an Entra application, service principal, finite-lifetime client secret, and subscription assignment. The managing identity needs Microsoft Graph application-management plus subscription role-assignment privileges and can authenticate with `az login`. `role_definition_name` defaults to high-privilege `Contributor`. Creation requires an explicit acknowledgement. + +```hcl +module "azure_static" { + source = "./azure_static" + subscription_id = "00000000-0000-0000-0000-000000000000" + tenant_id = "00000000-0000-0000-0000-000000000000" + application_display_name = "stackguardian-static" + allow_static_credentials = true +} +``` + +Outputs: `client_id`, `tenant_id`, `subscription_id`, `client_secret_value` (sensitive), `client_secret_id`. diff --git a/azure_static/main.tf b/azure_static/main.tf index e73f66e..391e6e5 100644 --- a/azure_static/main.tf +++ b/azure_static/main.tf @@ -1,43 +1,60 @@ data "azuread_client_config" "current" {} -data "azurerm_subscription" "current" {} -# Create Azure AD application resource "azuread_application" "app_registration" { - display_name = var.AD_name + display_name = var.application_display_name owners = [data.azuread_client_config.current.object_id] + + lifecycle { + precondition { + condition = var.allow_static_credentials + error_message = "Static Azure credentials are deprecated. Set allow_static_credentials = true only when required; prefer azure_oidc." + } + } } -# Create the Service Principal resource "azuread_service_principal" "sg_sp" { client_id = azuread_application.app_registration.client_id owners = [data.azuread_client_config.current.object_id] app_role_assignment_required = false + + lifecycle { + precondition { + condition = var.allow_static_credentials + error_message = "Static Azure credentials are deprecated. Set allow_static_credentials = true only when required; prefer azure_oidc." + } + } } -# Assign Contributor role to the Service Principal at the subscription level -resource "azurerm_role_assignment" "example" { +resource "azurerm_role_assignment" "subscription" { principal_id = azuread_service_principal.sg_sp.object_id - role_definition_name = "Contributor" - scope = data.azurerm_subscription.current.id + role_definition_name = var.role_definition_name + scope = "/subscriptions/${var.subscription_id}" + + lifecycle { + precondition { + condition = var.allow_static_credentials + error_message = "Static Azure credentials are deprecated. Set allow_static_credentials = true only when required; prefer azure_oidc." + } + } } -# Step 3: Create a Client Secret for the Service Principal resource "azuread_service_principal_password" "client_secret" { service_principal_id = azuread_service_principal.sg_sp.id -} + end_date_relative = var.service_principal_password_end_date_relative -# Step 4: Output the Client Secret Value (ID will be available in the Service Principal) -output "client_secret_value" { - value = azuread_service_principal_password.client_secret.value - sensitive = true + lifecycle { + precondition { + condition = var.allow_static_credentials + error_message = "Static Azure credentials are deprecated. Set allow_static_credentials = true only when required; prefer azure_oidc." + } + } } -# Step 5: Output the Client ID (Application ID) -output "client_id" { - value = azuread_application.app_registration.client_id -} +resource "terraform_data" "static_credentials_deprecation" { + count = var.allow_static_credentials ? 1 : 0 + input = "Static Azure credentials are deprecated; migrate to azure_oidc." -# Step 6: Output the Client Secret ID (from the service principal password) -output "client_secret_id" { - value = azuread_service_principal_password.client_secret.id -} \ No newline at end of file + provisioner "local-exec" { + command = "printf '%s\\n' 'WARNING: Static Azure credentials are deprecated; migrate to azure_oidc.'" + } +} diff --git a/azure_static/outputs.tf b/azure_static/outputs.tf new file mode 100644 index 0000000..9c85436 --- /dev/null +++ b/azure_static/outputs.tf @@ -0,0 +1,25 @@ +output "client_secret_value" { + description = "Generated service principal secret. It is stored in Terraform state." + value = azuread_service_principal_password.client_secret.value + sensitive = true +} + +output "client_id" { + description = "Client ID of the created Entra application." + value = azuread_application.app_registration.client_id +} + +output "tenant_id" { + description = "Tenant ID for the created service principal." + value = var.tenant_id +} + +output "subscription_id" { + description = "Subscription ID where the role assignment was created." + value = var.subscription_id +} + +output "client_secret_id" { + description = "ID of the generated service principal password." + value = azuread_service_principal_password.client_secret.id +} diff --git a/azure_static/provider.tf b/azure_static/provider.tf index 6b606c5..7dd8cc3 100644 --- a/azure_static/provider.tf +++ b/azure_static/provider.tf @@ -1,26 +1,14 @@ terraform { + required_version = "= 1.5.7" + required_providers { azurerm = { source = "hashicorp/azurerm" - version = "=4.6.0" + version = ">= 5.0.1, < 6.0.0" } azuread = { source = "hashicorp/azuread" - version = "=3.0.2" + version = ">= 3.9.0, < 4.0.0" } } } - -provider "azurerm" { - features { - - } - subscription_id = var.subscription_id - client_id = var.client_id - client_secret = var.client_secret - tenant_id = var.tenant_id -} - -provider "azuread" { - tenant_id = var.tenant_id -} \ No newline at end of file diff --git a/azure_static/variables.tf b/azure_static/variables.tf index 7be88ef..025e4bb 100644 --- a/azure_static/variables.tf +++ b/azure_static/variables.tf @@ -1,20 +1,47 @@ variable "subscription_id" { - type = string + type = string + description = "Azure subscription ID where the Contributor assignment is created." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.subscription_id)) + error_message = "subscription_id must be a UUID." + } } -variable "client_id" { - type = string +variable "tenant_id" { + type = string + description = "Azure tenant ID." + + validation { + condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.tenant_id)) + error_message = "tenant_id must be a UUID." + } } -variable "client_secret" { - type = string +variable "application_display_name" { + type = string + description = "Display name for the Entra application." } -variable "tenant_id" { - type = string +variable "role_definition_name" { + type = string + description = "Azure subscription role assigned to the created service principal. Contributor is a high-privilege default." + default = "Contributor" } -variable "AD_name" { +variable "service_principal_password_end_date_relative" { type = string - description = "name of the azure active directory" -} \ No newline at end of file + description = "Finite lifetime for the generated service principal secret." + default = "8760h" + + validation { + condition = can(regex("^[1-9][0-9]*h$", var.service_principal_password_end_date_relative)) + error_message = "service_principal_password_end_date_relative must be a positive number of hours." + } +} + +variable "allow_static_credentials" { + type = bool + default = false + description = "Acknowledges deprecated static Azure credentials. Prefer azure_oidc." +} diff --git a/examples/aws-oidc.tfvars.example b/examples/aws-oidc.tfvars.example new file mode 100644 index 0000000..ef055ab --- /dev/null +++ b/examples/aws-oidc.tfvars.example @@ -0,0 +1,18 @@ +# Authenticate before onboarding: aws sso login --profile +# The task uses the AWS credential chain, including AWS_PROFILE. +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" +}] + +vcs_connectors = {} +role_name = "engineering-developer" +template_list = ["terraform-aws-vpc"] +subject = "developer@example.invalid" +entity_type = "EMAIL" diff --git a/examples/azure-oidc.tfvars.example b/examples/azure-oidc.tfvars.example new file mode 100644 index 0000000..84cba19 --- /dev/null +++ b/examples/azure-oidc.tfvars.example @@ -0,0 +1,19 @@ +# Authenticate before onboarding: az login --tenant +# The subscription and tenant must match the active Azure CLI account. +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" +}] + +vcs_connectors = {} +role_name = "engineering-developer" +template_list = ["terraform-azurerm-resource-group"] +subject = "developer@example.invalid" +entity_type = "EMAIL" diff --git a/gcp_oidc/README.md b/gcp_oidc/README.md new file mode 100644 index 0000000..d78cb15 --- /dev/null +++ b/gcp_oidc/README.md @@ -0,0 +1,21 @@ +# GCP OIDC Identity + +Creates a service account, workload identity pool/provider, and narrowly scoped IAM members. The caller needs service-account, workload-identity, and project-IAM permissions. `project_role` defaults to high-privilege `roles/owner`; override it for production. + +```hcl +module "gcp_oidc" { + source = "./gcp_oidc" + project_id = "example-project" + region = "europe-west3" + stackguardian_org_name = "example-org" + service_account_id = "stackguardian" + workload_identity_pool_id = "stackguardian" + workload_identity_pool_provider_id = "stackguardian-oidc" + workload_identity_pool_display_name = "StackGuardian" + project_role = "roles/viewer" +} +``` + +Validate issuer, audience, and the exact serialized subject with a real token before applying. Import the self-member binding before removing the previous authoritative IAM-policy state. + +Outputs: `service_account_email`, `workload_identity_pool_id`, `workload_identity_pool_provider_id`. diff --git a/gcp_oidc/main.tf b/gcp_oidc/main.tf index f0dfe23..3f9e65e 100644 --- a/gcp_oidc/main.tf +++ b/gcp_oidc/main.tf @@ -1,64 +1,74 @@ -#StackGuardian OIDC Connector locals { - sg-org-id = var.sg-org-id + oidc_subject = coalesce(var.oidc_subject, "/orgs/${var.stackguardian_org_name}") } -# Create Service Account -resource "google_service_account" "sg-service-account" { +moved { + from = google_service_account.sg-service-account + to = google_service_account.stackguardian +} + +moved { + from = google_iam_workload_identity_pool.sg-pool + to = google_iam_workload_identity_pool.stackguardian +} + +moved { + from = google_iam_workload_identity_pool_provider.sg-oidc-connector-provider-x + to = google_iam_workload_identity_pool_provider.stackguardian +} + +moved { + from = google_service_account_iam_member.allow_federation_impersonation + to = google_service_account_iam_member.federation_impersonation +} + +moved { + from = google_project_iam_member.sg-service-account-iam + to = google_project_iam_member.stackguardian +} + +resource "google_service_account" "stackguardian" { account_id = var.service_account_id display_name = "StackGuardian Service Account" + description = "Service account used by StackGuardian workload identity federation." + project = var.project_id } - -resource "google_iam_workload_identity_pool" "sg-pool" { +resource "google_iam_workload_identity_pool" "stackguardian" { workload_identity_pool_id = var.workload_identity_pool_id + project = var.project_id } -resource "google_iam_workload_identity_pool_provider" "sg-oidc-connector-provider-x" { - workload_identity_pool_id = google_iam_workload_identity_pool.sg-pool.workload_identity_pool_id +resource "google_iam_workload_identity_pool_provider" "stackguardian" { + workload_identity_pool_id = google_iam_workload_identity_pool.stackguardian.workload_identity_pool_id workload_identity_pool_provider_id = var.workload_identity_pool_provider_id display_name = var.workload_identity_pool_display_name - description = "OIDC identity pool provider for StackGuardian Connector" - disabled = false + description = "OIDC identity pool provider for StackGuardian." + project = var.project_id attribute_mapping = { "google.subject" = "assertion.sub" } oidc { - allowed_audiences = ["https://testapi.qa.stackguardian.io"] # https://api.app.stackguardian.io - issuer_uri = "https://testapi.qa.stackguardian.io" + allowed_audiences = var.oidc_allowed_audiences + issuer_uri = var.oidc_issuer_uri } } - -# Allow Service Account Impersonation via Federation -resource "google_service_account_iam_member" "allow_federation_impersonation" { - service_account_id = google_service_account.sg-service-account.id +resource "google_service_account_iam_member" "federation_impersonation" { + service_account_id = google_service_account.stackguardian.id role = "roles/iam.workloadIdentityUser" - member = "principal://iam.googleapis.com/projects/${google_iam_workload_identity_pool.sg-pool.project}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.sg-pool.workload_identity_pool_id}/subject//orgs/${local.sg-org-id}" + member = "principal://iam.googleapis.com/projects/${google_iam_workload_identity_pool.stackguardian.project}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.stackguardian.workload_identity_pool_id}/subject/${local.oidc_subject}" } -# Assign Policy for Federated Identity -resource "google_service_account_iam_policy" "federated_identity" { - service_account_id = google_service_account.sg-service-account.name - - policy_data = < 0 + error_message = "stackguardian_org_name must not be empty." + } } -variable "sg-org-id" { - type = string +variable "oidc_subject" { + type = string + description = "Exact StackGuardian OIDC subject accepted by workload identity." + default = null + + validation { + condition = var.oidc_subject == null || can(regex("^/orgs/[^/\\s]+$", var.oidc_subject)) + error_message = "oidc_subject must use the /orgs/ format." + } } variable "service_account_id" { - type = string + type = string + description = "Service account ID." + + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.service_account_id)) + error_message = "service_account_id must be 6-30 lowercase letters, digits, or hyphens." + } } variable "workload_identity_pool_id" { - type = string + type = string + description = "Workload identity pool ID." + + validation { + condition = can(regex("^[a-z][a-z0-9-]{3,31}$", var.workload_identity_pool_id)) + error_message = "workload_identity_pool_id must be 4-32 lowercase letters, digits, or hyphens." + } } variable "workload_identity_pool_provider_id" { - type = string + type = string + description = "Workload identity pool provider ID." + + validation { + condition = can(regex("^[a-z][a-z0-9-]{3,31}$", var.workload_identity_pool_provider_id)) + error_message = "workload_identity_pool_provider_id must be 4-32 lowercase letters, digits, or hyphens." + } } variable "workload_identity_pool_display_name" { - type = string -} \ No newline at end of file + type = string + description = "Display name of the workload identity pool provider." +} + +variable "oidc_issuer_uri" { + type = string + description = "StackGuardian OIDC issuer URI." + default = "https://api.app.stackguardian.io" +} + +variable "oidc_allowed_audiences" { + type = list(string) + description = "OIDC audiences allowed by workload identity." + default = ["https://api.app.stackguardian.io"] +} + +variable "project_role" { + type = string + description = "Project role granted to the StackGuardian service account. roles/owner is a high-privilege default." + default = "roles/owner" +} diff --git a/main.tf b/main.tf index 8560b41..07cc5d4 100644 --- a/main.tf +++ b/main.tf @@ -1,69 +1,141 @@ -locals { - cloud_connectors_list = [for con in var.cloud_connectors : con.name] -} - -# ################################ -# # Create Stackguardian Workflow Group -# ################################ module "stackguardian_workflow_group" { for_each = toset(var.workflow_groups) source = "./stackguardian_workflow_group" - api_key = var.api_key - org_name = var.org_name - workflow_group_name = each.key + workflow_group_name = each.value } -# ################################ -# # Create Stackguardian cloud connector -# ################################ -module "stackguardian_connector_cloud" { - for_each = { for c in var.cloud_connectors : c.name => c } - source = "./stackguardian_connector_cloud" - cloud_connector_name = each.key - connector_type = each.value.connector_type - role_arn = each.value.role_arn - role_external_id = each.value.aws_role_external_id - api_key = var.api_key - org_name = var.org_name +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 + 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"]) } -################################ -# Create Stackguardian VCS Connector -################################ +module "aws_rbac" { + for_each = { + for name, connector in local.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"]) +} + +module "aws_static" { + for_each = { + for name, connector in local.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) + 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" + } + + source = "./aws_oidc" + + aws_region = each.value.aws_region + iam_role_name = each.value.iam_role_name + stackguardian_org_name = var.stackguardian_org_name + policy_arn = each.value.policy_arn +} +module "azure_oidc" { + for_each = { + for name, connector in local.cloud_connectors : name => connector if connector.kind == "AZURE_OIDC" + } -module "vcs_connector" { + 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) + stackguardian_org_name = var.stackguardian_org_name + role_definition_name = each.value.role_definition_name +} + +module "azure_static" { + for_each = { + for name, connector in local.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 + 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" + } + + source = "./gcp_oidc" + + project_id = each.value.gcp_project_id + stackguardian_org_name = var.stackguardian_org_name + 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 +} + +module "stackguardian_connector_cloud" { + for_each = local.cloud_connectors + + source = "./stackguardian_connector_cloud" + connector_name = each.value.name + connector_kind = each.value.kind + allow_static_credentials = try(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 + 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 + azure_client_secret = each.value.kind == "AZURE_STATIC" ? module.azure_static[each.key].client_secret_value : null + aws_role_arn = each.value.kind == "AWS_RBAC" ? module.aws_rbac[each.key].iam_role_arn : each.value.kind == "AWS_OIDC" ? module.aws_oidc[each.key].oidc_role_arn : null + aws_external_id = each.value.kind == "AWS_RBAC" ? each.value.aws_external_id : null + gcp_config_file_content = each.value.kind == "GCP_OIDC" ? module.gcp_oidc[each.key].external_account_config : null +} + +module "stackguardian_connector_vcs" { source = "./stackguardian_connector_vcs" vcs_connectors = var.vcs_connectors - api_key = var.api_key - org_name = var.org_name } - -################################ -# Create Stackguardian Role -################################ module "stackguardian_role" { source = "./stackguardian_role" - api_key = var.api_key - org_name = var.org_name + org_name = var.stackguardian_org_name role_name = var.role_name - cloud_connectors = [for con in var.cloud_connectors : con.name] - vcs_connectors = [for vcs in var.vcs_connectors : vcs.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 - #depends_on = [ module.stackguardian_workflow_group, module.stackguardian_connector_cloud, module.stackguardian_connector_vcs ] } -# ################################ -# # Create Stackguardian role assignment -# ################################ module "stackguardian_role_assignment" { - source = "./stackguardian_role_assignment" - api_key = var.api_key - org_name = var.org_name - role_name = var.role_name - user_or_group = var.user_or_group - entity_type = var.entity_type - depends_on = [module.stackguardian_role] + source = "./stackguardian_role_assignment" + role_name = var.role_name + subject = var.subject + entity_type = var.entity_type + depends_on = [module.stackguardian_role] } diff --git a/provider.tf b/provider.tf index 8c6aae1..1bfb48e 100644 --- a/provider.tf +++ b/provider.tf @@ -1,26 +1,49 @@ terraform { + required_version = "= 1.5.7" + required_providers { stackguardian = { source = "StackGuardian/stackguardian" - version = "1.3.1" + version = ">= 1.12.0, < 2.0.0" } aws = { source = "hashicorp/aws" - version = "5.84.0" + version = ">= 6.58.0, < 7.0.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 5.0.1, < 6.0.0" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 3.9.0, < 4.0.0" + } + google = { + source = "hashicorp/google" + version = ">= 7.44.0, < 8.0.0" } } } # StackGuardian provider configuration provider "stackguardian" { - api_key = var.api_key - org_name = var.org_name - api_uri = "https://api.app.stackguardian.io" + api_key = var.stackguardian_api_key + org_name = var.stackguardian_org_name + api_uri = var.stackguardian_api_uri } -# AWS provider configuration -/* provider "aws" { - region = var.region + region = local.aws_provider_region +} + +provider "azurerm" { + features {} +} + +provider "azuread" { +} + +provider "google" { + # Avoid requiring gcloud ADC when this root has no GCP resources. + access_token = local.has_gcp_connector ? null : "unused" } -*/ \ No newline at end of file diff --git a/stackguardian_connector_cloud/README.md b/stackguardian_connector_cloud/README.md new file mode 100644 index 0000000..5c35838 --- /dev/null +++ b/stackguardian_connector_cloud/README.md @@ -0,0 +1,17 @@ +# StackGuardian Cloud Connector + +Creates one `AWS_STATIC`, `AWS_RBAC`, `AWS_OIDC`, `AZURE_STATIC`, `AZURE_OIDC`, or `GCP_OIDC` connector. Configure the StackGuardian provider in the calling root. Credential fields are typed, sensitive where secret, and persisted in Terraform state. + +> **Deprecated:** `AWS_STATIC` and `AZURE_STATIC` store static secrets in Terraform state and can expose them in plan artifacts. Prefer `AWS_RBAC`, `AWS_OIDC`, `AZURE_OIDC`, or another non-static connector kind. Static connector kinds require `allow_static_credentials = true`. + +```hcl +module "connector" { + source = "./stackguardian_connector_cloud" + connector_name = "aws-rbac" + connector_kind = "AWS_RBAC" + aws_role_arn = "arn:aws:iam::123456789012:role/StackGuardianRole" + aws_external_id = var.external_id +} +``` + +Outputs: `connector_name`, `connector_kind`, `connector_id`. diff --git a/stackguardian_connector_cloud/main.tf b/stackguardian_connector_cloud/main.tf index 27af7fc..9c60ef6 100644 --- a/stackguardian_connector_cloud/main.tf +++ b/stackguardian_connector_cloud/main.tf @@ -1,122 +1,64 @@ -check "aws_static_vars" { - assert { - condition = var.connector_type != "AWS_STATIC" || (var.aws_access_key_id != null && var.aws_secret_access_key != null && var.aws_default_region != null) - error_message = "Variables aws_access_key_id, aws_secret_access_key, and aws_default_region must be set when connector_type is AWS_STATIC." - } -} - -resource "stackguardian_connector" "sg_aws_static_connector" { - count = (var.connector_type == "AWS_STATIC") ? 1 : 0 - resource_name = var.cloud_connector_name - description = "Onboarding example of terraform-provider-stackguardian for AWSConnectorCloud" - settings = { - kind = var.connector_type, - config = [{ - aws_access_key_id = var.aws_access_key_id, - aws_secret_access_key = var.aws_secret_access_key, - aws_default_region = var.aws_default_region - }] - } -} - -check "aws_oidc_vars" { - assert { - condition = var.connector_type != "AWS_OIDC" || var.role_arn != null - error_message = "Variable role_arn must be set when connector_type is AWS_OIDC." - } -} - -resource "stackguardian_connector" "sg_aws_oidc_connector" { - count = (var.connector_type == "AWS_OIDC") ? 1 : 0 - resource_name = var.cloud_connector_name - description = "Onboarding an AWS Role with OIDC" - settings = { - kind = var.connector_type, - config = [{ - role_arn = var.role_arn - }] - } -} - -check "aws_rbac_vars" { - assert { - condition = var.connector_type != "AWS_RBAC" || (var.role_arn != null && var.role_external_id != null) - error_message = "Variables role_arn and role_external_id must be set when connector_type is AWS_RBAC." - } -} - -resource "stackguardian_connector" "sg_aws_rbac_connector" { - count = (var.connector_type == "AWS_RBAC") ? 1 : 0 - resource_name = var.cloud_connector_name - description = "Onboarding an AWS Role with RBAC" - settings = { - kind = var.connector_type, - config = [{ - role_arn = var.role_arn - external_id = var.role_external_id - duration_seconds = 3600 - }] - } +locals { + azure_identifiers_valid = alltrue([ + for value in [var.azure_tenant_id, var.azure_subscription_id, var.azure_client_id] : + value != null && can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", value)) + ]) } -check "azure_static_vars" { +check "connector_credentials" { assert { - condition = var.connector_type != "AZURE_STATIC" || (var.armTenantId != null && var.armSubscriptionId != null && var.armClientId != null && var.armClientSecret != null) - error_message = "Variables armTenantId, armSubscriptionId, armClientId, and armClientSecret must be set when connector_type is AZURE_STATIC." + condition = ( + (var.connector_kind == "AWS_STATIC" && var.aws_access_key_id != null && var.aws_secret_access_key != null && var.aws_region != null && var.aws_role_arn == null && var.azure_tenant_id == null && var.gcp_config_file_content == null) || + (var.connector_kind == "AWS_RBAC" && var.aws_role_arn != null && var.aws_external_id != null && var.aws_access_key_id == null && var.azure_tenant_id == null && var.gcp_config_file_content == null) || + (var.connector_kind == "AWS_OIDC" && var.aws_role_arn != null && var.aws_access_key_id == null && var.aws_external_id == null && var.azure_tenant_id == null && var.gcp_config_file_content == null) || + (var.connector_kind == "AZURE_STATIC" && local.azure_identifiers_valid && var.azure_client_secret != null && var.aws_access_key_id == null && var.aws_role_arn == null && var.gcp_config_file_content == null) || + (var.connector_kind == "AZURE_OIDC" && local.azure_identifiers_valid && var.azure_client_secret == null && var.aws_access_key_id == null && var.aws_role_arn == null && var.gcp_config_file_content == null) || + (var.connector_kind == "GCP_OIDC" && var.gcp_config_file_content != null && var.aws_access_key_id == null && var.aws_role_arn == null && var.azure_tenant_id == null) + ) + error_message = "Supply only the credential fields required by connector_kind." } } -resource "stackguardian_connector" "sg_azure_static_connector" { - count = (var.connector_type == "AZURE_STATIC") ? 1 : 0 - resource_name = var.cloud_connector_name - description = "Onboarding example of terraform-provider-stackguardian for AzureConnectorCloud" +resource "stackguardian_connector" "cloud" { + resource_name = var.connector_name + description = "StackGuardian ${var.connector_kind} cloud connector" settings = { - kind = var.connector_type, - config = [{ - armTenantId = var.armTenantId, - armSubscriptionId = var.armSubscriptionId, - armClientId = var.armClientId, - armClientSecret = var.armClientSecret - }] - } -} - -check "azure_oidc_vars" { - assert { - condition = var.connector_type != "AZURE_OIDC" || (var.armTenantId != null && var.armSubscriptionId != null && var.armClientId != null) - error_message = "Variables armTenantId, armSubscriptionId, and armClientId must be set when connector_type is AZURE_OIDC." + kind = var.connector_kind + config = [merge( + var.connector_kind == "AWS_STATIC" ? { + aws_access_key_id = var.aws_access_key_id + aws_secret_access_key = var.aws_secret_access_key + aws_default_region = var.aws_region + } : {}, + var.connector_kind == "AWS_RBAC" ? { + role_arn = var.aws_role_arn + external_id = var.aws_external_id + duration_seconds = 3600 + } : {}, + var.connector_kind == "AWS_OIDC" ? { role_arn = var.aws_role_arn } : {}, + contains(["AZURE_STATIC", "AZURE_OIDC"], var.connector_kind) ? { + arm_tenant_id = var.azure_tenant_id + arm_subscription_id = var.azure_subscription_id + arm_client_id = var.azure_client_id + } : {}, + var.connector_kind == "AZURE_STATIC" ? { arm_client_secret = var.azure_client_secret } : {}, + var.connector_kind == "GCP_OIDC" ? { gcp_config_file_content = var.gcp_config_file_content } : {} + )] } -} -resource "stackguardian_connector" "sg_azure_oidc_connector" { - count = (var.connector_type == "AZURE_OIDC") ? 1 : 0 - resource_name = var.cloud_connector_name - description = "Onboarding example of terraform-provider-stackguardian for AzureConnectorCloud" - settings = { - kind = var.connector_type, - config = [{ - armTenantId = var.armTenantId, - armSubscriptionId = var.armSubscriptionId, - armClientId = var.armClientId, - }] + lifecycle { + precondition { + condition = !contains(["AWS_STATIC", "AZURE_STATIC"], var.connector_kind) || var.allow_static_credentials + error_message = "Static cloud connector credentials are deprecated. Set allow_static_credentials = true only when required; prefer AWS_RBAC, AWS_OIDC, AZURE_OIDC, or another non-static connector kind." + } } } -check "gcp_oidc_vars" { - assert { - condition = var.connector_type != "GCP_OIDC" || var.gcp_config_file_content != null - error_message = "Variable gcp_config_file_content must be set when connector_type is GCP_OIDC." - } -} +resource "terraform_data" "static_credentials_deprecation" { + count = contains(["AWS_STATIC", "AZURE_STATIC"], var.connector_kind) && var.allow_static_credentials ? 1 : 0 + input = "Static cloud connector credentials are deprecated; migrate to a non-static connector kind." -resource "stackguardian_connector" "sg_gcp_oidc_connector" { - count = (var.connector_type == "GCP_OIDC") ? 1 : 0 - resource_name = var.cloud_connector_name - description = "Onboarding example of terraform-provider-stackguardian for AzureConnectorCloud" - settings = { - kind = var.connector_type, - config = [{ - gcp_config_file_content = var.gcp_config_file_content - }] + provisioner "local-exec" { + command = "printf '%s\\n' 'WARNING: Static cloud connector credentials are deprecated; migrate to a non-static connector kind.'" } } diff --git a/stackguardian_connector_cloud/outputs.tf b/stackguardian_connector_cloud/outputs.tf index 9c8bf74..e51933e 100644 --- a/stackguardian_connector_cloud/outputs.tf +++ b/stackguardian_connector_cloud/outputs.tf @@ -1,8 +1,14 @@ -output "resource_name" { - description = "Cloud Connector Name created" - value = var.cloud_connector_name +output "connector_name" { + description = "Created cloud connector name." + value = stackguardian_connector.cloud.resource_name } -output "connector_type" { - description = "Cloud Connector Type created" - value = var.connector_type + +output "connector_kind" { + description = "Created cloud connector kind." + value = var.connector_kind +} + +output "connector_id" { + description = "Provider-computed cloud connector ID." + value = stackguardian_connector.cloud.id } diff --git a/stackguardian_connector_cloud/provider.tf b/stackguardian_connector_cloud/provider.tf index 65bfd24..b8f877f 100644 --- a/stackguardian_connector_cloud/provider.tf +++ b/stackguardian_connector_cloud/provider.tf @@ -1,8 +1,10 @@ terraform { + required_version = "= 1.5.7" + required_providers { stackguardian = { source = "StackGuardian/stackguardian" - version = "1.3.1" + version = ">= 1.12.0, < 2.0.0" } } -} \ No newline at end of file +} diff --git a/stackguardian_connector_cloud/tests/static_credentials.tofutest.hcl b/stackguardian_connector_cloud/tests/static_credentials.tofutest.hcl new file mode 100644 index 0000000..460b6cc --- /dev/null +++ b/stackguardian_connector_cloud/tests/static_credentials.tofutest.hcl @@ -0,0 +1,48 @@ +mock_provider "stackguardian" {} + +run "rejects_static_credentials_without_acknowledgement" { + command = plan + + variables { + connector_name = "test-static" + connector_kind = "AWS_STATIC" + aws_access_key_id = "AKIATESTKEY" + aws_secret_access_key = "test-secret" + aws_region = "eu-central-1" + } + + expect_failures = [stackguardian_connector.cloud] +} + +run "allows_acknowledged_static_credentials" { + command = plan + + variables { + connector_name = "test-static" + connector_kind = "AWS_STATIC" + allow_static_credentials = true + aws_access_key_id = "AKIATESTKEY" + aws_secret_access_key = "test-secret" + aws_region = "eu-central-1" + } + + assert { + condition = output.connector_name == "test-static" && output.connector_kind == "AWS_STATIC" + error_message = "Acknowledged static credentials must retain the configured connector identity." + } +} + +run "allows_non_static_credentials_without_acknowledgement" { + command = plan + + variables { + connector_name = "test-oidc" + connector_kind = "AWS_OIDC" + aws_role_arn = "arn:aws:iam::123456789012:role/TestRole" + } + + assert { + condition = output.connector_kind == "AWS_OIDC" + error_message = "Non-static connector kinds must not require static credential acknowledgement." + } +} diff --git a/stackguardian_connector_cloud/variables.tf b/stackguardian_connector_cloud/variables.tf index ab92a41..91e6b31 100644 --- a/stackguardian_connector_cloud/variables.tf +++ b/stackguardian_connector_cloud/variables.tf @@ -1,109 +1,100 @@ -variable "api_key" { +variable "connector_name" { type = string - description = "Your organization's API key on the StackGuardian Platform" - sensitive = true -} + description = "Name of the cloud connector." -variable "org_name" { - type = string - description = "Your organization name on StackGuardian Platform" + validation { + condition = can(regex("^[A-Za-z0-9][A-Za-z0-9 _.-]{0,99}$", var.connector_name)) + error_message = "connector_name must be 1-100 characters and start with a letter or number." + } } -variable "connector_type" { +variable "connector_kind" { type = string - description = "type of connector. You can select anyone of the following AWS_STATIC, AWS_RBAC, AWS_OIDC, AZURE_STATIC, AZURE_OIDC, GCP_OIDC" + description = "Cloud connector kind." + validation { - condition = contains([ - "AWS_STATIC", - "AWS_OIDC", - "AWS_RBAC", - "AZURE_STATIC", - "AZURE_OIDC", - "GCP_OIDC", - ], var.connector_type) - error_message = "Variable connector_type must be one of AWS_STATIC, AWS_OIDC, AWS_RBAC, AZURE_STATIC, AZURE_OIDC, GCP_OIDC." + condition = contains(["AWS_STATIC", "AWS_RBAC", "AWS_OIDC", "AZURE_STATIC", "AZURE_OIDC", "GCP_OIDC"], var.connector_kind) + error_message = "connector_kind must be AWS_STATIC, AWS_RBAC, AWS_OIDC, AZURE_STATIC, AZURE_OIDC, or GCP_OIDC." } } -variable "cloud_connector_name" { - type = string - description = "Name of the Cloud connector" +variable "allow_static_credentials" { + type = bool + default = false + description = "Acknowledges deprecated AWS_STATIC or AZURE_STATIC connector credentials. Prefer a non-static connector kind." } - -################ -# AWS_STATIC Credentials -################ - variable "aws_access_key_id" { type = string - description = "your AWS acoount access key" - default = null # optional + description = "AWS access key ID for an AWS_STATIC connector." + default = null + sensitive = true } variable "aws_secret_access_key" { type = string - description = "your AWS account secret access key" - default = null # optional + description = "AWS secret access key for an AWS_STATIC connector." + default = null sensitive = true } -variable "aws_default_region" { +variable "aws_region" { type = string - description = "any default region you want to set, for all your deployments" - default = null # optional -} + description = "AWS region for an AWS_STATIC connector." + default = null -################ -# AZURE_STATIC Credentials -################ + validation { + condition = var.aws_region == null || can(regex("^[a-z]{2}(-gov)?-[a-z]+-\\d$", var.aws_region)) + error_message = "aws_region must be a valid AWS region." + } +} -variable "armTenantId" { +variable "azure_tenant_id" { type = string - description = "your azure account tenant id" - default = null # optional + description = "Azure tenant ID for an Azure connector." + default = null } -variable "armSubscriptionId" { +variable "azure_subscription_id" { type = string - description = "your azure subscription id" - default = null # optional + description = "Azure subscription ID for an Azure connector." + default = null } -variable "armClientId" { +variable "azure_client_id" { type = string - description = "your azure client id" - default = null # optional + description = "Azure application client ID for an Azure connector." + default = null } -variable "armClientSecret" { +variable "azure_client_secret" { type = string - description = "your azure client secret" - default = null # optional + description = "Azure application secret for an AZURE_STATIC connector." + default = null sensitive = true } -################ -# AWS_OIDC Credentials + AWS_RBAC Credentials -################ -variable "role_arn" { +variable "aws_role_arn" { type = string - description = "arn of the aws oidc role" - default = null # optional + description = "AWS role ARN for AWS_RBAC or AWS_OIDC connectors." + default = null + + validation { + condition = var.aws_role_arn == null || can(regex("^arn:aws[a-z-]*:iam::\\d{12}:role/.+$", var.aws_role_arn)) + error_message = "aws_role_arn must be a valid IAM role ARN." + } } -###### for AWS_RBAC the externalID is also needed -variable "role_external_id" { +variable "aws_external_id" { type = string - description = "external id of the aws rbac role" - default = null # optional; ":" is recommended + description = "External ID for an AWS_RBAC connector." + default = null + sensitive = true } -################ -# GCP_OIDC Credentials + GCP_STATIC Credentials -################ variable "gcp_config_file_content" { type = string - description = "the gco config content gor the connector" - default = null # optional + description = "Google external-account configuration content for a GCP_OIDC connector." + default = null + sensitive = true } diff --git a/stackguardian_connector_vcs/README.md b/stackguardian_connector_vcs/README.md new file mode 100644 index 0000000..7a002eb --- /dev/null +++ b/stackguardian_connector_vcs/README.md @@ -0,0 +1,18 @@ +# StackGuardian VCS Connector + +Creates typed GitHub, GitLab, or Bitbucket connectors. Configure the StackGuardian provider in the calling root. VCS credentials are sensitive and retained in Terraform state. + +```hcl +module "vcs" { + source = "./stackguardian_connector_vcs" + vcs_connectors = { + github = { + name = "github" + kind = "GITHUB_COM" + github = { githubCreds = var.github_credential } + } + } +} +``` + +Output: `connector_vcs`. diff --git a/stackguardian_connector_vcs/main.tf b/stackguardian_connector_vcs/main.tf index 4dcd9b2..6c1de0c 100644 --- a/stackguardian_connector_vcs/main.tf +++ b/stackguardian_connector_vcs/main.tf @@ -1,39 +1,14 @@ -# resource "stackguardian_connector" "sg_vcs_connector" { -# resource_name = var.stackguardian_connector_vcs_name -# description = "Onboarding example of terraform-provider-stackguardian for ConnectorVcs" -# settings = { -# kind = var.vcs_kind -# config = [{ -# gitlab_creds = var.gitlab_credentials -# }] -# } -# } - -resource "stackguardian_connector" "sg_vcs_connector" { - for_each = { - for key, value in var.vcs_connectors : - key => value if( - # Check if any credentials are provided for gitlab, github or bitbucket - ( - (lookup(value.config[0], "gitlab_creds", null) != null) || - (lookup(value.config[0], "github_creds", null) != null) || - (lookup(value.config[0], "bitbucket_creds", null) != null) - ) - ) - } - - resource_name = each.value.name - description = "Onboarding VCS connector" +resource "stackguardian_connector" "vcs" { + for_each = toset(nonsensitive(keys(var.vcs_connectors))) + resource_name = var.vcs_connectors[each.value].name + description = "StackGuardian ${var.vcs_connectors[each.value].kind} VCS connector" settings = { - kind = each.value.kind - config = flatten([ - for config_item in each.value.config : { - # Dynamically handle different connector types and jsonencode here - gitlab_creds = lookup(config_item, "gitlab_creds", null) != null ? jsonencode(lookup(config_item, "gitlab_creds", null)) : null - github_creds = lookup(config_item, "github_creds", null) != null ? jsonencode(lookup(config_item, "github_creds", null)) : null - bitbucket_creds = lookup(config_item, "bitbucket_creds", null) != null ? jsonencode(lookup(config_item, "bitbucket_creds", null)) : null - } - ]) + kind = var.vcs_connectors[each.value].kind + config = [merge( + var.vcs_connectors[each.value].github != null ? { github_creds = jsonencode(var.vcs_connectors[each.value].github) } : {}, + var.vcs_connectors[each.value].gitlab != null ? { gitlab_creds = jsonencode(var.vcs_connectors[each.value].gitlab) } : {}, + var.vcs_connectors[each.value].bitbucket != null ? { bitbucket_creds = jsonencode(var.vcs_connectors[each.value].bitbucket) } : {} + )] } -} \ No newline at end of file +} diff --git a/stackguardian_connector_vcs/outputs.tf b/stackguardian_connector_vcs/outputs.tf index 802db5d..f9431e7 100644 --- a/stackguardian_connector_vcs/outputs.tf +++ b/stackguardian_connector_vcs/outputs.tf @@ -1,4 +1,4 @@ output "connector_vcs" { description = "Created VCS connector" - value = [for con in var.vcs_connectors : con.name] -} \ No newline at end of file + value = [for connector in values(var.vcs_connectors) : connector.name] +} diff --git a/stackguardian_connector_vcs/provider.tf b/stackguardian_connector_vcs/provider.tf index 65bfd24..b8f877f 100644 --- a/stackguardian_connector_vcs/provider.tf +++ b/stackguardian_connector_vcs/provider.tf @@ -1,8 +1,10 @@ terraform { + required_version = "= 1.5.7" + required_providers { stackguardian = { source = "StackGuardian/stackguardian" - version = "1.3.1" + version = ">= 1.12.0, < 2.0.0" } } -} \ No newline at end of file +} diff --git a/stackguardian_connector_vcs/variables.tf b/stackguardian_connector_vcs/variables.tf index a7c015a..305ec88 100644 --- a/stackguardian_connector_vcs/variables.tf +++ b/stackguardian_connector_vcs/variables.tf @@ -1,46 +1,32 @@ -variable "api_key" { - type = string - description = "API key to authenticate to StackGuardian" -} -variable "org_name" { - type = string - description = "Organisation name in StackGuardian platform" -} - variable "vcs_connectors" { - description = "A map of connectors and their respective configurations" - type = map(any) - default = { - vcs_gitlab = { - kind = "GITLAB_COM" - name = "gitlab-connector" - config = [{ - gitlab_creds = { - gitlabCreds = "gitlabuser:gitlab_pat", - gitlabHttpUrl = "https://gitlab.com", - gitlabApiUrl = "https://gitlab.com/api/v4" - } - }] - }, - vcs_github = { - name = "github-connector" - kind = "GITHUB_COM" - config = [{ - github_creds = { - githubCreds = "username:personal_access_token" - github_com_url = "https://api.github.com" - github_http_url = "https://github.com" - } - }] - }, - vcs_bitbucket = { - name = "bitbucket-connector" - kind = "BITBUCKET_ORG" - config = [{ - bitbucket_creds = { - bitbucket_creds = "" - } - }] - } + description = "Typed VCS connector configuration. Credentials remain in Terraform state." + sensitive = true + type = map(object({ + kind = string + name = string + github = optional(object({ + githubCreds = string + github_com_url = optional(string, "https://api.github.com") + github_http_url = optional(string, "https://github.com") + })) + gitlab = optional(object({ + gitlabCreds = string + gitlabHttpUrl = optional(string, "https://gitlab.com") + gitlabApiUrl = optional(string, "https://gitlab.com/api/v4") + })) + bitbucket = optional(object({ + bitbucket_creds = string + })) + })) + + validation { + condition = alltrue([for connector in values(var.vcs_connectors) : + length(trimspace(connector.name)) > 0 && ( + (connector.kind == "GITHUB_COM" && connector.github != null && connector.gitlab == null && connector.bitbucket == null && length(trimspace(connector.github.githubCreds)) > 0) || + (connector.kind == "GITLAB_COM" && connector.gitlab != null && connector.github == null && connector.bitbucket == null && length(trimspace(connector.gitlab.gitlabCreds)) > 0) || + (connector.kind == "BITBUCKET_ORG" && connector.bitbucket != null && connector.github == null && connector.gitlab == null && length(trimspace(connector.bitbucket.bitbucket_creds)) > 0) + ) + ]) + error_message = "Each VCS connector needs a non-empty, matching GitHub, GitLab, or Bitbucket credential object." } } diff --git a/stackguardian_role/README.md b/stackguardian_role/README.md new file mode 100644 index 0000000..a1c0d31 --- /dev/null +++ b/stackguardian_role/README.md @@ -0,0 +1,17 @@ +# 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. + +```hcl +module "role" { + source = "./stackguardian_role" + org_name = "example-org" + role_name = "developer" + workflow_groups = ["engineering"] + cloud_connectors = ["aws-rbac"] + vcs_connectors = ["github"] + template_list = ["terraform-aws-vpc"] +} +``` + +Outputs: `role`, `allowed_permissions`. diff --git a/stackguardian_role/main.tf b/stackguardian_role/main.tf index 388ced1..95800c2 100644 --- a/stackguardian_role/main.tf +++ b/stackguardian_role/main.tf @@ -1,10 +1,7 @@ -resource "stackguardian_role" "role" { - - resource_name = var.role_name - description = "Onboarding example of terraform-provider-stackguardian for Role Developer" - tags = [ - var.org_name - ] +resource "stackguardian_rolev4" "role" { + resource_name = var.role_name + description = "Scoped workflow, connector, and template access for ${var.role_name}." + tags = ["terraform", "scoped-access"] allowed_permissions = local.team_onboarding_permissions } diff --git a/stackguardian_role/outputs.tf b/stackguardian_role/outputs.tf index ae638fc..82f499c 100644 --- a/stackguardian_role/outputs.tf +++ b/stackguardian_role/outputs.tf @@ -1,4 +1,9 @@ output "role" { description = "Created Role" value = var.role_name -} \ No newline at end of file +} + +output "allowed_permissions" { + description = "Generated StackGuardian role v4 permission document." + value = local.team_onboarding_permissions +} diff --git a/stackguardian_role/provider.tf b/stackguardian_role/provider.tf index 65bfd24..b8f877f 100644 --- a/stackguardian_role/provider.tf +++ b/stackguardian_role/provider.tf @@ -1,8 +1,10 @@ terraform { + required_version = "= 1.5.7" + required_providers { stackguardian = { source = "StackGuardian/stackguardian" - version = "1.3.1" + version = ">= 1.12.0, < 2.0.0" } } -} \ No newline at end of file +} diff --git a/stackguardian_role/team_onboarding_permissions.tf b/stackguardian_role/team_onboarding_permissions.tf index e9d29a5..543bfcf 100644 --- a/stackguardian_role/team_onboarding_permissions.tf +++ b/stackguardian_role/team_onboarding_permissions.tf @@ -1,320 +1,64 @@ locals { - team_onboarding_permissions = { - "GET/api/v1/orgs/${var.org_name}/wfgrps//wfs//wfruns//wfrunfacts//" : { - "name" : "GetWorkflowRunFact", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ], - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "POST/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//wfruns//resume/" : { - "name" : "ResumeStackWorkflowRun", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ], - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "PATCH/api/v1/orgs/${var.org_name}/wfgrps//" : { - "name" : "UpdateWorkflowGroup", - "paths" : { - "" : var.workflow_groups - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//listall_artifacts/" : { - "name" : "ListStackWorkflowArtifacts", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "POST/api/v1/orgs/${var.org_name}/wfgrps//wfgrps/" : { - "name" : "CreateNestedWorkflowGroup", - "paths" : { - "" : var.workflow_groups - } - }, - "POST/api/v1/orgs/${var.org_name}/wfgrps//wfs/" : { - "name" : "CreateWorkflow", - "paths" : { - "" : var.workflow_groups - } - }, - "DELETE/api/v1/orgs/${var.org_name}/wfgrps//wfs//wfruns//" : { - "name" : "UpdateWorkflowRun", - "paths" : { - "" : [ - ".*" - ], - "" : var.workflow_groups, - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//wfruns//" : { - "name" : "GetStackWorkflowRun", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ], - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "DELETE/api/v1/orgs/${var.org_name}/wfgrps//stacks//" : { - "name" : "DeleteStack", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ] - } - }, - "PATCH/api/v1/orgs/${var.org_name}/wfgrps//wfs//" : { - "name" : "UpdateWorkflow", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ] - } - }, - "POST/api/v1/orgs/${var.org_name}/wfgrps//stacks//stackruns/" : { - "name" : "CreateStackRun", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//wfs//listall_artifacts/" : { - "name" : "ListWorkflowArtifacts", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ] - } - }, - "PATCH/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//" : { - "name" : "UpdateStackWorkflow", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//wfruns//wfrunfacts//" : { - "name" : "GetStackWorkflowRunFact", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ], - "" : [ - ".*" - ], - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//outputs/" : { - "name" : "GetStackWorkflowOutputs", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//wfruns//logs/" : { - "name" : "GetStackWorkflowRunLogs", - "paths" : { - "" : var.workflow_groups, - "" : [ - ".*" - ], - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "POST/api/v1/orgs/${var.org_name}/wfgrps//wfs//wfruns//resume/" : { - "name" : "ResumeWorkflowRun", - "paths" : { - "" : [ - ".*" - ], - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "POST/api/v1/orgs/${var.org_name}/wfgrps//wfs//wfruns/" : { - "name" : "CreateWorkflowRun", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "DELETE/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//" : { - "name" : "DeleteStackWorkflow", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//stacks//wfs//" : { - "name" : "GetStackWorkflow", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ], - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//stacks//" : { - "name" : "GetStack", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//wfs//" : { - "name" : "GetWorkflow", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//" : { - "name" : "GetWorkflowGroup", - "paths" : { - "" : var.workflow_groups - } - }, - "POST/api/v1/orgs/${var.org_name}/wfgrps//stacks/" : { - "name" : "CreateStack", - "paths" : { - "" : var.workflow_groups - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//wfs//outputs/" : { - "name" : "GetWorkflowOutputs", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "DELETE/api/v1/orgs/${var.org_name}/wfgrps//wfs//" : { - "name" : "DeleteWorkflow", - "paths" : { - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//wfs//wfruns//" : { - "name" : "GetWorkflowRun", - "paths" : { - "" : [ - ".*" - ], - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/wfgrps//wfs//wfruns//logs/" : { - "name" : "GetWorkflowRunLogs", - "paths" : { - "" : [ - ".*" - ], - "" : var.workflow_groups - "" : [ - ".*" - ] - } - }, - "GET/api/v1/orgs/${var.org_name}/integrations//" : { - "name" : "GetIntegration", - "paths" : { - "" : concat(var.cloud_connectors, var.vcs_connectors) - } - }, - "GET/api/v1/templatetypes///