diff --git a/Taskfile.yml b/Taskfile.yml index eed1fdc..6be9e11 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -112,13 +112,13 @@ tasks: export AZUREAD_CLIENT_SECRET="$ARM_CLIENT_SECRET" export AZUREAD_TENANT_ID="$ARM_TENANT_ID" while IFS= read -r config_dir; do - if [ "$config_dir" = "$root" ]; then - target="$temp/root" - else - target="$temp/${config_dir#"$root"/}" - fi - mkdir -p "$target" - tar --exclude='.git' --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$config_dir" . | tar -xf - -C "$target" + # Copy the whole repository so relative module sources (../../runner_group) resolve + rel="${config_dir#"$root"}" + rel="${rel#/}" + work="$temp/$(printf '%s' "${rel:-root}" | tr '/' '_')" + target="$work${rel:+/$rel}" + mkdir -p "$work" + tar --exclude='.git' --exclude='.terraform' --exclude='.terraform.lock.hcl' -cf - -C "$root" . | tar -xf - -C "$work" if [ "$config_dir" = "$root" ]; then : > "$target/provider.tf" printf '%s\n' \ @@ -142,7 +142,7 @@ tasks: while IFS= read -r provider_file; do perl -0pi -e 's/required_version = "= 1\.5\.7"/required_version = ">= 1.6.0"/' "$provider_file" perl -0pi -e 's/source\s*=\s*"StackGuardian\/stackguardian"/source = "registry.terraform.io\/StackGuardian\/stackguardian"/' "$provider_file" - done < <(find "$target" -name provider.tf -type f) + done < <(find "$work" -name provider.tf -type f) tofu -chdir="$target" init -backend=false tofu -chdir="$target" test done < <(find "$root" \( -name .git -o -name .terraform \) -type d -prune -o -path '*/tests/*.tofutest.hcl' -type f -print | while IFS= read -r test_file; do dirname "$(dirname "$test_file")"; done | sort -u) diff --git a/stackguardian_private_runner/README.md b/stackguardian_private_runner/README.md index da16b4b..65d4447 100644 --- a/stackguardian_private_runner/README.md +++ b/stackguardian_private_runner/README.md @@ -170,7 +170,7 @@ runner_constraints: ### Runner Group - **StackGuardian Runner Group**: Platform integration for runner management -- **S3 Storage Backend**: Encrypted bucket for Terraform state with versioning +- **S3 Storage Backend**: Private bucket for runner state, always versioned so state files keep their history - **AWS Connector**: Cross-account access configuration ### Autoscaling Group diff --git a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md index 66075aa..91b0f4e 100644 --- a/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md +++ b/stackguardian_private_runner/aws/runner_group/DOCUMENTATION.md @@ -45,6 +45,8 @@ Deploying on Azure instead? Use the **StackGuardian Runner Group - Azure** templ | Create Storage Backend | Whether to create a new S3 bucket | Enabled | | Existing S3 Bucket Name | Name of an existing S3 bucket to use (when not creating new) | — | | Force Destroy Storage Backend | Delete all data in the S3 bucket on destroy (use with caution) | Disabled | +| State File Version History — Old Version Retention (days) | Days to keep a version after it is replaced; 0 keeps them forever | 365 | +| Extra CORS Origins | Extra browser origins allowed to fetch from the bucket (e.g. a local frontend at http://localhost:3000); the StackGuardian console is always allowed | — | | Global Prefix | Prefix for the runner group and connector names. Leave empty to omit it | SG_RUNNER | | Runner Group Name | Name half of the runner group; the full name is `{prefix}-{name}` | 6-character random string | | Connector Name | Name half of the connector | Same as the runner group | @@ -79,6 +81,14 @@ and its name is not information a tag adds. **Data Retention**: **Force Destroy Storage Backend** deletes all bucket contents on destroy. Leave it disabled to protect your data. +**Versioning**: Always on, and not optional. The storage backend keeps the history of +the runners' state files, so an overwritten or deleted state file can always be +recovered from a previous version. S3 keeps every previous version of an object, and a +lifecycle rule deletes each one **Old Version Retention** days after it was replaced. +The same lifecycle configuration aborts incomplete multipart uploads after 7 days. +Existing deployments get versioning turned on in place on their next apply. Applies only +to a bucket this template creates; for an existing bucket, enable versioning yourself. + ## Outputs | Output | Description | diff --git a/stackguardian_private_runner/aws/runner_group/README.md b/stackguardian_private_runner/aws/runner_group/README.md index 2e7e971..11f78e2 100644 --- a/stackguardian_private_runner/aws/runner_group/README.md +++ b/stackguardian_private_runner/aws/runner_group/README.md @@ -15,6 +15,11 @@ deployment never initializes `azurerm` or `azuread`. The Azure equivalent is - **S3 bucket** with public access blocked and CORS limited to the StackGuardian platform origin (when `create_storage_backend = true`). +- **Object versioning** on that bucket, always on, so the runners' state files keep their + history. +- **Lifecycle rules** on that bucket: old versions are deleted + `noncurrent_retention_days` after they are replaced, and incomplete multipart uploads + are aborted after 7 days. - **IAM role + policy** scoped to the bucket. The trust policy allows the StackGuardian AWS accounts (`163602625436`, `476299211833`) and the caller's own account, gated by an external ID of the form `{org_name}:{24-char-random}`. @@ -81,6 +86,8 @@ module "runner_group" { | `create_storage_backend` | Create a new S3 bucket | `true` | | `existing_s3_bucket_name` | Existing bucket name (when `create_storage_backend = false`) | `""` | | `force_destroy_storage_backend` | Force destroy the bucket on `destroy` — deletes all objects | `false` | +| `storage_backend_versioning.noncurrent_retention_days` | Days to keep a version after it is replaced; `0` keeps them forever | `365` | +| `storage_backend_cors_extra_origins` | Extra browser origins for the bucket's CORS rule (e.g. `http://localhost:3000`); bare origins only, no wildcards; the console origin is always allowed | `[]` | | `override_names.global_prefix` | Prefix for the runner group and connector names; `""` omits it | `SG_RUNNER` | | `override_names.runner_group_name` | Name half of the runner group | (6-char random) | | `override_names.connector_name` | Name half of the connector | (runner group's name) | @@ -141,9 +148,16 @@ together. ## Security Notes - The bucket blocks all public access and accepts browser requests only from the - StackGuardian console origin. + StackGuardian console origin, plus any origins you list in + `storage_backend_cors_extra_origins` (no wildcards accepted). - The IAM policy grants only the S3 actions runners need, scoped to the one bucket. - Cross-account access is gated by a random external ID, so a leaked role ARN alone is not enough to assume the role. - `force_destroy_storage_backend` deletes every object in the bucket on `destroy`. Leave it off unless you mean it. +- Versioning is always on and can't be turned off: the storage backend keeps the history + of the runners' state files, so an overwritten or deleted state file can always be + recovered. `storage_backend_versioning` only sets how long old versions are kept. +- With `create_storage_backend = false` the module doesn't touch your bucket: enable + versioning on it yourself, and set up CORS. A plan warning says so when extra CORS + origins are set. diff --git a/stackguardian_private_runner/aws/runner_group/checks.tf b/stackguardian_private_runner/aws/runner_group/checks.tf new file mode 100644 index 0000000..92e2941 --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/checks.tf @@ -0,0 +1,9 @@ +# Plan-time warnings. A failing check never blocks a plan or apply; it prints a +# warning. + +check "existing_bucket_cors_ignored" { + assert { + condition = var.create_storage_backend || length(var.storage_backend_cors_extra_origins) == 0 + error_message = "create_storage_backend = false: storage_backend_cors_extra_origins is not applied to the existing bucket ${var.existing_s3_bucket_name}. Configure CORS on it yourself." + } +} diff --git a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json index 5a14947..2e180b8 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/input_schema.json @@ -127,6 +127,29 @@ "title": "Force Destroy Storage Backend", "type": "boolean", "default": false + }, + "storage_backend_versioning": { + "title": "State File Version History", + "type": "object", + "properties": { + "noncurrent_retention_days": { + "title": "Old Version Retention (days)", + "type": "integer", + "default": 365, + "minimum": 0, + "maximum": 99999 + } + }, + "additionalProperties": false + }, + "storage_backend_cors_extra_origins": { + "title": "Extra CORS Origins", + "type": "array", + "items": { + "type": "string", + "pattern": "^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$" + }, + "default": [] } } }, diff --git a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json index 41def6f..1931e69 100644 --- a/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json +++ b/stackguardian_private_runner/aws/runner_group/schemas/ui_schema.json @@ -7,6 +7,8 @@ "create_storage_backend", "existing_s3_bucket_name", "force_destroy_storage_backend", + "storage_backend_versioning", + "storage_backend_cors_extra_origins", "override_names", "max_runners" ], @@ -47,6 +49,19 @@ "ui:widget": "checkbox", "ui:description": "Warning: Force destroy the S3 bucket on module destruction (deletes all data)" }, + "storage_backend_versioning": { + "ui:title": "State File Version History", + "ui:description": "Versioning is always on for the S3 bucket: it keeps the history of the runners' state files, so an overwritten or deleted state file can be recovered. Choose how long old versions are kept.", + "ui:order": [ + "noncurrent_retention_days" + ], + "noncurrent_retention_days": { + "ui:description": "Days to keep a version after it is replaced, then delete it. 0 keeps old versions forever" + } + }, + "storage_backend_cors_extra_origins": { + "ui:description": "Extra browser origins allowed to fetch from the S3 bucket, e.g. http://localhost:3000. The StackGuardian console origin is always allowed" + }, "override_names": { "ui:title": "Resource Naming Configuration", "ui:description": "Customize resource names (optional)", diff --git a/stackguardian_private_runner/aws/runner_group/storage_backend.tf b/stackguardian_private_runner/aws/runner_group/storage_backend.tf index a2974ce..245ce73 100644 --- a/stackguardian_private_runner/aws/runner_group/storage_backend.tf +++ b/stackguardian_private_runner/aws/runner_group/storage_backend.tf @@ -34,13 +34,71 @@ resource "aws_s3_bucket_cors_configuration" "this" { cors_rule { allowed_headers = ["*"] allowed_methods = ["GET", "HEAD", "PUT"] - allowed_origins = [ - local.sg_app_uri - ] + allowed_origins = concat( + [local.sg_app_uri], + var.storage_backend_cors_extra_origins, + ) expose_headers = [] } } +# Object versioning - always on. The storage backend keeps the history of the +# runners' state files, so this is not optional. +resource "aws_s3_bucket_versioning" "this" { + count = var.create_storage_backend ? 1 : 0 + + bucket = aws_s3_bucket.this[0].id + + versioning_configuration { + status = "Enabled" + } +} + +# Lifecycle rules for the bucket this module creates. This resource owns the +# bucket's whole lifecycle configuration. +resource "aws_s3_bucket_lifecycle_configuration" "this" { + count = var.create_storage_backend ? 1 : 0 + + bucket = aws_s3_bucket.this[0].id + + # Expire versions noncurrent_retention_days after they are replaced, then drop + # the delete markers left with no versions behind them. Skipped when retention + # is 0 (keep forever). + dynamic "rule" { + for_each = var.storage_backend_versioning.noncurrent_retention_days > 0 ? [1] : [] + + content { + id = "expire-noncurrent-versions" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = var.storage_backend_versioning.noncurrent_retention_days + } + + expiration { + expired_object_delete_marker = true + } + } + } + + # Interrupted multipart uploads (large state or artifact files) otherwise + # leave their parts in the bucket, billed, until removed by hand. + rule { + id = "abort-incomplete-multipart-uploads" + status = "Enabled" + + filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + depends_on = [aws_s3_bucket_versioning.this] +} + # Data source for existing S3 bucket (when using an existing bucket) data "aws_s3_bucket" "existing" { count = var.create_storage_backend ? 0 : 1 diff --git a/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl new file mode 100644 index 0000000..ae5393e --- /dev/null +++ b/stackguardian_private_runner/aws/runner_group/tests/storage_backend.tofutest.hcl @@ -0,0 +1,198 @@ +mock_provider "stackguardian" {} +# ARN and ID attributes are validated at plan time, so the mocks need real shapes. +mock_provider "aws" { + mock_resource "aws_iam_policy" { + defaults = { + arn = "arn:aws:iam::123456789012:policy/test-runner-s3-policy" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/test-private-runner-s3-role" + } + } + + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } +} + +variables { + stackguardian = { + api_key = "sgu_testkey" + org_name = "contract-org" + } +} + +run "versioning_always_on" { + command = plan + + assert { + condition = aws_s3_bucket_versioning.this[0].versioning_configuration[0].status == "Enabled" + error_message = "Versioning must always be on for a bucket the module creates." + } + + assert { + condition = [for r in aws_s3_bucket_lifecycle_configuration.this[0].rule : r.id] == ["expire-noncurrent-versions", "abort-incomplete-multipart-uploads"] + error_message = "By default the bucket gets the version expiry and the multipart cleanup rules." + } + + assert { + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].noncurrent_version_expiration[0].noncurrent_days == 365 + error_message = "Noncurrent versions must expire after 365 days by default." + } + + assert { + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].expiration[0].expired_object_delete_marker == true + error_message = "Expired delete markers must be cleaned up." + } + + assert { + condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[1].abort_incomplete_multipart_upload[0].days_after_initiation == 7 + error_message = "Incomplete multipart uploads must be aborted after 7 days." + } +} + +run "old_enabled_flag_cannot_turn_versioning_off" { + command = plan + + variables { + storage_backend_versioning = { + enabled = false + noncurrent_retention_days = 365 + } + } + + assert { + condition = aws_s3_bucket_versioning.this[0].versioning_configuration[0].status == "Enabled" + error_message = "A leftover enabled = false must not turn versioning off." + } +} + +run "zero_retention_keeps_versions_forever" { + command = plan + + variables { + storage_backend_versioning = { + noncurrent_retention_days = 0 + } + } + + assert { + condition = length(aws_s3_bucket_versioning.this) == 1 + error_message = "Versioning must be enabled." + } + + assert { + condition = [for r in aws_s3_bucket_lifecycle_configuration.this[0].rule : r.id] == ["abort-incomplete-multipart-uploads"] + error_message = "Retention 0 must leave only the multipart cleanup rule." + } +} + +run "existing_bucket_is_left_alone" { + command = plan + + variables { + create_storage_backend = false + existing_s3_bucket_name = "existing-bucket" + } + + assert { + condition = length(aws_s3_bucket_versioning.this) == 0 && length(aws_s3_bucket_lifecycle_configuration.this) == 0 + error_message = "A bucket the module doesn't create must not get versioning or lifecycle rules." + } +} + +run "existing_bucket_warns_about_ignored_cors" { + command = plan + + variables { + create_storage_backend = false + existing_s3_bucket_name = "existing-bucket" + storage_backend_cors_extra_origins = ["http://localhost:3000"] + } + + expect_failures = [check.existing_bucket_cors_ignored] +} + +run "rejects_fractional_retention" { + command = plan + + variables { + storage_backend_versioning = { + noncurrent_retention_days = 1.5 + } + } + + expect_failures = [var.storage_backend_versioning] +} + +run "rejects_negative_retention" { + command = plan + + variables { + storage_backend_versioning = { + noncurrent_retention_days = -1 + } + } + + expect_failures = [var.storage_backend_versioning] +} + +run "cors_allows_console_origin_only_by_default" { + command = plan + + variables { + stackguardian = { + api_key = "sgu_testkey" + api_uri = "https://testapi.qa.stackguardian.io" + org_name = "contract-org" + } + } + + assert { + condition = tolist(aws_s3_bucket_cors_configuration.this[0].cors_rule)[0].allowed_origins == toset(["https://dash.qa.stackguardian.io"]) + error_message = "Only the console origin of the selected API region may be allowed by default." + } +} + +run "cors_adds_extra_origins" { + command = plan + + variables { + stackguardian = { + api_key = "sgu_testkey" + api_uri = "https://testapi.qa.stackguardian.io" + org_name = "contract-org" + } + storage_backend_cors_extra_origins = ["http://localhost:3000", "https://dash.qa.stackguardian.io"] + } + + assert { + condition = tolist(aws_s3_bucket_cors_configuration.this[0].cors_rule)[0].allowed_origins == toset(["https://dash.qa.stackguardian.io", "http://localhost:3000"]) + error_message = "Extra origins must be added next to the console origin, without duplicates." + } +} + +run "cors_rejects_wildcard_origin" { + command = plan + + variables { + storage_backend_cors_extra_origins = ["https://*"] + } + + expect_failures = [var.storage_backend_cors_extra_origins] +} + +run "cors_rejects_path_query_and_fragment" { + command = plan + + variables { + storage_backend_cors_extra_origins = ["http://localhost:3000/", "https://a.example.com?x", "https://a.example.com#f"] + } + + expect_failures = [var.storage_backend_cors_extra_origins] +} diff --git a/stackguardian_private_runner/aws/runner_group/variables.tf b/stackguardian_private_runner/aws/runner_group/variables.tf index 2839b1f..ca82afe 100644 --- a/stackguardian_private_runner/aws/runner_group/variables.tf +++ b/stackguardian_private_runner/aws/runner_group/variables.tf @@ -26,6 +26,54 @@ variable "force_destroy_storage_backend" { default = false } +variable "storage_backend_versioning" { + description = <= 0 + && var.storage_backend_versioning.noncurrent_retention_days <= 99999 + ) + error_message = "storage_backend_versioning.noncurrent_retention_days must be a whole number between 0 and 99999." + } +} + +variable "storage_backend_cors_extra_origins" { + description = < 0 + ) ? 1 : 0 + + storage_account_id = azurerm_storage_account.this[0].id + + rule { + name = "expire-noncurrent-versions" + enabled = true + + filters { + blob_types = ["blockBlob"] + } + + actions { + version { + delete_after_days_since_creation = var.storage_backend_versioning.noncurrent_retention_days + } + } + } +} diff --git a/stackguardian_private_runner/azure/runner_group/tests/storage_backend.tofutest.hcl b/stackguardian_private_runner/azure/runner_group/tests/storage_backend.tofutest.hcl new file mode 100644 index 0000000..c6fbea7 --- /dev/null +++ b/stackguardian_private_runner/azure/runner_group/tests/storage_backend.tofutest.hcl @@ -0,0 +1,172 @@ +mock_provider "stackguardian" {} +# Resource IDs are parsed at plan time, so the mocks need real shapes. +mock_provider "azurerm" { + mock_resource "azurerm_storage_account" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/testaccount" + } + } + + mock_data "azurerm_client_config" { + defaults = { + subscription_id = "00000000-0000-0000-0000-000000000000" + tenant_id = "00000000-0000-0000-0000-000000000000" + object_id = "00000000-0000-0000-0000-000000000000" + } + } +} +mock_provider "azuread" { + mock_resource "azuread_application" { + defaults = { + id = "/applications/00000000-0000-0000-0000-000000000000" + client_id = "00000000-0000-0000-0000-000000000000" + } + } +} + +variables { + stackguardian = { + api_key = "sgu_testkey" + org_name = "contract-org" + } +} + +run "versioning_always_on" { + command = plan + + assert { + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == true + error_message = "Blob versioning must always be on for an account the module creates." + } + + assert { + condition = azurerm_storage_management_policy.this[0].rule[0].actions[0].version[0].delete_after_days_since_creation == 365 + error_message = "Previous versions must be deleted after 365 days by default." + } +} + +run "old_enabled_flag_cannot_turn_versioning_off" { + command = plan + + variables { + storage_backend_versioning = { + enabled = false + noncurrent_retention_days = 365 + } + } + + assert { + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == true + error_message = "A leftover enabled = false must not turn blob versioning off." + } +} + +run "zero_retention_keeps_versions_forever" { + command = plan + + variables { + storage_backend_versioning = { + noncurrent_retention_days = 0 + } + } + + assert { + condition = azurerm_storage_account.this[0].blob_properties[0].versioning_enabled == true + error_message = "Blob versioning must be enabled." + } + + assert { + condition = length(azurerm_storage_management_policy.this) == 0 + error_message = "Retention 0 must not create a delete rule." + } +} + +run "rejects_premium_tier" { + command = plan + + variables { + azure_storage = { + account_tier = "Premium" + } + } + + expect_failures = [var.azure_storage] +} + +run "existing_account_is_left_alone" { + command = plan + + variables { + create_storage_backend = false + existing_azure_storage_account_name = "existingaccount" + existing_azure_storage_account_access_key = "dGVzdA==" + } + + assert { + condition = length(azurerm_storage_account.this) == 0 && length(azurerm_storage_management_policy.this) == 0 + error_message = "An account the module doesn't create must not get versioning or a management policy." + } +} + +run "existing_account_warns_about_ignored_cors" { + command = plan + + variables { + create_storage_backend = false + existing_azure_storage_account_name = "existingaccount" + existing_azure_storage_account_access_key = "dGVzdA==" + storage_backend_cors_extra_origins = ["http://localhost:3000"] + } + + expect_failures = [check.existing_account_cors_ignored] +} + +run "rejects_retention_above_azure_max" { + command = plan + + variables { + storage_backend_versioning = { + noncurrent_retention_days = 100000 + } + } + + expect_failures = [var.storage_backend_versioning] +} + +run "cors_adds_extra_origins_without_duplicates" { + command = plan + + variables { + stackguardian = { + api_key = "sgu_testkey" + api_uri = "https://testapi.qa.stackguardian.io" + org_name = "contract-org" + } + storage_backend_cors_extra_origins = ["http://localhost:3000", "https://dash.qa.stackguardian.io"] + } + + assert { + condition = azurerm_storage_account.this[0].blob_properties[0].cors_rule[0].allowed_origins == tolist(["https://dash.qa.stackguardian.io", "http://localhost:3000"]) + error_message = "Extra origins must be added next to the console origin, without duplicates." + } +} + +run "cors_rejects_wildcard_origin" { + command = plan + + variables { + storage_backend_cors_extra_origins = ["https://*.example.com"] + } + + expect_failures = [var.storage_backend_cors_extra_origins] +} + +run "cors_rejects_path_query_and_fragment" { + command = plan + + variables { + storage_backend_cors_extra_origins = ["http://localhost:3000/logs", "https://a.example.com?x", "https://a.example.com#f"] + } + + expect_failures = [var.storage_backend_cors_extra_origins] +} diff --git a/stackguardian_private_runner/azure/runner_group/variables.tf b/stackguardian_private_runner/azure/runner_group/variables.tf index 17b9b04..f880eae 100644 --- a/stackguardian_private_runner/azure/runner_group/variables.tf +++ b/stackguardian_private_runner/azure/runner_group/variables.tf @@ -27,7 +27,9 @@ variable "azure_storage" { description = <= 0 + && var.storage_backend_versioning.noncurrent_retention_days <= 99999 + ) + error_message = "storage_backend_versioning.noncurrent_retention_days must be a whole number between 0 and 99999." + } +} + +variable "storage_backend_cors_extra_origins" { + description = <