diff --git a/tofu/remote-state/cf/DOCS.md b/tofu/remote-state/cf/DOCS.md new file mode 100644 index 000000000..d591dea27 --- /dev/null +++ b/tofu/remote-state/cf/DOCS.md @@ -0,0 +1,71 @@ +## Overview + +This OpenTofu module is responsible for provisioning the foundational infrastructure for managing OpenTofu state within Cloudflare R2. It automates the creation of the R2 bucket and generates the necessary S3-compatible access credentials for remote state storage. + +## Key Resources + +- **R2 Bucket for Tofu Remote State**: A Cloudflare R2 bucket is created to serve as a centralized and secure backend for storing OpenTofu state files. It is configured with versioning and lifecycle rules for state protection and management. +- **S3-Compatible R2 Access Credentials**: This module programmatically generates a Cloudflare Account API Token that is specifically scoped to provide S3-compatible read and write access to the created R2 bucket. These credentials (Access Key ID and Access Key Secret) are then securely stored in Infisical. +- **Secrets Management with Infisical**: This module interacts with Infisical for managing sensitive configurations. + - **Input Secrets (Manually set up by the user in Infisical under the `/tofu` path in the appropriate Infisical environment):** + - `TF_VAR_cloudflare_master_account_api_token`: A Cloudflare API Account token with permissions to create R2 buckets and Account API tokens. + - `TF_VAR_cloudflare_account_id`: Your Cloudflare account ID. + - `TF_VAR_tofu_encryption_passphrase`: Encryption passphrase for encrypting the state file. + - The rest of the variables are automatically picked up from the devContainer environment. + - Note: If you create these variables after loading the devContainer, you will need to run `source ~/.zshrc` again to load them into the environment. + - **Other Secrets:** + - `TF_VAR_bucket_name` - automatically set in the devcontainer when set in the `.env` file in the root folder. + - `TF_VAR_branch_env`- automatically set in the devcontainer base on the current branch. + - `TF_VAR_tofu_encryption_passphrase` - set it in infisical manually + - `TF_VAR_infisical_domain` - automatically set in the devcontainer when set in the `.env` file in the root folder. + - `TF_VAR_infisical_client_id` - automatically set in the devcontainer when set in the `.env` file in the root folder. + - `TF_VAR_infisical_client_secret` - set it in [devcontainer](/.devcontainer/README.md) manually. + - `TF_VAR_infisical_project_id` - automatically set in the devcontainer when set in the `.env` file in the root folder. + - **Output Secrets (Automatically generated and stored by this Tofu module in Infisical under the path defined by `var.infisical_rw_secrets_path` (default: `/tofu_rw`) in the `prod` Infisical environment):** + - `TF_VAR_cloudflare_r2_tofu_access_key`: The S3-compatible Access Key ID for the R2 bucket. + - `TF_VAR_cloudflare_r2_tofu_access_secret`: The S3-compatible Secret Access Key for the R2 bucket. +- **OpenTofu State Encryption**: The OpenTofu state file (both local and remote) is encrypted at rest using a passphrase provided by the user. This ensures sensitive data within the state file is protected. + +## Instructions + +This module uses a two-phase approach for bootstrapping the remote state: + +### Phase 1: Initial Apply (Local Backend) + +Create the R2 bucket and generate access credentials using a local OpenTofu state. + +```bash +# Copy local backend file +cp samples/backend_local.tofu.sample ./backend.tofu + +# Initialize tofu +tofu init + +# Run tofu apply to create R2 bucket and permissions +tofu apply +``` + +### Phase 2: Migrate State (Remote Backend) + +Migrate your OpenTofu state to the newly provisioned R2 bucket. + +```bash +# Copy R2 backend file +cp samples/backend_r2.tofu.sample ./backend.tofu + +# Load Cloudflare tokens into the devcontainer environment +source ~/.zshrc + +# If in local environment, you could run +# export TF_VAR_cloudflare_r2_tofu_access_key=$(tofu output -raw r2_access_key_id) +# export TF_VAR_cloudflare_r2_tofu_access_secret=$(tofu output -raw r2_secret_access_key) + +# Re-initialize tofu to migrate state to R2 backend +# Double check TF_VAR_branch_env is properly set to your env - prod/staging/dev - everytime you checkout a new branch. +tofu init -migrate-state + +# Remove any leftover local state files - careful, know what you are doing! +# rm *.tfstate* +``` + +Your OpenTofu state is now securely stored in the Cloudflare R2 bucket. diff --git a/tofu/remote-state/cf/README.md b/tofu/remote-state/cf/README.md new file mode 100644 index 000000000..d7ba36c34 --- /dev/null +++ b/tofu/remote-state/cf/README.md @@ -0,0 +1,14 @@ +## Overview + +This OpenTofu module provisions the foundational infrastructure for managing OpenTofu state within Cloudflare R2. It automates the creation of the R2 bucket and generates the necessary S3-compatible access credentials for remote state storage. + +## Key Resources + +- **R2 Bucket for Tofu Remote State**: A Cloudflare R2 bucket with versioning and lifecycle rules for secure state storage. +- **S3-Compatible R2 Access Credentials**: Programmatically generated credentials (Access Key ID and Secret Access Key) for R2 access, securely stored in Infisical. +- **Secrets Management with Infisical**: Manages both input secrets (e.g., Cloudflare API token) and output secrets (e.g., R2 access credentials). +- **OpenTofu State Encryption**: Ensures the OpenTofu state file is encrypted at rest for enhanced security. + +## Instructions + +This module uses a two-phase approach for bootstrapping the remote state. For detailed instructions, including prerequisites and step-by-step guides, please refer to [DOCS.md](./DOCS.md). diff --git a/tofu/remote-state/cf/backend.tofu b/tofu/remote-state/cf/backend.tofu new file mode 100644 index 000000000..be346c077 --- /dev/null +++ b/tofu/remote-state/cf/backend.tofu @@ -0,0 +1,18 @@ +terraform { + backend "s3" { + bucket = var.bucket_name + key = "remote-state/cf/${var.branch_env}/terraform.tfstate" + region = "auto" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + skip_requesting_account_id = true + skip_s3_checksum = true + use_path_style = true + endpoints = { + s3 = "https://${var.cloudflare_account_id}.r2.cloudflarestorage.com" + } + access_key = var.cloudflare_r2_tofu_access_key + secret_key = var.cloudflare_r2_tofu_access_secret + } +} diff --git a/tofu/remote-state/cf/cf.tofu b/tofu/remote-state/cf/cf.tofu new file mode 100644 index 000000000..62abe3399 --- /dev/null +++ b/tofu/remote-state/cf/cf.tofu @@ -0,0 +1,48 @@ +locals { + # Create a nested map: scope -> permission_name -> id + api_token_permission_groups_map = { + account = { + for perm in data.cloudflare_account_api_token_permission_groups_list.all.result : + perm.name => perm.id + if contains(perm.scopes, "com.cloudflare.api.account") + } + zone = { + for perm in data.cloudflare_account_api_token_permission_groups_list.all.result : + perm.name => perm.id + if contains(perm.scopes, "com.cloudflare.api.account.zone") + } + # Add R2 scope mapping + r2 = { + for perm in data.cloudflare_account_api_token_permission_groups_list.all.result : + perm.name => perm.id + if contains(perm.scopes, "com.cloudflare.edge.r2.bucket") + } + } +} + +# Get API token permission groups data +data "cloudflare_account_api_token_permission_groups_list" "all" { + account_id = var.cloudflare_account_id +} + +# Create R2 bucket for remote state +resource "cloudflare_r2_bucket" "tofu_bucket" { + account_id = var.cloudflare_account_id + name = var.bucket_name +} + +# Create Account token for R2 access with proper permissions +resource "cloudflare_account_token" "r2_tofu_token" { + name = "R2 Tofu Remote State Token" + account_id = var.cloudflare_account_id + + policies = [{ + effect = "allow" + permission_groups = [{ + id = local.api_token_permission_groups_map.r2["Workers R2 Storage Bucket Item Write"] + }] + resources = { + "com.cloudflare.edge.r2.bucket.${var.cloudflare_account_id}_default_${var.bucket_name}" = "*" + } + }] +} diff --git a/tofu/remote-state/cf/infisical.tofu b/tofu/remote-state/cf/infisical.tofu new file mode 100644 index 000000000..ac51d32f8 --- /dev/null +++ b/tofu/remote-state/cf/infisical.tofu @@ -0,0 +1,29 @@ +provider "infisical" { + host = var.infisical_domain + auth = { + universal = { + client_id = var.infisical_client_id + client_secret = var.infisical_client_secret + } + } +} + +# Store R2 access key +resource "infisical_secret" "cloudflare_r2_tofu_access_key" { + name = "TF_VAR_cloudflare_r2_tofu_access_key" + value = cloudflare_account_token.r2_tofu_token.id + + env_slug = var.branch_env + folder_path = var.infisical_rw_secrets_path + workspace_id = var.infisical_project_id +} + +# Store R2 access secret +resource "infisical_secret" "cloudflare_r2_tofu_access_secret" { + name = "TF_VAR_cloudflare_r2_tofu_access_secret" + value = sha256(cloudflare_account_token.r2_tofu_token.value) + + env_slug = var.branch_env + folder_path = var.infisical_rw_secrets_path + workspace_id = var.infisical_project_id +} diff --git a/tofu/remote-state/cf/infisical_variables.tofu b/tofu/remote-state/cf/infisical_variables.tofu new file mode 100644 index 000000000..d2e290c1d --- /dev/null +++ b/tofu/remote-state/cf/infisical_variables.tofu @@ -0,0 +1,30 @@ +variable "infisical_domain" { + description = "Infisical Domain" + type = string + default = "https://app.infisical.com" +} + +variable "infisical_client_id" { + description = "Infisical Client ID" + type = string + default = null +} + +variable "infisical_project_id" { + description = "Infisical Project ID" + type = string + default = null +} + +variable "infisical_rw_secrets_path" { + description = "Infisical Client Secret" + type = string + default = "/tofu_rw" +} + +variable "infisical_client_secret" { + description = "Infisical Client Secret" + type = string + sensitive = true + default = null +} diff --git a/tofu/remote-state/cf/outputs.tofu b/tofu/remote-state/cf/outputs.tofu new file mode 100644 index 000000000..6befe402a --- /dev/null +++ b/tofu/remote-state/cf/outputs.tofu @@ -0,0 +1,38 @@ +output "bucket_name" { + description = "The name of the R2 bucket." + value = cloudflare_r2_bucket.tofu_bucket.name +} + +output "tofu_remote_state_token" { + description = "The API token for the Tofu remote state bucket." + value = { + id = cloudflare_account_token.r2_tofu_token.id + name = cloudflare_account_token.r2_tofu_token.name + } + sensitive = true +} + +# R2 access credentials for S3-compatible operations +output "r2_access_key_id" { + description = "R2 access key ID for S3-compatible operations" + value = cloudflare_account_token.r2_tofu_token.id + sensitive = true +} + +output "r2_secret_access_key" { + description = "R2 secret access key for S3-compatible operations" + value = cloudflare_account_token.r2_tofu_token.value + sensitive = true +} + +# R2 endpoint for S3-compatible operations +output "r2_endpoint" { + description = "R2 S3-compatible endpoint" + value = "https://${var.cloudflare_account_id}.r2.cloudflarestorage.com" +} + +# Bucket URL for direct access +output "bucket_url" { + description = "Direct URL to the R2 bucket" + value = "https://${var.cloudflare_account_id}.r2.cloudflarestorage.com/${var.bucket_name}" +} diff --git a/tofu/remote-state/cf/providers.tofu b/tofu/remote-state/cf/providers.tofu new file mode 100644 index 000000000..52808882d --- /dev/null +++ b/tofu/remote-state/cf/providers.tofu @@ -0,0 +1,32 @@ +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = ">= 5.6.0" + } + infisical = { + source = "infisical/infisical" + version = ">= 0.15.21" + } + } + encryption { + key_provider "pbkdf2" "my_passphrase" { + passphrase = var.tofu_encryption_passphrase + } + method "aes_gcm" "my_method" { + keys = key_provider.pbkdf2.my_passphrase + } + state { + method = method.aes_gcm.my_method + enforced = true + } + plan { + method = method.aes_gcm.my_method + enforced = true + } + } +} + +provider "cloudflare" { + api_token = var.cloudflare_master_account_api_token +} diff --git a/tofu/remote-state/cf/samples/backend_local.tofu.sample b/tofu/remote-state/cf/samples/backend_local.tofu.sample new file mode 100644 index 000000000..8a1272ae6 --- /dev/null +++ b/tofu/remote-state/cf/samples/backend_local.tofu.sample @@ -0,0 +1,5 @@ +terraform { + backend "local" { + path = "${var.branch_env}.tfstate" + } +} diff --git a/tofu/remote-state/cf/samples/backend_r2.tofu.sample b/tofu/remote-state/cf/samples/backend_r2.tofu.sample new file mode 100644 index 000000000..be346c077 --- /dev/null +++ b/tofu/remote-state/cf/samples/backend_r2.tofu.sample @@ -0,0 +1,18 @@ +terraform { + backend "s3" { + bucket = var.bucket_name + key = "remote-state/cf/${var.branch_env}/terraform.tfstate" + region = "auto" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + skip_requesting_account_id = true + skip_s3_checksum = true + use_path_style = true + endpoints = { + s3 = "https://${var.cloudflare_account_id}.r2.cloudflarestorage.com" + } + access_key = var.cloudflare_r2_tofu_access_key + secret_key = var.cloudflare_r2_tofu_access_secret + } +} diff --git a/tofu/remote-state/cf/variables.tofu b/tofu/remote-state/cf/variables.tofu new file mode 100644 index 000000000..528c8a0df --- /dev/null +++ b/tofu/remote-state/cf/variables.tofu @@ -0,0 +1,53 @@ + +variable "cloudflare_account_id" { + type = string + description = "The Cloudflare account ID." +} + +variable "cloudflare_master_account_api_token" { + type = string + description = "The Cloudflare Account API token for creating r2 bucket and access creds." + sensitive = true +} + +variable "cloudflare_r2_tofu_access_key" { + type = string + description = "The Cloudflare R2 access key for tofu remote state." + default = null +} + +variable "cloudflare_r2_tofu_access_secret" { + type = string + description = "The Cloudflare R2 access secret for tofu remote state." + default = null +} + +# Define a variable for the bucket name. +# Set this in .env file in root, which should automatically set it in devcontainer env. +variable "bucket_name" { + description = "The globally unique name for the bucket." + type = string + + validation { + condition = can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]$", var.bucket_name)) + error_message = "Bucket name must be lowercase, alphanumeric, and hyphens only." + } + + validation { + condition = length(var.bucket_name) >= 3 && length(var.bucket_name) <= 63 + error_message = "Bucket name must be between 3 and 63 characters." + } +} + +# Define a variable for part of bucket prefix. +# This should be set automatically based on branch logic in devcontainer. +variable "branch_env" { + description = "Part of bucket prefix." + type = string +} + +variable "tofu_encryption_passphrase" { + description = "The encryption passphrase for tofu state encryption." + type = string + sensitive = true +} diff --git a/tofu/remote-state/gcs/DOCS.md b/tofu/remote-state/gcs/DOCS.md new file mode 100644 index 000000000..154229536 --- /dev/null +++ b/tofu/remote-state/gcs/DOCS.md @@ -0,0 +1,61 @@ +## Overview + +This OpenTofu module is responsible for provisioning the foundational infrastructure for managing OpenTofu state within Google Cloud Platform (GCP) and enabling secure CI/CD operations via GitHub Actions. + +## Key resources + +- **GCS Bucket for Tofu Remote State**: A Google Cloud Storage (GCS) bucket is created to serve as a centralized and secure backend for storing OpenTofu state files. This includes versioning and lifecycle rules for state backups. +- **Service Account (`tofu-dev-sa`)**: A dedicated Google Cloud Service Account named `tofu-dev-sa` is provisioned. + - **Purpose**: This service account is granted administrative permissions (`roles/storage.objectAdmin`) specifically on the GCS state bucket. + - It is also configured to be impersonated by authorized users and, importantly, by GitHub Actions workflows via Workload Identity Federation. +- **Secrets Management with Infisical**: This module interacts with Infisical for managing sensitive configurations. It's important to distinguish between secrets managed _by this module_ and secrets that _must be manually set up by the user_ in Infisical: + - **User-Managed Secrets (to be created manually by the user in Infisical under the `/tofu` path in the `prod` Infisical environment):** + - `TF_VAR_tofu_encryption_passphrase`: The passphrase for OpenTofu state encryption. (Refer to "Instructions" section on how to generate and where to store). + - `TF_VAR_gcp_sa_dev_emails`: A JSON string array of user emails that are granted permission to impersonate the `tofu-dev-sa` service account (e.g., `'["user1@example.com","user2@example.com"]'`). (Refer to "Instructions" section on where to store). + - **Module-Managed Secrets (automatically created/updated by this Tofu module in Infisical under the path specified by `var.infisical_rw_secrets_path` (default: `/tofu_rw`) in the `prod` Infisical environment or set a different path in .env file in root for TF_VAR_infisical_rw_secrets_path):** + - `GCP_WORKLOAD_IDENTITY_PROVIDER`: The full Google Cloud resource name of the Workload Identity Provider created for GitHub Actions. This is used by GitHub Actions to authenticate to GCP. + - `GCP_SERVICE_ACCOUNT_EMAIL`: The email address of the `tofu-dev-sa` service account. This is also used by GitHub Actions during authentication to specify which service account to impersonate. + +## Workload Identity Federation for GitHub Actions + +This configuration (primarily in `wif.tofu`) sets up Google Cloud Workload Identity Federation, offering significant benefits: + +- **Enhanced Security:** Allows GitHub Actions to authenticate to Google Cloud and access resources (like the GCS state bucket) **without needing long-lived service account keys** stored as GitHub secrets. This is the Google-recommended best practice. +- **Fine-grained Permissions:** The `tofu-dev-sa` service account has specific permissions (e.g., to manage the GCS state bucket). GitHub Actions only inherit these necessary permissions when they impersonate this service account. +- **Auditable:** The impersonation events can be audited in Google Cloud. +- **Infrastructure as Code:** The entire authentication mechanism for GitHub Actions is managed via OpenTofu, making it version-controlled, repeatable, and transparent. + +After this OpenTofu configuration is applied, the `workload_identity_provider_name` and `tofu_dev_service_account_email` are pushed to Infisical (as `GCP_WORKLOAD_IDENTITY_PROVIDER` and `GCP_SERVICE_ACCOUNT_EMAIL` respectively, under the path defined by `var.infisical_rw_secrets_path`). Your GitHub Actions workflows (like `cf_adblock.yaml`) should then be configured to fetch these values from Infisical and use them in the `google-github-actions/auth` step for secure authentication to Google Cloud. + +## Instructions + +1. Setup [gcloud cli](/DEVCONTAINER.md). +2. Setup \*.auto.tfvars files. +3. Setup .env file in root folder and commit it to git. +4. Setup TF_VAR_tofu_encryption_passphrase as per instruction below and save them to infisical in `/tofu` directory (or the directory defined in TF_VAR_infisical_ro_secrets_path in .env file in root). + ```bash + # TF_VAR_tofu_encryption_passphrase generation command + openssl rand -base64 32 + ``` +5. Setup TF_VAR_gcp_sa_dev_emails = ["email1@example.com","email2@example.com"] (emails you want to grant access to) below and save them to infisical in `/tofu` directory (or the directory defined in TF_VAR_infisical_ro_secrets_path in .env file in root). +6. Follow devcontainers docs [here](/DEVCONTAINER.md). If done properly, all secrets from infisical will be available in the container environment. + +```bash +# Copy local backend file +cp samples/backend_local.tofu.sample ./backend.tofu + +# Initialize tofu +tofu init + +# Run tofu apply to create GCS bucket and permissions +tofu apply +``` + +```bash +# Copy GCS backend file +cp samples/backend_gcs.tofu.sample ./backend.tofu + +# Re-initialize tofu to migrate state to GCS backend +# Double check TF_VAR_branch_env is properly set to your env - prod/staging/dev - everytime you checkout a new branch. +tofu init -migrate-state +``` diff --git a/tofu/remote-state/gcs/README.md b/tofu/remote-state/gcs/README.md new file mode 100644 index 000000000..2485b68d1 --- /dev/null +++ b/tofu/remote-state/gcs/README.md @@ -0,0 +1,23 @@ +## Overview + +This OpenTofu module is responsible for provisioning the foundational infrastructure for managing OpenTofu state within Google Cloud Platform (GCP) and enabling secure CI/CD operations via GitHub Actions. + +## Key resources + +- **GCS Bucket for Tofu Remote State**: Stores OpenTofu state files securely with versioning. +- **Service Account (`tofu-dev-sa`)**: Used by authorized users and GitHub Actions (via WIF) to access GCP resources, primarily the state bucket. +- **Secrets Management with Infisical**: Manages module-specific secrets (like WIF details) and references user-managed secrets (like encryption passphrase, SA user emails). + +For detailed information on these resources, including manual setup of user-managed secrets, see [DOCS.md](./DOCS.md#key-resources). + +## Workload Identity Federation for GitHub Actions + +This module sets up Google Cloud Workload Identity Federation (WIF), allowing GitHub Actions to securely authenticate to GCP and access the state bucket without using long-lived keys. The necessary WIF details are managed and pushed to Infisical. + +For a detailed explanation of WIF configuration and benefits, see [DOCS.md](./DOCS.md#workload-identity-federation-for-github-actions). + +## Instructions + +This section provides a quick overview of the manual steps required to set up the GCS state backend. + +For detailed prerequisites and step-by-step instructions, including how to generate and store necessary secrets and configure local variables, please refer to [DOCS.md](./DOCS.md#instructions). diff --git a/tofu/remote-state/gcs/backend.tofu b/tofu/remote-state/gcs/backend.tofu new file mode 100644 index 000000000..75f24a593 --- /dev/null +++ b/tofu/remote-state/gcs/backend.tofu @@ -0,0 +1,6 @@ +terraform { + backend "gcs" { + bucket = var.bucket_name + prefix = "remote-state/gcs/${var.branch_env}" + } +} diff --git a/tofu/remote-state/gcs/gcs.tofu b/tofu/remote-state/gcs/gcs.tofu new file mode 100644 index 000000000..6f2bd2a98 --- /dev/null +++ b/tofu/remote-state/gcs/gcs.tofu @@ -0,0 +1,56 @@ +# 1. Create a Service Account named tofu-dev-sa +resource "google_service_account" "tofu_dev_sa" { + account_id = "tofu-dev-sa" + display_name = "Tofu Dev Service Account" + description = "Service account for development tasks using Tofu" +} + +# 2. Grant users the roles/iam.serviceAccountTokenCreator role on the tofu_dev_sa service account +# This allows the specified users to impersonate the tofu-dev-sa service account. +# Using for_each to iterate over the list of user email addresses. +resource "google_service_account_iam_member" "tofu_dev_sa_token_creator" { + # Use for_each to create an instance of this resource for each email in the list. + # toset() is used to ensure a set of unique keys for for_each. + for_each = toset(jsondecode(var.gcp_sa_dev_emails)) + service_account_id = google_service_account.tofu_dev_sa.name + role = "roles/iam.serviceAccountTokenCreator" + # Use each.value to reference the current email address in the iteration. + member = "user:${each.value}" +} + +# 3. Create a GCS bucket +# Bucket names must be globally unique. +resource "google_storage_bucket" "tofu_remote_state" { + name = var.bucket_name + location = var.gcp_region + storage_class = "STANDARD" + + uniform_bucket_level_access = true + # Enable public access prevention for the bucket + public_access_prevention = "enforced" + + # Enable versioning for objects in the bucket + versioning { + enabled = true + } + + # Add a lifecycle rule to delete noncurrent versions after 90 days + lifecycle_rule { + action { + type = "Delete" + } + condition { + age = 90 # Delete noncurrent versions older than 90 days + num_newer_versions = 100 # Keep up to 100 newer versions + with_state = "NONCURRENT" # Apply this rule to noncurrent versions + } + } +} + +# 4. Add Storage Object Admin permission for this bucket for tofu-dev-sa +# This grants the tofu-dev_sa service account administrative permissions over objects in the bucket. +resource "google_storage_bucket_iam_member" "tofu_remote_state_object_admin" { # Renamed the resource here + bucket = google_storage_bucket.tofu_remote_state.name # Updated reference here + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.tofu_dev_sa.email}" +} diff --git a/tofu/remote-state/gcs/infisical.tofu b/tofu/remote-state/gcs/infisical.tofu new file mode 100644 index 000000000..70d22c28b --- /dev/null +++ b/tofu/remote-state/gcs/infisical.tofu @@ -0,0 +1,37 @@ +provider "infisical" { + host = var.infisical_domain + auth = { + universal = { + client_id = var.infisical_client_id + client_secret = var.infisical_client_secret + } + } +} + +locals { + # Define the secrets to be created in Infisical + # Keys are the names the secrets will have in Infisical + # Values are the Tofu expressions for their content + gcp_wif_secrets_to_infisical = { + # Name the secrets as GitHub Actions will expect them + "GCP_WORKLOAD_IDENTITY_PROVIDER" = google_iam_workload_identity_pool_provider.github_provider.name + "GCP_SERVICE_ACCOUNT_EMAIL" = google_service_account.tofu_dev_sa.email + } +} + +resource "infisical_secret" "gcp_wif_details" { + for_each = local.gcp_wif_secrets_to_infisical + + name = each.key # The name of the secret in Infisical + value = each.value # The value of the secret + + env_slug = var.branch_env + folder_path = var.infisical_rw_secrets_path + workspace_id = var.infisical_project_id + + # Ensure this runs after the resources providing the values are created/updated + depends_on = [ + google_iam_workload_identity_pool_provider.github_provider, + google_service_account.tofu_dev_sa + ] +} diff --git a/tofu/remote-state/gcs/infisical_variables.tofu b/tofu/remote-state/gcs/infisical_variables.tofu new file mode 100644 index 000000000..d2e290c1d --- /dev/null +++ b/tofu/remote-state/gcs/infisical_variables.tofu @@ -0,0 +1,30 @@ +variable "infisical_domain" { + description = "Infisical Domain" + type = string + default = "https://app.infisical.com" +} + +variable "infisical_client_id" { + description = "Infisical Client ID" + type = string + default = null +} + +variable "infisical_project_id" { + description = "Infisical Project ID" + type = string + default = null +} + +variable "infisical_rw_secrets_path" { + description = "Infisical Client Secret" + type = string + default = "/tofu_rw" +} + +variable "infisical_client_secret" { + description = "Infisical Client Secret" + type = string + sensitive = true + default = null +} diff --git a/tofu/remote-state/gcs/outputs.tofu b/tofu/remote-state/gcs/outputs.tofu new file mode 100644 index 000000000..889097f4c --- /dev/null +++ b/tofu/remote-state/gcs/outputs.tofu @@ -0,0 +1,12 @@ +# --- Outputs for Workload Identity Federation --- + +output "workload_identity_provider_name" { + description = "The full resource name of the Workload Identity Provider for GitHub Actions. Use this for the GCP_WORKLOAD_IDENTITY_PROVIDER GitHub secret." + value = google_iam_workload_identity_pool_provider.github_provider.name + # Example output: projects/YOUR_PROJECT_NUMBER/locations/global/workloadIdentityPools/YOUR_POOL_ID/providers/YOUR_PROVIDER_ID +} + +output "tofu_dev_service_account_email" { + description = "The email of the service account that GitHub Actions will impersonate. Use this for the GCP_SERVICE_ACCOUNT_EMAIL GitHub secret." + value = google_service_account.tofu_dev_sa.email +} diff --git a/tofu/remote-state/gcs/providers.tofu b/tofu/remote-state/gcs/providers.tofu new file mode 100644 index 000000000..35f9b67b2 --- /dev/null +++ b/tofu/remote-state/gcs/providers.tofu @@ -0,0 +1,38 @@ +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.33.0" + } + infisical = { + source = "infisical/infisical" + version = ">= 0.15.7" + } + external = { + source = "hashicorp/external" + version = ">= 2.3.4" + } + } + encryption { + key_provider "pbkdf2" "my_passphrase" { + passphrase = var.tofu_encryption_passphrase + } + method "aes_gcm" "my_method" { + keys = key_provider.pbkdf2.my_passphrase + } + state { + method = method.aes_gcm.my_method + enforced = true + } + plan { + method = method.aes_gcm.my_method + enforced = true + } + } +} + +# Configure the Google Cloud provider +provider "google" { + project = var.gcp_project_id + region = var.gcp_region +} diff --git a/tofu/remote-state/gcs/samples/backend_gcs.tofu.sample b/tofu/remote-state/gcs/samples/backend_gcs.tofu.sample new file mode 100644 index 000000000..75f24a593 --- /dev/null +++ b/tofu/remote-state/gcs/samples/backend_gcs.tofu.sample @@ -0,0 +1,6 @@ +terraform { + backend "gcs" { + bucket = var.bucket_name + prefix = "remote-state/gcs/${var.branch_env}" + } +} diff --git a/tofu/remote-state/gcs/samples/backend_local.tofu.sample b/tofu/remote-state/gcs/samples/backend_local.tofu.sample new file mode 100644 index 000000000..8a1272ae6 --- /dev/null +++ b/tofu/remote-state/gcs/samples/backend_local.tofu.sample @@ -0,0 +1,5 @@ +terraform { + backend "local" { + path = "${var.branch_env}.tfstate" + } +} diff --git a/tofu/remote-state/gcs/variables.auto.tfvars b/tofu/remote-state/gcs/variables.auto.tfvars new file mode 100644 index 000000000..38dbcae17 --- /dev/null +++ b/tofu/remote-state/gcs/variables.auto.tfvars @@ -0,0 +1,8 @@ +# The Google Cloud project ID to deploy resources into. +gcp_project_id = "homelab-454718" + +# Ideally, set TF_VAR_bucket_name in .env at root. +# bucket_name = "kj-homelab-tf-state" + +# The Google Cloud region to deploy resources into. +gcp_region = "us-east1" diff --git a/tofu/remote-state/gcs/variables.tofu b/tofu/remote-state/gcs/variables.tofu new file mode 100644 index 000000000..faf7e9b89 --- /dev/null +++ b/tofu/remote-state/gcs/variables.tofu @@ -0,0 +1,45 @@ +# Define a variable for the Google Cloud project ID +variable "gcp_project_id" { + description = "The Google Cloud project ID to deploy resources into." + type = string +} +# Define a variable for the Google Cloud region +variable "gcp_region" { + description = "The Google Cloud region to deploy resources into." + type = string +} + +# Define a variable for the bucket name. +# Set this in .env file in root, which should automatically set it in devcontainer env. +variable "bucket_name" { + description = "The globally unique name for the bucket." + type = string + + validation { + condition = can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]$", var.bucket_name)) + error_message = "Bucket name must be lowercase, alphanumeric, and hyphens only." + } + + validation { + condition = length(var.bucket_name) >= 3 && length(var.bucket_name) <= 63 + error_message = "Bucket name must be between 3 and 63 characters." + } +} + +# Define a variable for part of bucket prefix. +# This should be set automatically based on branch logic in devcontainer. +variable "branch_env" { + description = "Part of bucket prefix." + type = string +} + +variable "tofu_encryption_passphrase" { + description = "The encryption passphrase for tofu state encryption." + type = string + sensitive = true +} + +variable "gcp_sa_dev_emails" { + description = "GCP SA Dev emails" + type = string +} diff --git a/tofu/remote-state/gcs/wif.auto.tfvars b/tofu/remote-state/gcs/wif.auto.tfvars new file mode 100644 index 000000000..b447cb49f --- /dev/null +++ b/tofu/remote-state/gcs/wif.auto.tfvars @@ -0,0 +1,9 @@ +# Workload Identity Federation Configuration for GitHub Actions +wif_config = { + github_owner = "karteekiitg" # Replace with your github username / org name + github_repository = "homelab" # Replace with your GitHub repository name + pool_id = "gh-actions-pool" # Choose a suitable ID for the WIF pool + pool_display_name = "GitHub Actions WIF Pool" # Choose a display name for the pool + provider_id = "gh-actions-provider" # Choose a suitable ID for the WIF provider + provider_display_name = "GitHub Actions WIF Provider" # Choose a display name for the provider +} diff --git a/tofu/remote-state/gcs/wif.tofu b/tofu/remote-state/gcs/wif.tofu new file mode 100644 index 000000000..b1ac789d5 --- /dev/null +++ b/tofu/remote-state/gcs/wif.tofu @@ -0,0 +1,53 @@ +# --- Workload Identity Federation for GitHub Actions --- + +# Data source to get the current project details (like project number) +data "google_project" "current" { + project_id = var.gcp_project_id # Assuming var.gcp_project_id is defined and set +} + +# 1. Create a Workload Identity Pool +resource "google_iam_workload_identity_pool" "github_pool" { + project = var.gcp_project_id + workload_identity_pool_id = var.wif_config.pool_id + display_name = var.wif_config.pool_display_name + description = "Workload Identity Pool for GitHub Actions to access GCP resources for ${var.wif_config.github_owner}/${var.wif_config.github_repository}" + disabled = false # Ensure it's enabled +} + +# 2. Create a Workload Identity Provider within the pool for GitHub +resource "google_iam_workload_identity_pool_provider" "github_provider" { + project = var.gcp_project_id + workload_identity_pool_id = google_iam_workload_identity_pool.github_pool.workload_identity_pool_id + workload_identity_pool_provider_id = var.wif_config.provider_id + display_name = var.wif_config.provider_display_name + description = "OIDC provider for GitHub Actions for repository ${var.wif_config.github_owner}/${var.wif_config.github_repository}" + disabled = false # Ensure it's enabled + + oidc { + issuer_uri = "https://token.actions.githubusercontent.com" + allowed_audiences = [] # Typically left empty for GitHub Actions OIDC. + } + + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.actor" = "assertion.actor" + "attribute.repository" = "assertion.repository" + # "attribute.repository_owner" = "assertion.repository_owner" # Can be useful for org-level policies + } + + # Attribute condition to restrict to your specific repository. + # This is a key security feature. + attribute_condition = "attribute.repository == '${var.wif_config.github_owner}/${var.wif_config.github_repository}'" +} + +# 3. Grant the GitHub Actions Workload Identity permission to impersonate the tofu-dev-sa service account +# This allows workflows from your GitHub repository (matching the provider's condition) +# to generate tokens for the 'tofu-dev-sa' service account (defined in gcs.tofu). +resource "google_service_account_iam_member" "github_wif_user_for_tofu_sa" { + service_account_id = google_service_account.tofu_dev_sa.name # From gcs.tofu + role = "roles/iam.workloadIdentityUser" + + # This member principal string identifies the GitHub Actions principals that can impersonate. + # It uses the workload identity pool and the 'repository' attribute that we mapped. + member = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github_pool.workload_identity_pool_id}/attribute.repository/${var.wif_config.github_owner}/${var.wif_config.github_repository}" +} diff --git a/tofu/remote-state/gcs/wif_variables.tofu b/tofu/remote-state/gcs/wif_variables.tofu new file mode 100644 index 000000000..56e80112e --- /dev/null +++ b/tofu/remote-state/gcs/wif_variables.tofu @@ -0,0 +1,11 @@ +variable "wif_config" { + description = "Configuration for Workload Identity Federation with GitHub Actions." + type = object({ + github_owner = string + github_repository = string + pool_id = string + pool_display_name = string + provider_id = string + provider_display_name = string + }) +}