diff --git a/gcp/modules/dex/gcs.tf b/gcp/modules/dex/gcs.tf new file mode 100644 index 00000000..680132cf --- /dev/null +++ b/gcp/modules/dex/gcs.tf @@ -0,0 +1,71 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +locals { + workload_iam_member_id = format("principal://iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s.svc.id.goog/subject/ns/%s/sa/%s", var.project_number, var.project_id, var.cluster_namespace, var.cluster_service_account) +} + +# Grant the K8s workload identity direct permission to push keys to the bucket +resource "google_storage_bucket_iam_member" "k8s_pusher_access" { + count = var.single_region ? 0 : 1 + + bucket = var.bucket_name + role = "roles/storage.objectUser" + member = local.workload_iam_member_id +} + +data "archive_file" "function_source" { + type = "zip" + source_dir = "${path.module}/src/jwks-merger" + output_path = "${path.module}/jwks-merger.zip" +} + +resource "google_storage_bucket_object" "function_zip" { + count = var.single_region ? 0 : 1 + + name = "source/jwks-merger-${data.archive_file.function_source.output_md5}.zip" + bucket = var.bucket_name + source = data.archive_file.function_source.output_path +} + +resource "google_cloudfunctions2_function" "jwks_merger" { + count = var.single_region ? 0 : 1 + + project = var.project_id + + name = "dex-jwks-merger" + location = var.region + + build_config { + runtime = "go125" + entry_point = "MergeKeys" + source { + storage_source { + bucket = var.bucket_name + object = google_storage_bucket_object.function_zip[count.index].name + } + } + } + + event_trigger { + trigger_region = "us" + event_type = "google.cloud.storage.object.v1.finalized" + event_filters { + attribute = "bucket" + value = var.bucket_name + } + } +} diff --git a/gcp/modules/dex/global/gcs.tf b/gcp/modules/dex/global/gcs.tf new file mode 100644 index 00000000..9022b897 --- /dev/null +++ b/gcp/modules/dex/global/gcs.tf @@ -0,0 +1,56 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +resource "google_storage_bucket" "auth_bucket" { + project = var.project_id + + name = "${var.project_id}-dex-jwks-storage" + location = "US" + + uniform_bucket_level_access = true +} + +# Grant the global internet permission to read the merged keys file via the CDN +resource "google_storage_bucket_iam_member" "public_read_access" { + bucket = google_storage_bucket.auth_bucket.name + role = "roles/storage.objectViewer" + member = "allUsers" +} + +# Grant the default compute service account (which runs the Cloud Function) access +resource "google_storage_bucket_iam_member" "function_bucket_access" { + bucket = google_storage_bucket.auth_bucket.name + role = "roles/storage.objectUser" + member = "serviceAccount:${var.project_number}-compute@developer.gserviceaccount.com" +} + +# Grant the Eventarc Service Agent its required project-level role +resource "google_project_iam_member" "eventarc_service_agent" { + project = var.project_id + role = "roles/eventarc.serviceAgent" + member = "serviceAccount:service-${var.project_number}@gcp-sa-eventarc.iam.gserviceaccount.com" +} + +data "google_storage_project_service_account" "gcs_account" { + project = var.project_id +} + +# Grant the project's hidden Cloud Storage agent permission to publish to Pub/Sub +resource "google_project_iam_member" "gcs_pubsub_publishing" { + project = var.project_id + role = "roles/pubsub.publisher" + member = "serviceAccount:${data.google_storage_project_service_account.gcs_account.email_address}" +} diff --git a/gcp/modules/dex/global/network.tf b/gcp/modules/dex/global/network.tf new file mode 100644 index 00000000..8cbdc070 --- /dev/null +++ b/gcp/modules/dex/global/network.tf @@ -0,0 +1,309 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +########################### PER-SERVICE ############################ +# One per service globally, whether single region or multi-region. # +#################################################################### + +locals { + hostname = trimsuffix("oauth2.${var.dns_domain_name}", ".") +} + +resource "google_dns_record_set" "A_dex" { + count = var.manage_dns_a_record ? 1 : 0 + + name = "oauth2.${var.dns_domain_name}" + type = "A" + ttl = 60 + + project = var.project_id + + managed_zone = var.dns_zone_name + rrdatas = [google_compute_global_address.gce_lb_ipv4.address] +} + +resource "google_certificate_manager_dns_authorization" "dex_auth" { + count = var.single_region ? 0 : 1 + + name = "dex-dns-auth" + domain = local.hostname +} + +resource "google_dns_record_set" "CNAME_auth_dex" { + count = var.single_region ? 0 : 1 + + project = var.project_id + + name = google_certificate_manager_dns_authorization.dex_auth[count.index].dns_resource_record[0].name + type = google_certificate_manager_dns_authorization.dex_auth[count.index].dns_resource_record[0].type + ttl = 60 + + managed_zone = var.dns_zone_name + rrdatas = [google_certificate_manager_dns_authorization.dex_auth[count.index].dns_resource_record[0].data] +} + +resource "google_compute_global_address" "gce_lb_ipv4" { + name = var.lb_address_name == "" ? format("oauth2-%s-gce-ext-lb", var.cluster_name) : var.lb_address_name + address_type = "EXTERNAL" + project = var.project_id +} + +resource "google_compute_security_policy" "http_security_policy" { + count = var.enable_cloud_armor ? 1 : 0 + + name = var.cloud_armor_policy_name + project = var.project_id + type = "CLOUD_ARMOR" + + dynamic "rule" { + for_each = var.cloud_armor_rules + content { + action = rule.value.action + priority = rule.value.priority + match { + versioned_expr = rule.value.match.versioned_expr + dynamic "config" { + for_each = rule.value.match.config != null ? [rule.value.match.config] : [] + content { + src_ip_ranges = config.value.src_ip_ranges + } + } + dynamic "expr" { + for_each = rule.value.match.expr != null ? [rule.value.match.expr] : [] + content { + expression = expr.value.expression + } + } + } + + dynamic "rate_limit_options" { + for_each = rule.value.rate_limit_options != null ? [rule.value.rate_limit_options] : [] + content { + enforce_on_key = rate_limit_options.value.enforce_on_key + conform_action = rate_limit_options.value.conform_action + exceed_action = rate_limit_options.value.exceed_action + rate_limit_threshold { + count = rate_limit_options.value.qpm_rate_limit + interval_sec = rate_limit_options.value.interval_sec + } + } + } + + dynamic "redirect_options" { + for_each = rule.value.redirect_options != null ? [rule.value.redirect_options] : [] + content { + type = redirect_options.value.type + target = redirect_options.value.target + } + } + + description = rule.value.description + } + + } + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + advanced_options_config { + json_parsing = "STANDARD" + } + + adaptive_protection_config { + layer_7_ddos_defense_config { + enable = var.enable_adaptive_protection + } + } +} + +resource "google_compute_ssl_policy" "ssl_policy" { + count = var.enable_ssl_policy ? 1 : 0 + + name = var.ssl_policy_name + project = var.project_id + + profile = "MODERN" + min_tls_version = "TLS_1_2" +} + +####################### PER-SERVICE MULTIREGION ######################## +# One per service globally, only if using a multi-region configuration # +# rather than K8s-Ingress-driven load balancing. # +######################################################################## + +resource "google_compute_health_check" "http_health_check" { + count = var.single_region ? 0 : 1 + + name = "dex-http-health-check" + project = var.project_id + + timeout_sec = 5 + check_interval_sec = 5 + healthy_threshold = 2 + unhealthy_threshold = 2 + + http_health_check { + request_path = "/auth/healthz" + port_specification = "USE_SERVING_PORT" + } + + log_config { + enable = var.enable_healthcheck_logging + } +} + +data "google_compute_network_endpoint_group" "k8s_http_neg" { + for_each = toset(var.network_endpoint_group_zones) + + name = var.network_endpoint_group_name + project = var.project_id + zone = each.key +} + +resource "google_compute_backend_service" "http_backend_service" { + count = var.single_region ? 0 : 1 + + name = "dex-http-backend" + project = var.project_id + + load_balancing_scheme = "EXTERNAL_MANAGED" + port_name = "http" + protocol = "HTTP" + + connection_draining_timeout_sec = 15 + health_checks = [google_compute_health_check.http_health_check[count.index].id] + + dynamic "backend" { + for_each = data.google_compute_network_endpoint_group.k8s_http_neg + iterator = neg + content { + group = neg.value.id + balancing_mode = "RATE" + max_rate_per_endpoint = var.backend_service_max_rps + } + } + + depends_on = [google_compute_security_policy.http_security_policy] + security_policy = length(google_compute_security_policy.http_security_policy) > 0 ? google_compute_security_policy.http_security_policy[0].self_link : "" + + log_config { + enable = var.enable_backend_service_logging + } +} + +resource "google_compute_backend_bucket" "jwks_backend_bucket" { + count = var.single_region ? 0 : 1 + + name = "dex-jwks-backend-bucket" + description = "Serves public/keys.json to Fulcio" + bucket_name = google_storage_bucket.auth_bucket.name + enable_cdn = true +} + +resource "google_compute_url_map" "url_map" { + count = var.single_region ? 0 : 1 + + name = "dex-lb" + project = var.project_id + + default_service = google_compute_backend_service.http_backend_service[count.index].id + + host_rule { + hosts = [local.hostname] + path_matcher = "dex-path-matcher" + } + + path_matcher { + name = "dex-path-matcher" + // By default, route to the Dex NEGs + default_service = google_compute_backend_service.http_backend_service[count.index].id + + // For Fulcio requesting keys, route to the bucket containing the merged keys from each Dex instance + path_rule { + paths = ["/auth/keys", "/auth/keys/"] + service = google_compute_backend_bucket.jwks_backend_bucket[count.index].id + + route_action { + url_rewrite { + path_prefix_rewrite = "/public/keys.json" + } + } + } + } +} + +resource "google_certificate_manager_certificate" "ssl_certificate" { + count = var.single_region ? 0 : 1 + + name = "dex-ssl-cert" + project = var.project_id + + managed { + domains = [local.hostname] + dns_authorizations = [ + google_certificate_manager_dns_authorization.dex_auth[count.index].id + ] + } +} + +resource "google_certificate_manager_certificate_map" "dex_certificate_map" { + count = var.single_region ? 0 : 1 + + name = "dex-cert-map" +} + +resource "google_certificate_manager_certificate_map_entry" "dex_certificate_map_entry" { + count = var.single_region ? 0 : 1 + + name = "dex-cert-map-entry" + map = google_certificate_manager_certificate_map.dex_certificate_map[count.index].name + certificates = [google_certificate_manager_certificate.ssl_certificate[count.index].id] + hostname = local.hostname +} + +resource "google_compute_target_https_proxy" "lb_proxy" { + count = var.single_region ? 0 : 1 + + name = "dex-https-proxy" + project = var.project_id + + url_map = google_compute_url_map.url_map[count.index].id + + ssl_policy = google_compute_ssl_policy.ssl_policy[count.index].id + certificate_map = "//certificatemanager.googleapis.com/${google_certificate_manager_certificate_map.dex_certificate_map[count.index].id}" +} + +resource "google_compute_global_forwarding_rule" "https_forwarding_rule" { + count = var.single_region ? 0 : 1 + + name = "dex-https-forwarding-rule" + project = var.project_id + + ip_address = google_compute_global_address.gce_lb_ipv4.address + target = google_compute_target_https_proxy.lb_proxy[count.index].id + port_range = "443" + load_balancing_scheme = "EXTERNAL_MANAGED" + ip_protocol = "TCP" +} diff --git a/gcp/modules/dex/global/outputs.tf b/gcp/modules/dex/global/outputs.tf new file mode 100644 index 00000000..3016089e --- /dev/null +++ b/gcp/modules/dex/global/outputs.tf @@ -0,0 +1,20 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "auth_bucket_name" { + description = "The name of the globally shared Dex keys bucket." + value = google_storage_bucket.auth_bucket.name +} diff --git a/gcp/modules/dex/global/variables.tf b/gcp/modules/dex/global/variables.tf new file mode 100644 index 00000000..f14f1718 --- /dev/null +++ b/gcp/modules/dex/global/variables.tf @@ -0,0 +1,158 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + default = "" + validation { + condition = length(var.project_id) > 0 + error_message = "Must specify project_id variable." + } +} + +variable "project_number" { + description = "The GCP project number." + type = string + default = "" +} + +variable "single_region" { + description = "Whether this module instance is only deployed in one region, and therefore in charge of managing its own IP address and DNS record but not other load balancer resources." + type = bool + default = false +} + +variable "lb_address_name" { + description = "Name of the global address of the load balancer. If not specified, defaults to 'oauth2-CLUSTER_NAME-gce-ext-lb'." + type = string + default = "" +} + +variable "cluster_name" { + type = string + default = "" +} + +variable "dns_zone_name" { + description = "Name of DNS Zone object in Google Cloud DNS" + type = string +} + +variable "dns_domain_name" { + description = "Name of DNS domain name in Google Cloud DNS" + type = string +} + +variable "manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + +variable "enable_cloud_armor" { + description = "Whether to create a Cloud Armor security policy." + type = bool + default = false +} + +variable "cloud_armor_policy_name" { + description = "Name of the Cloud Armor policy." + type = string + default = "dex-service-security-policy" +} + +variable "cloud_armor_rules" { + description = "Cloud Armor security policy rules." + type = list(object({ + action = string + priority = number + description = optional(string) + + match = object({ + versioned_expr = optional(string) + + config = optional(object({ + src_ip_ranges = list(string) + })) + + expr = optional(object({ + expression = string + })) + }) + + rate_limit_options = optional(object({ + enforce_on_key = string + conform_action = string + exceed_action = string + qpm_rate_limit = number + interval_sec = number + })) + + redirect_options = optional(object({ + type = string + target = string + })) + })) + default = [] +} + +variable "enable_adaptive_protection" { + description = "Whether to enable layer 7 DDoS adaptive protection in Cloud Armor." + type = bool + default = true +} + +variable "enable_ssl_policy" { + description = "Whether to create a SSL policy." + type = bool + default = false +} + +variable "ssl_policy_name" { + description = "Name of the SSL policy." + type = string + default = "dex-ingress-ssl-policy" +} + +variable "enable_healthcheck_logging" { + description = "Whether to enable logging for the HTTP health check" + type = bool + default = true +} + +variable "network_endpoint_group_zones" { + type = list(string) + description = "zones where the NEGs live. NEGs will not exist until the Kubernetes service they belong to exists and creates them. This value must be set to empty if NEGs are not expected to exist yet, and then can later be updated." + default = [] +} + +variable "network_endpoint_group_name" { + description = "Name of the NEG that will be created for the HTTP service by the Dex Kubernetes service." + type = string + default = "" +} + +variable "backend_service_max_rps" { + description = "Max requests per second that a single backend instance can handle." + type = number + default = 100 +} + +variable "enable_backend_service_logging" { + description = "Whether to enable logging for the HTTP backend service." + type = bool + default = true +} diff --git a/gcp/modules/dex/global/versions.tf b/gcp/modules/dex/global/versions.tf new file mode 100644 index 00000000..371c3133 --- /dev/null +++ b/gcp/modules/dex/global/versions.tf @@ -0,0 +1,26 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_version = "1.14.2" + + required_providers { + google = { + version = "7.21.0" + source = "hashicorp/google" + } + } +} diff --git a/gcp/modules/dex/main.tf b/gcp/modules/dex/main.tf index 0d85007d..09f9f195 100644 --- a/gcp/modules/dex/main.tf +++ b/gcp/modules/dex/main.tf @@ -17,7 +17,9 @@ // Enable required services for this module resource "google_project_service" "service" { for_each = toset([ - "dns.googleapis.com", // For configuring DNS records + "dns.googleapis.com", // For configuring DNS records + "cloudfunctions.googleapis.com", // For aggregating keys across regions + "eventarc.googleapis.com", // For aggregating keys across regions ]) project = var.project_id service = each.key diff --git a/gcp/modules/dex/network.tf b/gcp/modules/dex/network.tf index 6ffca829..de63508b 100644 --- a/gcp/modules/dex/network.tf +++ b/gcp/modules/dex/network.tf @@ -14,107 +14,39 @@ * limitations under the License. */ -resource "google_dns_record_set" "A_dex" { - count = var.dns_domain_name == "" ? 0 : 1 - name = "oauth2.${var.dns_domain_name}" - type = "A" - ttl = 60 +module "global" { + count = var.single_region ? 1 : 0 - project = var.project_id - managed_zone = var.dns_zone_name + source = "./global" - rrdatas = [google_compute_global_address.gce_lb_ipv4.address] -} - -// Create a static global IP for the external IPV4 GCE L7 load balancer -resource "google_compute_global_address" "gce_lb_ipv4" { - name = format("oauth2-%s-gce-ext-lb", var.cluster_name) - address_type = "EXTERNAL" - project = var.project_id -} - -resource "google_compute_security_policy" "http_security_policy" { - count = var.enable_cloud_armor ? 1 : 0 - - name = "dex-service-security-policy" - project = var.project_id - type = "CLOUD_ARMOR" - - dynamic "rule" { - for_each = var.cloud_armor_rules - content { - action = rule.value.action - priority = rule.value.priority - match { - versioned_expr = rule.value.match.versioned_expr - dynamic "config" { - for_each = rule.value.match.config != null ? [rule.value.match.config] : [] - content { - src_ip_ranges = config.value.src_ip_ranges - } - } - dynamic "expr" { - for_each = rule.value.match.expr != null ? [rule.value.match.expr] : [] - content { - expression = expr.value.expression - } - } - } - - dynamic "rate_limit_options" { - for_each = rule.value.rate_limit_options != null ? [rule.value.rate_limit_options] : [] - content { - enforce_on_key = rate_limit_options.value.enforce_on_key - conform_action = rate_limit_options.value.conform_action - exceed_action = rate_limit_options.value.exceed_action - rate_limit_threshold { - count = rate_limit_options.value.qpm_rate_limit - interval_sec = rate_limit_options.value.interval_sec - } - } - } + project_id = var.project_id - dynamic "redirect_options" { - for_each = rule.value.redirect_options != null ? [rule.value.redirect_options] : [] - content { - type = redirect_options.value.type - target = redirect_options.value.target - } - } + single_region = true + manage_dns_a_record = var.manage_dns_a_record - description = rule.value.description - } + dns_zone_name = var.dns_zone_name + dns_domain_name = var.dns_domain_name - } + cluster_name = var.cluster_name - rule { - action = "allow" - priority = "2147483647" - match { - versioned_expr = "SRC_IPS_V1" - config { - src_ip_ranges = ["*"] - } - } - description = "default rule" - } - - advanced_options_config { - json_parsing = "STANDARD" - } - - adaptive_protection_config { - layer_7_ddos_defense_config { - enable = var.enable_adaptive_protection - } - } + enable_cloud_armor = var.enable_cloud_armor + cloud_armor_rules = var.cloud_armor_rules + enable_adaptive_protection = var.enable_adaptive_protection + enable_ssl_policy = var.enable_ssl_policy } - -resource "google_compute_ssl_policy" "ssl_policy" { - count = var.enable_ssl_policy ? 1 : 0 - name = "dex-ingress-ssl-policy" - project = var.project_id - - profile = "MODERN" - min_tls_version = "TLS_1_2" +moved { + from = google_dns_record_set.A_dex + to = module.global[0].google_dns_record_set.A_dex +} +moved { + from = google_compute_global_address.gce_lb_ipv4 + to = module.global[0].google_compute_global_address.gce_lb_ipv4 +} +moved { + from = google_compute_security_policy.http_security_policy + to = module.global[0].google_compute_security_policy.http_security_policy +} +moved { + from = google_compute_ssl_policy.ssl_policy + to = module.global[0].google_compute_ssl_policy.ssl_policy } diff --git a/gcp/modules/dex/src/jwks-merger/go.mod b/gcp/modules/dex/src/jwks-merger/go.mod new file mode 100644 index 00000000..02ae74d7 --- /dev/null +++ b/gcp/modules/dex/src/jwks-merger/go.mod @@ -0,0 +1,47 @@ +module github.com/sigstore/terraform-modules/jwksmerger + +go 1.25 + +require ( + cloud.google.com/go/storage v1.40.0 + github.com/cloudevents/sdk-go/v2 v2.15.2 + google.golang.org/api v0.170.0 +) + +require ( + cloud.google.com/go v0.112.1 // indirect + cloud.google.com/go/compute v1.24.0 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/iam v1.1.7 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.3 // indirect + github.com/json-iterator/go v1.1.10 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/oauth2 v0.18.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240314234333-6e1732d8331c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/gcp/modules/dex/src/jwks-merger/main.go b/gcp/modules/dex/src/jwks-merger/main.go new file mode 100644 index 00000000..7de59f22 --- /dev/null +++ b/gcp/modules/dex/src/jwks-merger/main.go @@ -0,0 +1,102 @@ +package jwksmerger + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "cloud.google.com/go/storage" + "github.com/cloudevents/sdk-go/v2/event" + "google.golang.org/api/iterator" +) + +type StorageObjectData struct { + Bucket string `json:"bucket"` + Name string `json:"name"` +} + +type JWKS struct { + Keys []json.RawMessage `json:"keys"` +} + +func MergeKeys(ctx context.Context, e event.Event) error { + var data StorageObjectData + if err := e.DataAs(&data); err != nil { + return fmt.Errorf("failed to parse event: %v", err) + } + + // 1. Prevent infinite loops + if !strings.HasPrefix(data.Name, "keys/") { + log.Printf("Ignoring file outside keys/ directory: %s", data.Name) + return nil + } + + client, err := storage.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to create storage client: %v", err) + } + defer client.Close() + + bucket := client.Bucket(data.Bucket) + var mergedKeys []json.RawMessage + + // 2. Dynamically list all files in the "keys/" directory + query := &storage.Query{Prefix: "keys/"} + it := bucket.Objects(ctx, query) + + for { + attrs, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return fmt.Errorf("error listing bucket objects: %v", err) + } + + // Skip the directory itself if GCS reports it as a 0-byte object + if attrs.Name == "keys/" { + continue + } + + // 3. Read and merge each discovered file + reader, err := bucket.Object(attrs.Name).NewReader(ctx) + if err != nil { + log.Printf("Warning: Could not read %s: %v", attrs.Name, err) + continue + } + + var jwks JWKS + if err := json.NewDecoder(reader).Decode(&jwks); err != nil { + log.Printf("Error decoding JSON from %s: %v", attrs.Name, err) + reader.Close() + continue + } + reader.Close() + + mergedKeys = append(mergedKeys, jwks.Keys...) + log.Printf("Successfully merged keys from %s", attrs.Name) + } + + // 4. Write the final result + finalJWKS := JWKS{Keys: mergedKeys} + finalJSON, err := json.Marshal(finalJWKS) + if err != nil { + return fmt.Errorf("failed to marshal merged keys: %v", err) + } + + writer := bucket.Object("public/keys.json").NewWriter(ctx) + writer.ContentType = "application/json" + writer.CacheControl = "public, max-age=60" + + if _, err := writer.Write(finalJSON); err != nil { + return fmt.Errorf("failed to write final json: %v", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("failed to close writer: %v", err) + } + + log.Printf("Successfully published merged JWKS with %d total keys.", len(mergedKeys)) + return nil +} diff --git a/gcp/modules/dex/variables.tf b/gcp/modules/dex/variables.tf index 5d9af198..d484c867 100644 --- a/gcp/modules/dex/variables.tf +++ b/gcp/modules/dex/variables.tf @@ -23,6 +23,23 @@ variable "project_id" { } } +variable "project_number" { + description = "The GCP Project Number" + type = string + default = "" +} + +variable "region" { + type = string + description = "GCP region" +} + +variable "single_region" { + description = "Whether this module instance is only deployed in one region, and therefore in charge of managing its own IP address and DNS record but not other load balancer resources." + type = bool + default = true +} + variable "dns_zone_name" { description = "Name of DNS Zone object in Google Cloud DNS" type = string @@ -33,11 +50,29 @@ variable "dns_domain_name" { type = string } +variable "manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + variable "cluster_name" { type = string default = "" } +variable "cluster_namespace" { + description = "Kubernetes namespace of the Dex deployment." + type = string + default = "default" +} + +variable "cluster_service_account" { + description = "Kubernetes service account name for the Dex deployment." + type = string + default = "default" +} + // Network variable "enable_cloud_armor" { description = "Whether to create a Cloud Armor security policy." @@ -91,3 +126,9 @@ variable "enable_ssl_policy" { type = bool default = false } + +variable "bucket_name" { + description = "The name of the global bucket to attach IAM and Functions to." + type = string + default = "" +} diff --git a/gcp/modules/fulcio/global/network.tf b/gcp/modules/fulcio/global/network.tf new file mode 100644 index 00000000..02287330 --- /dev/null +++ b/gcp/modules/fulcio/global/network.tf @@ -0,0 +1,358 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +########################### PER-SERVICE ############################ +# One per service globally, whether single region or multi-region. # +#################################################################### + +locals { + hostname = trimsuffix("fulcio.${var.dns_domain_name}", ".") +} + +resource "google_dns_record_set" "A_fulcio" { + count = var.manage_dns_a_record ? 1 : 0 + + name = "fulcio.${var.dns_domain_name}" + type = "A" + ttl = 60 + + project = var.project_id + + managed_zone = var.dns_zone_name + rrdatas = [google_compute_global_address.gce_lb_ipv4.address] +} + +resource "google_certificate_manager_dns_authorization" "fulcio_auth" { + count = var.single_region ? 0 : 1 + + name = "fulcio-dns-auth" + domain = local.hostname +} + +resource "google_dns_record_set" "CNAME_auth_fulcio" { + count = var.single_region ? 0 : 1 + + project = var.project_id + + name = google_certificate_manager_dns_authorization.fulcio_auth[count.index].dns_resource_record[0].name + type = google_certificate_manager_dns_authorization.fulcio_auth[count.index].dns_resource_record[0].type + ttl = 60 + + managed_zone = var.dns_zone_name + rrdatas = [google_certificate_manager_dns_authorization.fulcio_auth[count.index].dns_resource_record[0].data] +} + +resource "google_compute_global_address" "gce_lb_ipv4" { + name = var.lb_address_name == "" ? format("fulcio-%s-gce-ext-lb", var.cluster_name) : var.lb_address_name + address_type = "EXTERNAL" + project = var.project_id +} + +resource "google_compute_security_policy" "http_security_policy" { + count = var.enable_cloud_armor ? 1 : 0 + + name = var.cloud_armor_policy_name + project = var.project_id + type = "CLOUD_ARMOR" + + dynamic "rule" { + for_each = var.cloud_armor_rules + content { + action = rule.value.action + priority = rule.value.priority + match { + versioned_expr = rule.value.match.versioned_expr + dynamic "config" { + for_each = rule.value.match.config != null ? [rule.value.match.config] : [] + content { + src_ip_ranges = config.value.src_ip_ranges + } + } + dynamic "expr" { + for_each = rule.value.match.expr != null ? [rule.value.match.expr] : [] + content { + expression = expr.value.expression + } + } + } + + dynamic "rate_limit_options" { + for_each = rule.value.rate_limit_options != null ? [rule.value.rate_limit_options] : [] + content { + enforce_on_key = rate_limit_options.value.enforce_on_key + conform_action = rate_limit_options.value.conform_action + exceed_action = rate_limit_options.value.exceed_action + rate_limit_threshold { + count = rate_limit_options.value.qpm_rate_limit + interval_sec = rate_limit_options.value.interval_sec + } + } + } + + dynamic "redirect_options" { + for_each = rule.value.redirect_options != null ? [rule.value.redirect_options] : [] + content { + type = redirect_options.value.type + target = redirect_options.value.target + } + } + + description = rule.value.description + } + + } + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + advanced_options_config { + json_parsing = "STANDARD" + } + + adaptive_protection_config { + layer_7_ddos_defense_config { + enable = var.enable_adaptive_protection + } + } +} + +resource "google_compute_ssl_policy" "ssl_policy" { + count = var.enable_ssl_policy ? 1 : 0 + + name = var.ssl_policy_name + project = var.project_id + + profile = "MODERN" + min_tls_version = "TLS_1_2" +} + +####################### PER-SERVICE MULTIREGION ######################## +# One per service globally, only if using a multi-region configuration # +# rather than K8s-Ingress-driven load balancing. # +######################################################################## + +resource "google_compute_health_check" "http_health_check" { + count = var.single_region ? 0 : 1 + + name = "fulcio-http-health-check" + project = var.project_id + + timeout_sec = 5 + check_interval_sec = 5 + healthy_threshold = 2 + unhealthy_threshold = 2 + + http_health_check { + request_path = "/healthz" + port_specification = "USE_SERVING_PORT" + } + + log_config { + enable = var.enable_healthcheck_logging + } +} + +resource "google_compute_health_check" "grpc_health_check" { + count = var.single_region ? 0 : 1 + + name = "fulcio-grpc-health-check" + project = var.project_id + + timeout_sec = 5 + check_interval_sec = 5 + healthy_threshold = 2 + unhealthy_threshold = 2 + + tcp_health_check { + port_specification = "USE_SERVING_PORT" + } + + log_config { + enable = var.enable_healthcheck_logging + } +} + +data "google_compute_network_endpoint_group" "k8s_http_neg" { + for_each = toset(var.network_endpoint_group_zones) + + name = var.network_endpoint_group_name + project = var.project_id + zone = each.key +} + +data "google_compute_network_endpoint_group" "k8s_grpc_neg" { + for_each = toset(var.network_endpoint_group_zones) + + name = var.network_endpoint_group_name_grpc + project = var.project_id + zone = each.key +} + +resource "google_compute_backend_service" "http_backend_service" { + count = var.single_region ? 0 : 1 + + name = "fulcio-http-backend" + project = var.project_id + + load_balancing_scheme = "EXTERNAL_MANAGED" + port_name = "http" + protocol = "HTTP" + + connection_draining_timeout_sec = 15 + health_checks = [google_compute_health_check.http_health_check[count.index].id] + + dynamic "backend" { + for_each = data.google_compute_network_endpoint_group.k8s_http_neg + iterator = neg + content { + group = neg.value.id + balancing_mode = "RATE" + max_rate_per_endpoint = var.backend_service_max_rps + } + } + + depends_on = [google_compute_security_policy.http_security_policy] + security_policy = length(google_compute_security_policy.http_security_policy) > 0 ? google_compute_security_policy.http_security_policy[0].self_link : "" + + log_config { + enable = var.enable_backend_service_logging + } +} + +resource "google_compute_backend_service" "grpc_backend_service" { + count = var.single_region ? 0 : 1 + + name = "fulcio-grpc-backend" + project = var.project_id + + load_balancing_scheme = "EXTERNAL_MANAGED" + port_name = "grpc" + protocol = "HTTP2" + + connection_draining_timeout_sec = 15 + health_checks = [google_compute_health_check.grpc_health_check[count.index].id] + + dynamic "backend" { + for_each = data.google_compute_network_endpoint_group.k8s_grpc_neg + iterator = neg + content { + group = neg.value.id + balancing_mode = "RATE" + max_rate_per_endpoint = var.backend_service_max_rps + } + } + + depends_on = [google_compute_security_policy.http_security_policy] + security_policy = length(google_compute_security_policy.http_security_policy) > 0 ? google_compute_security_policy.http_security_policy[0].self_link : "" + + log_config { + enable = var.enable_backend_service_logging + } +} + +resource "google_compute_url_map" "url_map" { + count = var.single_region ? 0 : 1 + + name = "fulcio-lb" + project = var.project_id + + default_service = google_compute_backend_service.http_backend_service[count.index].id + + host_rule { + hosts = var.dns_domain_name == "" ? ["*"] : [local.hostname] + path_matcher = "fulcio" + } + + path_matcher { + name = "fulcio" + default_service = google_compute_backend_service.http_backend_service[count.index].id + path_rule { + paths = ["/*"] + service = google_compute_backend_service.http_backend_service[count.index].id + } + path_rule { + paths = ["/dev.sigstore.fulcio.v2.CA"] + service = google_compute_backend_service.grpc_backend_service[count.index].id + } + path_rule { + paths = ["/dev.sigstore.fulcio.v2.CA/*"] + service = google_compute_backend_service.grpc_backend_service[count.index].id + } + } +} + +resource "google_certificate_manager_certificate" "ssl_certificate" { + count = var.single_region ? 0 : 1 + + name = "fulcio-ssl-cert" + project = var.project_id + + managed { + domains = [local.hostname] + dns_authorizations = [ + google_certificate_manager_dns_authorization.fulcio_auth[count.index].id + ] + } +} + +resource "google_certificate_manager_certificate_map" "fulcio_certificate_map" { + count = var.single_region ? 0 : 1 + + name = "fulcio-cert-map" +} + +resource "google_certificate_manager_certificate_map_entry" "fulcio_certificate_map_entry" { + count = var.single_region ? 0 : 1 + + name = "fulcio-cert-map-entry" + map = google_certificate_manager_certificate_map.fulcio_certificate_map[count.index].name + certificates = [google_certificate_manager_certificate.ssl_certificate[count.index].id] + hostname = local.hostname +} + +resource "google_compute_target_https_proxy" "lb_proxy" { + count = var.single_region ? 0 : 1 + + name = "fulcio-https-proxy" + project = var.project_id + + url_map = google_compute_url_map.url_map[count.index].id + + ssl_policy = google_compute_ssl_policy.ssl_policy[count.index].id + certificate_map = "//certificatemanager.googleapis.com/${google_certificate_manager_certificate_map.fulcio_certificate_map[count.index].id}" +} + +resource "google_compute_global_forwarding_rule" "https_forwarding_rule" { + count = var.single_region ? 0 : 1 + + name = "fulcio-https-forwarding-rule" + project = var.project_id + + ip_address = google_compute_global_address.gce_lb_ipv4.address + target = google_compute_target_https_proxy.lb_proxy[count.index].id + port_range = "443" + load_balancing_scheme = "EXTERNAL_MANAGED" + ip_protocol = "TCP" +} diff --git a/gcp/modules/fulcio/global/variables.tf b/gcp/modules/fulcio/global/variables.tf new file mode 100644 index 00000000..6eae45c8 --- /dev/null +++ b/gcp/modules/fulcio/global/variables.tf @@ -0,0 +1,171 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + default = "" + validation { + condition = length(var.project_id) > 0 + error_message = "Must specify project_id variable." + } +} + +variable "single_region" { + description = "Whether this module instance is only deployed in one region, and therefore in charge of managing its own IP address and DNS record but not other load balancer resources." + type = bool + default = false +} + +variable "lb_address_name" { + description = "Name of the global address of the load balancer. If not specified, defaults to 'fulcio-CLUSTER_NAME-gce-ext-lb'." + type = string + default = "" +} + +variable "cluster_name" { + description = "The name to give the new Kubernetes cluster." + type = string + default = "" +} + +variable "dns_zone_name" { + description = "Name of DNS Zone object in Google Cloud DNS" + type = string +} + +variable "dns_domain_name" { + description = "Name of DNS domain name in Google Cloud DNS" + type = string +} + +variable "manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + +variable "enable_cloud_armor" { + description = "Whether to create a Cloud Armor security policy." + type = bool + default = false +} + +variable "cloud_armor_policy_name" { + description = "Name of the Cloud Armor policy." + type = string + default = "fulcio-service-security-policy" +} + +variable "cloud_armor_rules" { + description = "Cloud Armor security policy rules." + type = list(object({ + action = string + priority = number + description = optional(string) + + match = object({ + versioned_expr = optional(string) + + config = optional(object({ + src_ip_ranges = list(string) + })) + + expr = optional(object({ + expression = string + })) + }) + + rate_limit_options = optional(object({ + enforce_on_key = string + conform_action = string + exceed_action = string + qpm_rate_limit = number + interval_sec = number + })) + + redirect_options = optional(object({ + type = string + target = string + })) + })) + default = [] +} + +variable "enable_adaptive_protection" { + description = "Whether to enable layer 7 DDoS adaptive protection in Cloud Armor." + type = bool + default = true +} + +variable "enable_ssl_policy" { + description = "Whether to create a SSL policy." + type = bool + default = false +} + +variable "ssl_policy_name" { + description = "Name of the SSL policy." + type = string + default = "fulcio-ingress-ssl-policy" +} + +variable "http_service_port" { + description = "The internal HTTP port for the service pod" + type = string + default = "5555" +} + +variable "grpc_service_port" { + description = "The internal HTTP port for the service pod" + type = string + default = "5554" +} + +variable "enable_healthcheck_logging" { + description = "Whether to enable logging for the HTTP health check" + type = bool + default = true +} + +variable "network_endpoint_group_zones" { + type = list(string) + description = "zones where the NEGs live. NEGs will not exist until the Kubernetes service they belong to exists and creates them. This value must be set to empty if NEGs are not expected to exist yet, and then can later be updated." + default = [] +} + +variable "network_endpoint_group_name" { + description = "Name of the NEG that will be created for the HTTP service by the Fulcio Kubernetes service." + type = string + default = "" +} + +variable "network_endpoint_group_name_grpc" { + description = "Name of the NEG that will be created for the gRPC service by the Fulcio Kubernetes service." + type = string + default = "" +} + +variable "backend_service_max_rps" { + description = "Max requests per second that a single backend instance can handle." + type = number + default = 100 +} + +variable "enable_backend_service_logging" { + description = "Whether to enable logging for the HTTP backend service." + type = bool + default = true +} diff --git a/gcp/modules/fulcio/global/versions.tf b/gcp/modules/fulcio/global/versions.tf new file mode 100644 index 00000000..371c3133 --- /dev/null +++ b/gcp/modules/fulcio/global/versions.tf @@ -0,0 +1,26 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_version = "1.14.2" + + required_providers { + google = { + version = "7.21.0" + source = "hashicorp/google" + } + } +} diff --git a/gcp/modules/fulcio/network.tf b/gcp/modules/fulcio/network.tf index a68ef61f..080f9bf7 100644 --- a/gcp/modules/fulcio/network.tf +++ b/gcp/modules/fulcio/network.tf @@ -14,107 +14,39 @@ * limitations under the License. */ -resource "google_dns_record_set" "A_fulcio" { - count = var.dns_domain_name == "" ? 0 : 1 - name = "fulcio.${var.dns_domain_name}" - type = "A" - ttl = 60 +module "global" { + count = var.single_region ? 1 : 0 - project = var.project_id - managed_zone = var.dns_zone_name + source = "./global" - rrdatas = [google_compute_global_address.gce_lb_ipv4.address] -} - -// Create a static global IP for the external IPV4 GCE L7 load balancer -resource "google_compute_global_address" "gce_lb_ipv4" { - name = format("fulcio-%s-gce-ext-lb", var.cluster_name) - address_type = "EXTERNAL" - project = var.project_id -} - -resource "google_compute_security_policy" "http_security_policy" { - count = var.enable_cloud_armor ? 1 : 0 - - name = "fulcio-service-security-policy" - project = var.project_id - type = "CLOUD_ARMOR" - - dynamic "rule" { - for_each = var.cloud_armor_rules - content { - action = rule.value.action - priority = rule.value.priority - match { - versioned_expr = rule.value.match.versioned_expr - dynamic "config" { - for_each = rule.value.match.config != null ? [rule.value.match.config] : [] - content { - src_ip_ranges = config.value.src_ip_ranges - } - } - dynamic "expr" { - for_each = rule.value.match.expr != null ? [rule.value.match.expr] : [] - content { - expression = expr.value.expression - } - } - } - - dynamic "rate_limit_options" { - for_each = rule.value.rate_limit_options != null ? [rule.value.rate_limit_options] : [] - content { - enforce_on_key = rate_limit_options.value.enforce_on_key - conform_action = rate_limit_options.value.conform_action - exceed_action = rate_limit_options.value.exceed_action - rate_limit_threshold { - count = rate_limit_options.value.qpm_rate_limit - interval_sec = rate_limit_options.value.interval_sec - } - } - } + project_id = var.project_id - dynamic "redirect_options" { - for_each = rule.value.redirect_options != null ? [rule.value.redirect_options] : [] - content { - type = redirect_options.value.type - target = redirect_options.value.target - } - } + single_region = true + manage_dns_a_record = var.manage_dns_a_record - description = rule.value.description - } + dns_zone_name = var.dns_zone_name + dns_domain_name = var.dns_domain_name - } + cluster_name = var.cluster_name - rule { - action = "allow" - priority = "2147483647" - match { - versioned_expr = "SRC_IPS_V1" - config { - src_ip_ranges = ["*"] - } - } - description = "default rule" - } - - advanced_options_config { - json_parsing = "STANDARD" - } - - adaptive_protection_config { - layer_7_ddos_defense_config { - enable = var.enable_adaptive_protection - } - } + enable_cloud_armor = var.enable_cloud_armor + cloud_armor_rules = var.cloud_armor_rules + enable_adaptive_protection = var.enable_adaptive_protection + enable_ssl_policy = var.enable_ssl_policy } - -resource "google_compute_ssl_policy" "ssl_policy" { - count = var.enable_ssl_policy ? 1 : 0 - name = "fulcio-ingress-ssl-policy" - project = var.project_id - - profile = "MODERN" - min_tls_version = "TLS_1_2" +moved { + from = google_dns_record_set.A_fulcio + to = module.global[0].google_dns_record_set.A_fulcio +} +moved { + from = google_compute_global_address.gce_lb_ipv4 + to = module.global[0].google_compute_global_address.gce_lb_ipv4 +} +moved { + from = google_compute_security_policy.http_security_policy + to = module.global[0].google_compute_security_policy.http_security_policy +} +moved { + from = google_compute_ssl_policy.ssl_policy + to = module.global[0].google_compute_ssl_policy.ssl_policy } diff --git a/gcp/modules/fulcio/variables.tf b/gcp/modules/fulcio/variables.tf index 6f922582..3147e5a9 100644 --- a/gcp/modules/fulcio/variables.tf +++ b/gcp/modules/fulcio/variables.tf @@ -28,6 +28,12 @@ variable "region" { description = "GCP region" } +variable "single_region" { + description = "Whether this module instance is only deployed in one region, and therefore in charge of managing its own IP address and DNS record but not other load balancer resources." + type = bool + default = true +} + variable "cluster_name" { description = "The name to give the new Kubernetes cluster." type = string @@ -92,6 +98,12 @@ variable "dns_domain_name" { type = string } +variable "manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + // Network variable "enable_cloud_armor" { description = "Whether to create a Cloud Armor security policy." diff --git a/gcp/modules/monitoring/global/dex.tf b/gcp/modules/monitoring/global/dex.tf new file mode 100644 index 00000000..6c78abd0 --- /dev/null +++ b/gcp/modules/monitoring/global/dex.tf @@ -0,0 +1,79 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +resource "google_monitoring_alert_policy" "function_errors" { + project = var.project_id + + display_name = "Dex JWKS Merger - Execution Errors" + combiner = "OR" + + conditions { + display_name = "Function execution failed" + + condition_threshold { + # Monitor the execution count of Gen 2 Cloud Functions + filter = "resource.type = \"cloud_run_revision\" AND resource.labels.service_name = \"dex-jwks-merger\" AND metric.type = \"run.googleapis.com/request_count\" AND metric.labels.response_code_class != \"2xx\"" + duration = "0s" # Trigger immediately on the first failure + comparison = "COMPARISON_GT" + + aggregations { + alignment_period = "60s" + per_series_aligner = "ALIGN_RATE" + } + + trigger { + count = 1 + } + } + } + + notification_channels = [local.notification_channels] + + documentation { + content = "The Dex JWKS Cloud Function is throwing errors. Check the Cloud Run logs for 'dex-jwks-merger' immediately to see the Go panic/error trace." + mime_type = "text/markdown" + } +} + +resource "google_monitoring_alert_policy" "pipeline_stagnation" { + project = var.project_id + + display_name = "Dex JWKS Merger - Pipeline Stalled" + combiner = "OR" + + conditions { + display_name = "No successful merges in 1 hour" + + condition_absent { + # Look for successful (2xx) executions of the function + filter = "resource.type = \"cloud_run_revision\" AND resource.labels.service_name = \"dex-jwks-merger\" AND metric.type = \"run.googleapis.com/request_count\" AND metric.labels.response_code_class = \"2xx\"" + + # If this metric is completely missing for 3600 seconds (1 hour), trigger the alert. + duration = "3600s" + + trigger { + count = 1 + } + } + } + + notification_channels = [local.notification_channels] + + documentation { + content = "CRITICAL: The Dex JWKS Cloud Function has not successfully merged keys in over an hour. \n\n1. Check the K8s CronJob logs in the 'dex-system' namespace. \n2. Check if the 'keys/' directory in GCS is receiving new files. \n3. Check the Cloud Function logs for silent failures. \n\nFulcio will eventually start rejecting tokens if this is not resolved." + mime_type = "text/markdown" + } +} diff --git a/gcp/modules/monitoring/global/variables.tf b/gcp/modules/monitoring/global/variables.tf new file mode 100644 index 00000000..8e52ed67 --- /dev/null +++ b/gcp/modules/monitoring/global/variables.tf @@ -0,0 +1,34 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + default = "" + validation { + condition = length(var.project_id) > 0 + error_message = "Must specify PROJECT_ID variable." + } +} + +variable "notification_channel_ids" { + type = list(string) + description = "List of notification channel IDs which alerts should be sent to. You can find this by running `gcloud alpha monitoring channels list`." +} + +locals { + notification_channels = toset([for nc in var.notification_channel_ids : format("projects/%v/notificationChannels/%v", var.project_id, nc)]) +} + diff --git a/gcp/modules/sigstore/sigstore.tf b/gcp/modules/sigstore/sigstore.tf index b808afb7..6f9f7ad4 100644 --- a/gcp/modules/sigstore/sigstore.tf +++ b/gcp/modules/sigstore/sigstore.tf @@ -307,6 +307,10 @@ module "fulcio" { enable_cloud_armor = var.fulcio_enable_cloud_armor enable_ssl_policy = var.fulcio_enable_ssl_policy + // Load balancing + single_region = var.single_region + manage_dns_a_record = var.fulcio_manage_dns_a_record + depends_on = [ module.gke-cluster, module.network, @@ -340,6 +344,10 @@ module "timestamp" { enable_cloud_armor = var.timestamp_enable_cloud_armor enable_ssl_policy = var.timestamp_enable_ssl_policy + // Load balancing + single_region = var.single_region + manage_dns_a_record = var.timestamp_manage_dns_a_record + depends_on = [ module.gke-cluster, module.network, @@ -520,7 +528,9 @@ module "standalone_mysqls" { module "dex" { source = "../dex" - project_id = var.project_id + project_id = var.project_id + project_number = var.project_number + region = var.region cluster_name = var.cluster_name @@ -534,6 +544,15 @@ module "dex" { enable_cloud_armor = var.dex_enable_cloud_armor enable_ssl_policy = var.dex_enable_ssl_policy + // Load balancing + single_region = var.single_region + manage_dns_a_record = var.dex_manage_dns_a_record + + // Bucket + bucket_name = var.dex_bucket_name + cluster_namespace = var.dex_cluster_namespace + cluster_service_account = var.dex_cluster_service_account + depends_on = [ module.gke-cluster, module.network, diff --git a/gcp/modules/sigstore/variables.tf b/gcp/modules/sigstore/variables.tf index 693f5410..f7edecc2 100644 --- a/gcp/modules/sigstore/variables.tf +++ b/gcp/modules/sigstore/variables.tf @@ -52,6 +52,16 @@ variable "dns_domain_name" { default = "" } +/********************************/ +/********* LOAD BALANCER ********/ +/********************************/ + +variable "single_region" { + description = "Whether this module instance is only deployed in one region, and therefore in charge of managing its own IP address and DNS record but not other load balancer resources." + type = bool + default = true +} + /********************************/ /************ BASTION ***********/ /********************************/ @@ -540,6 +550,12 @@ variable "fulcio_enable_ssl_policy" { default = false } +variable "fulcio_manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record for Fulcio. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + /********************************/ /*********** REKOR v1 ***********/ /********************************/ @@ -670,6 +686,12 @@ variable "timestamp_enable_ssl_policy" { default = false } +variable "timestamp_manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record for TSA. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + /********************************/ /************* CTLOG ************/ /********************************/ @@ -775,6 +797,30 @@ variable "dex_enable_ssl_policy" { default = false } +variable "dex_manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record for Dex. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + +variable "dex_bucket_name" { + description = "The name of the global bucket for Dex keys." + type = string + default = "" +} + +variable "dex_cluster_namespace" { + description = "The name of the Kubernetes namespace where Dex runs." + type = string + default = "" +} + +variable "dex_cluster_service_account" { + description = "The name of the Kubernetes service account used for Dex." + type = string + default = "" +} + /********************************/ /********** VALIDATION **********/ /********************************/ diff --git a/gcp/modules/sigstore_global/global.tf b/gcp/modules/sigstore_global/global.tf new file mode 100644 index 00000000..e7338ea6 --- /dev/null +++ b/gcp/modules/sigstore_global/global.tf @@ -0,0 +1,101 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "project_roles" { + source = "../project_roles" + project_id = var.project_id + iam_members_to_roles = var.iam_members_to_roles +} + +module "dex" { + source = "../dex/global" + + project_id = var.project_id + project_number = var.project_number + + lb_address_name = "dex-global-ext-lb" + + dns_zone_name = var.dns_zone_name + dns_domain_name = var.dns_domain_name + manage_dns_a_record = var.dex_manage_dns_a_record + + enable_cloud_armor = true + cloud_armor_policy_name = "dex-service-security-policy-global" + cloud_armor_rules = var.dex_cloud_armor_rules + enable_adaptive_protection = true + enable_ssl_policy = true + ssl_policy_name = "dex-ingress-ssl-policy-global" + + network_endpoint_group_zones = var.network_endpoint_group_zones + network_endpoint_group_name = var.dex_network_endpoint_group_name + backend_service_max_rps = var.dex_backend_service_max_rps + + enable_healthcheck_logging = var.enable_loadbalancer_logging + enable_backend_service_logging = var.enable_loadbalancer_logging +} + +module "timestamp" { + source = "../timestamp/global" + + project_id = var.project_id + + lb_address_name = "timestamp-global-ext-lb" + + dns_zone_name = var.dns_zone_name + dns_domain_name = var.dns_domain_name + manage_dns_a_record = var.timestamp_manage_dns_a_record + + enable_cloud_armor = true + cloud_armor_policy_name = "timestamp-service-security-policy-global" + cloud_armor_rules = var.timestamp_cloud_armor_rules + enable_adaptive_protection = true + enable_ssl_policy = true + ssl_policy_name = "timestamp-ingress-ssl-policy-global" + + network_endpoint_group_zones = var.network_endpoint_group_zones + network_endpoint_group_name = var.timestamp_network_endpoint_group_name + backend_service_max_rps = var.timestamp_backend_service_max_rps + + enable_healthcheck_logging = var.enable_loadbalancer_logging + enable_backend_service_logging = var.enable_loadbalancer_logging +} + +module "fulcio" { + source = "../fulcio/global" + + project_id = var.project_id + + lb_address_name = "fulcio-global-ext-lb" + + dns_zone_name = var.dns_zone_name + dns_domain_name = var.dns_domain_name + manage_dns_a_record = var.fulcio_manage_dns_a_record + + enable_cloud_armor = true + cloud_armor_policy_name = "fulcio-service-security-policy-global" + cloud_armor_rules = var.fulcio_cloud_armor_rules + enable_adaptive_protection = true + enable_ssl_policy = true + ssl_policy_name = "fulcio-ingress-ssl-policy-global" + + network_endpoint_group_zones = var.network_endpoint_group_zones + network_endpoint_group_name = var.fulcio_network_endpoint_group_name + network_endpoint_group_name_grpc = var.fulcio_network_endpoint_group_name_grpc + backend_service_max_rps = var.fulcio_backend_service_max_rps + + enable_healthcheck_logging = var.enable_loadbalancer_logging + enable_backend_service_logging = var.enable_loadbalancer_logging +} diff --git a/gcp/modules/sigstore_global/monitoring.tf b/gcp/modules/sigstore_global/monitoring.tf new file mode 100644 index 00000000..c4ecb9dc --- /dev/null +++ b/gcp/modules/sigstore_global/monitoring.tf @@ -0,0 +1,23 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "monitoring" { + source = "../monitoring/global" + + project_id = var.project_id + + notification_channel_ids = var.notification_channel_ids +} diff --git a/gcp/modules/sigstore_global/outputs.tf b/gcp/modules/sigstore_global/outputs.tf new file mode 100644 index 00000000..c79228e2 --- /dev/null +++ b/gcp/modules/sigstore_global/outputs.tf @@ -0,0 +1,20 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "auth_bucket_name" { + description = "The name of the globally shared Dex keys bucket." + value = module.dex.auth_bucket_name +} diff --git a/gcp/modules/sigstore_global/variables.tf b/gcp/modules/sigstore_global/variables.tf new file mode 100644 index 00000000..0c4deb79 --- /dev/null +++ b/gcp/modules/sigstore_global/variables.tf @@ -0,0 +1,230 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + default = "" + validation { + condition = length(var.project_id) > 0 + error_message = "Must specify project_id variable." + } +} + +variable "project_number" { + description = "The GCP project number." + type = string + default = "" +} + +variable "iam_members_to_roles" { + description = "Map of IAM member (e.g. group:foo@sigstore.dev) to a set of IAM roles (e.g. roles/viewer)" + type = map(set(string)) + default = {} +} + +variable "dns_zone_name" { + description = "Name of DNS Zone object in Google Cloud DNS" + type = string +} + +variable "dns_domain_name" { + description = "Name of DNS domain name in Google Cloud DNS" + type = string +} + +variable "enable_loadbalancer_logging" { + description = "Whether to enable logging for the HTTP health checks and backend services for Fulcio, TSA, Dex" + type = bool + default = true +} + +variable "network_endpoint_group_zones" { + type = list(string) + description = "zones where the NEGs live. NEGs will not exist until the Kubernetes service they belong to exists and creates them. This value must be set to empty if NEGs are not expected to exist yet, and then can later be updated." + default = [] +} + +variable "dex_network_endpoint_group_name" { + description = "Name of the NEG that will be created for the HTTP service by the Dex Kubernetes service." + type = string + default = "" +} + +variable "dex_backend_service_max_rps" { + description = "Max requests per second that a single Dex backend instance can handle." + type = number + default = 100 +} + +variable "dex_cloud_armor_rules" { + description = "Cloud Armor security policy rules for Dex." + type = list(object({ + action = string + priority = number + description = optional(string) + + match = object({ + versioned_expr = optional(string) + + config = optional(object({ + src_ip_ranges = list(string) + })) + + expr = optional(object({ + expression = string + })) + }) + + rate_limit_options = optional(object({ + enforce_on_key = string + conform_action = string + exceed_action = string + qpm_rate_limit = number + interval_sec = number + })) + + redirect_options = optional(object({ + type = string + target = string + })) + })) + default = [] +} + +variable "dex_manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record for Dex. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + +variable "timestamp_network_endpoint_group_name" { + description = "Name of the NEG that will be created for the HTTP service by the timestamp Kubernetes service." + type = string + default = "" +} + +variable "timestamp_backend_service_max_rps" { + description = "Max requests per second that a single TSA backend instance can handle." + type = number + default = 100 +} + +variable "timestamp_cloud_armor_rules" { + description = "Cloud Armor security policy rules for TSA." + type = list(object({ + action = string + priority = number + description = optional(string) + + match = object({ + versioned_expr = optional(string) + + config = optional(object({ + src_ip_ranges = list(string) + })) + + expr = optional(object({ + expression = string + })) + }) + + rate_limit_options = optional(object({ + enforce_on_key = string + conform_action = string + exceed_action = string + qpm_rate_limit = number + interval_sec = number + })) + + redirect_options = optional(object({ + type = string + target = string + })) + })) + default = [] +} + +variable "timestamp_manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record for TSA. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + +variable "fulcio_network_endpoint_group_name" { + description = "Name of the NEG that will be created for the HTTP service by the Fulcio Kubernetes service." + type = string + default = "" +} + +variable "fulcio_network_endpoint_group_name_grpc" { + description = "Name of the NEG that will be created for the gRPC service by the Fulcio Kubernetes service." + type = string + default = "" +} + +variable "fulcio_backend_service_max_rps" { + description = "Max requests per second that a single Fulcio backend instance can handle." + type = number + default = 100 +} + +variable "fulcio_cloud_armor_rules" { + description = "Cloud Armor security policy rules for Fulcio." + type = list(object({ + action = string + priority = number + description = optional(string) + + match = object({ + versioned_expr = optional(string) + + config = optional(object({ + src_ip_ranges = list(string) + })) + + expr = optional(object({ + expression = string + })) + }) + + rate_limit_options = optional(object({ + enforce_on_key = string + conform_action = string + exceed_action = string + qpm_rate_limit = number + interval_sec = number + })) + + redirect_options = optional(object({ + type = string + target = string + })) + })) + default = [] +} + +variable "fulcio_manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record for Fulcio. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + +variable "notification_channel_ids" { + description = "List of notification channel IDs which alerts should be sent to. You can find this by running `gcloud alpha monitoring channels list`." + type = list(string) + default = [] +} + diff --git a/gcp/modules/sigstore_global/versions.tf b/gcp/modules/sigstore_global/versions.tf new file mode 100644 index 00000000..371c3133 --- /dev/null +++ b/gcp/modules/sigstore_global/versions.tf @@ -0,0 +1,26 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_version = "1.14.2" + + required_providers { + google = { + version = "7.21.0" + source = "hashicorp/google" + } + } +} diff --git a/gcp/modules/timestamp/global/network.tf b/gcp/modules/timestamp/global/network.tf new file mode 100644 index 00000000..3807ee26 --- /dev/null +++ b/gcp/modules/timestamp/global/network.tf @@ -0,0 +1,281 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +########################### PER-SERVICE ############################ +# One per service globally, whether single region or multi-region. # +#################################################################### + +locals { + hostname = trimsuffix("timestamp.${var.dns_domain_name}", ".") +} + +resource "google_dns_record_set" "A_timestamp" { + count = var.manage_dns_a_record ? 1 : 0 + + name = "timestamp.${var.dns_domain_name}" + type = "A" + ttl = 60 + + project = var.project_id + + managed_zone = var.dns_zone_name + rrdatas = [google_compute_global_address.gce_lb_ipv4.address] +} + +resource "google_certificate_manager_dns_authorization" "timestamp_auth" { + count = var.single_region ? 0 : 1 + + name = "timestamp-dns-auth" + domain = local.hostname +} + +resource "google_dns_record_set" "CNAME_auth_timestamp" { + count = var.single_region ? 0 : 1 + + project = var.project_id + + name = google_certificate_manager_dns_authorization.timestamp_auth[count.index].dns_resource_record[0].name + type = google_certificate_manager_dns_authorization.timestamp_auth[count.index].dns_resource_record[0].type + ttl = 60 + + managed_zone = var.dns_zone_name + rrdatas = [google_certificate_manager_dns_authorization.timestamp_auth[count.index].dns_resource_record[0].data] +} + +resource "google_compute_global_address" "gce_lb_ipv4" { + name = var.lb_address_name == "" ? format("timestamp-%s-gce-ext-lb", var.cluster_name) : var.lb_address_name + address_type = "EXTERNAL" + project = var.project_id +} + +resource "google_compute_security_policy" "http_security_policy" { + count = var.enable_cloud_armor ? 1 : 0 + + name = var.cloud_armor_policy_name + project = var.project_id + type = "CLOUD_ARMOR" + + dynamic "rule" { + for_each = var.cloud_armor_rules + content { + action = rule.value.action + priority = rule.value.priority + match { + versioned_expr = rule.value.match.versioned_expr + dynamic "config" { + for_each = rule.value.match.config != null ? [rule.value.match.config] : [] + content { + src_ip_ranges = config.value.src_ip_ranges + } + } + dynamic "expr" { + for_each = rule.value.match.expr != null ? [rule.value.match.expr] : [] + content { + expression = expr.value.expression + } + } + } + + dynamic "rate_limit_options" { + for_each = rule.value.rate_limit_options != null ? [rule.value.rate_limit_options] : [] + content { + enforce_on_key = rate_limit_options.value.enforce_on_key + conform_action = rate_limit_options.value.conform_action + exceed_action = rate_limit_options.value.exceed_action + rate_limit_threshold { + count = rate_limit_options.value.qpm_rate_limit + interval_sec = rate_limit_options.value.interval_sec + } + } + } + + dynamic "redirect_options" { + for_each = rule.value.redirect_options != null ? [rule.value.redirect_options] : [] + content { + type = redirect_options.value.type + target = redirect_options.value.target + } + } + + description = rule.value.description + } + + } + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + advanced_options_config { + json_parsing = "STANDARD" + } + + adaptive_protection_config { + layer_7_ddos_defense_config { + enable = var.enable_adaptive_protection + } + } +} + +// Create HTTPS certificate and load balancer configuration if managing DNS, otherwise use HTTP. +// Production instances should ALWAYS set a domain name and use HTTPS. Developers may choose to use HTTP for simpler ephemeral deployments. + +resource "google_compute_ssl_policy" "ssl_policy" { + count = var.enable_ssl_policy ? 1 : 0 + + name = var.ssl_policy_name + project = var.project_id + + profile = "MODERN" + min_tls_version = "TLS_1_2" +} + +####################### PER-SERVICE MULTIREGION ######################## +# One per service globally, only if using a multi-region configuration # +# rather than K8s-Ingress-driven load balancing. # +######################################################################## + +resource "google_compute_health_check" "http_health_check" { + count = var.single_region ? 0 : 1 + + name = "timestamp-http-health-check" + project = var.project_id + + timeout_sec = 5 + check_interval_sec = 5 + healthy_threshold = 2 + unhealthy_threshold = 2 + + http_health_check { + request_path = "/ping" + port_specification = "USE_SERVING_PORT" + } + + log_config { + enable = var.enable_healthcheck_logging + } +} + +data "google_compute_network_endpoint_group" "k8s_http_neg" { + for_each = toset(var.network_endpoint_group_zones) + + name = var.network_endpoint_group_name + project = var.project_id + zone = each.key +} + +resource "google_compute_backend_service" "http_backend_service" { + count = var.single_region ? 0 : 1 + + name = "timestamp-http-backend" + project = var.project_id + + load_balancing_scheme = "EXTERNAL_MANAGED" + port_name = "http" + protocol = "HTTP" + + connection_draining_timeout_sec = 15 + health_checks = [google_compute_health_check.http_health_check[count.index].id] + + dynamic "backend" { + for_each = data.google_compute_network_endpoint_group.k8s_http_neg + iterator = neg + content { + group = neg.value.id + balancing_mode = "RATE" + max_rate_per_endpoint = var.backend_service_max_rps + } + } + + depends_on = [google_compute_security_policy.http_security_policy] + security_policy = length(google_compute_security_policy.http_security_policy) > 0 ? google_compute_security_policy.http_security_policy[0].self_link : "" + + log_config { + enable = var.enable_backend_service_logging + } +} + +resource "google_compute_url_map" "url_map" { + count = var.single_region ? 0 : 1 + + name = "timestamp-lb" + project = var.project_id + + default_service = google_compute_backend_service.http_backend_service[count.index].id +} + +resource "google_certificate_manager_certificate" "ssl_certificate" { + count = var.single_region ? 0 : 1 + + name = "timestamp-ssl-cert" + project = var.project_id + + managed { + domains = [local.hostname] + dns_authorizations = [ + google_certificate_manager_dns_authorization.timestamp_auth[count.index].id + ] + } +} + +resource "google_certificate_manager_certificate_map" "timestamp_certificate_map" { + count = var.single_region ? 0 : 1 + + name = "timestamp-cert-map" +} + +resource "google_certificate_manager_certificate_map_entry" "timestamp_certificate_map_entry" { + count = var.single_region ? 0 : 1 + + name = "timestamp-cert-map-entry" + map = google_certificate_manager_certificate_map.timestamp_certificate_map[count.index].name + certificates = [google_certificate_manager_certificate.ssl_certificate[count.index].id] + hostname = local.hostname +} + +resource "google_compute_target_https_proxy" "lb_proxy" { + count = var.single_region ? 0 : 1 + + name = "timestamp-https-proxy" + project = var.project_id + + url_map = google_compute_url_map.url_map[count.index].id + + ssl_policy = google_compute_ssl_policy.ssl_policy[count.index].id + certificate_map = "//certificatemanager.googleapis.com/${google_certificate_manager_certificate_map.timestamp_certificate_map[count.index].id}" +} + + +resource "google_compute_global_forwarding_rule" "https_forwarding_rule" { + count = var.single_region ? 0 : 1 + + name = "timestamp-https-forwarding-rule" + project = var.project_id + + ip_address = google_compute_global_address.gce_lb_ipv4.address + target = google_compute_target_https_proxy.lb_proxy[count.index].id + port_range = "443" + load_balancing_scheme = "EXTERNAL_MANAGED" + ip_protocol = "TCP" +} diff --git a/gcp/modules/timestamp/global/variables.tf b/gcp/modules/timestamp/global/variables.tf new file mode 100644 index 00000000..c9d711ed --- /dev/null +++ b/gcp/modules/timestamp/global/variables.tf @@ -0,0 +1,159 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + type = string + default = "" + validation { + condition = length(var.project_id) > 0 + error_message = "Must specify project_id variable." + } +} + +variable "single_region" { + description = "Whether this module instance is only deployed in one region, and therefore in charge of managing its own IP address and DNS record but not other load balancer resources." + type = bool + default = false +} + +variable "lb_address_name" { + description = "Name of the global address of the load balancer. If not specified, defaults to 'timestamp-CLUSTER_NAME-gce-ext-lb'." + type = string + default = "" +} + +variable "cluster_name" { + description = "The name to give the new Kubernetes cluster." + type = string + default = "" +} + +variable "dns_zone_name" { + description = "Name of DNS Zone object in Google Cloud DNS" + type = string +} + +variable "dns_domain_name" { + description = "Name of DNS domain name in Google Cloud DNS" + type = string +} + +variable "manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + +variable "enable_cloud_armor" { + description = "Whether to create a Cloud Armor security policy." + type = bool + default = false +} + +variable "cloud_armor_policy_name" { + description = "Name of the Cloud Armor policy." + type = string + default = "tsa-service-security-policy" +} + +variable "cloud_armor_rules" { + description = "Cloud Armor security policy rules." + type = list(object({ + action = string + priority = number + description = optional(string) + + match = object({ + versioned_expr = optional(string) + + config = optional(object({ + src_ip_ranges = list(string) + })) + + expr = optional(object({ + expression = string + })) + }) + + rate_limit_options = optional(object({ + enforce_on_key = string + conform_action = string + exceed_action = string + qpm_rate_limit = number + interval_sec = number + })) + + redirect_options = optional(object({ + type = string + target = string + })) + })) + default = [] +} + +variable "enable_adaptive_protection" { + description = "Whether to enable layer 7 DDoS adaptive protection in Cloud Armor." + type = bool + default = true +} + +variable "enable_ssl_policy" { + description = "Whether to create a SSL policy." + type = bool + default = false +} + +variable "ssl_policy_name" { + description = "Name of the SSL policy." + type = string + default = "tsa-ingress-ssl-policy" +} + +variable "network" { + description = "VPC network in which the GKE cluster lives" + type = string + default = "default" +} + +variable "enable_healthcheck_logging" { + description = "Whether to enable logging for the HTTP health check" + type = bool + default = true +} + +variable "network_endpoint_group_zones" { + type = list(string) + description = "zones where the NEGs live. NEGs will not exist until the Kubernetes service they belong to exists and creates them. This value must be set to empty if NEGs are not expected to exist yet, and then can later be updated." + default = [] +} + +variable "network_endpoint_group_name" { + description = "Name of the NEG that will be created for the HTTP service by the timestamp Kubernetes service." + type = string + default = "" +} + +variable "backend_service_max_rps" { + description = "Max requests per second that a single backend instance can handle." + type = number + default = 100 +} + +variable "enable_backend_service_logging" { + description = "Whether to enable logging for the HTTP backend service." + type = bool + default = true +} diff --git a/gcp/modules/timestamp/global/versions.tf b/gcp/modules/timestamp/global/versions.tf new file mode 100644 index 00000000..371c3133 --- /dev/null +++ b/gcp/modules/timestamp/global/versions.tf @@ -0,0 +1,26 @@ +/** + * Copyright 2026 The Sigstore Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_version = "1.14.2" + + required_providers { + google = { + version = "7.21.0" + source = "hashicorp/google" + } + } +} diff --git a/gcp/modules/timestamp/network.tf b/gcp/modules/timestamp/network.tf index 849cdfbb..594af36a 100644 --- a/gcp/modules/timestamp/network.tf +++ b/gcp/modules/timestamp/network.tf @@ -14,107 +14,39 @@ * limitations under the License. */ -resource "google_dns_record_set" "A_timestamp" { - count = var.dns_domain_name == "" ? 0 : 1 - name = "timestamp.${var.dns_domain_name}" - type = "A" - ttl = 60 +module "global" { + count = var.single_region ? 1 : 0 - project = var.project_id - managed_zone = var.dns_zone_name + source = "./global" - rrdatas = [google_compute_global_address.gce_lb_ipv4.address] -} - -// Create a static global IP for the external IPV4 GCE L7 load balancer -resource "google_compute_global_address" "gce_lb_ipv4" { - name = format("timestamp-%s-gce-ext-lb", var.cluster_name) - address_type = "EXTERNAL" - project = var.project_id -} - -resource "google_compute_security_policy" "http_security_policy" { - count = var.enable_cloud_armor ? 1 : 0 - - name = "tsa-service-security-policy" - project = var.project_id - type = "CLOUD_ARMOR" - - dynamic "rule" { - for_each = var.cloud_armor_rules - content { - action = rule.value.action - priority = rule.value.priority - match { - versioned_expr = rule.value.match.versioned_expr - dynamic "config" { - for_each = rule.value.match.config != null ? [rule.value.match.config] : [] - content { - src_ip_ranges = config.value.src_ip_ranges - } - } - dynamic "expr" { - for_each = rule.value.match.expr != null ? [rule.value.match.expr] : [] - content { - expression = expr.value.expression - } - } - } - - dynamic "rate_limit_options" { - for_each = rule.value.rate_limit_options != null ? [rule.value.rate_limit_options] : [] - content { - enforce_on_key = rate_limit_options.value.enforce_on_key - conform_action = rate_limit_options.value.conform_action - exceed_action = rate_limit_options.value.exceed_action - rate_limit_threshold { - count = rate_limit_options.value.qpm_rate_limit - interval_sec = rate_limit_options.value.interval_sec - } - } - } + project_id = var.project_id - dynamic "redirect_options" { - for_each = rule.value.redirect_options != null ? [rule.value.redirect_options] : [] - content { - type = redirect_options.value.type - target = redirect_options.value.target - } - } + single_region = true + manage_dns_a_record = var.manage_dns_a_record - description = rule.value.description - } + dns_zone_name = var.dns_zone_name + dns_domain_name = var.dns_domain_name - } + cluster_name = var.cluster_name - rule { - action = "allow" - priority = "2147483647" - match { - versioned_expr = "SRC_IPS_V1" - config { - src_ip_ranges = ["*"] - } - } - description = "default rule" - } - - advanced_options_config { - json_parsing = "STANDARD" - } - - adaptive_protection_config { - layer_7_ddos_defense_config { - enable = var.enable_adaptive_protection - } - } + enable_cloud_armor = var.enable_cloud_armor + cloud_armor_rules = var.cloud_armor_rules + enable_adaptive_protection = var.enable_adaptive_protection + enable_ssl_policy = var.enable_ssl_policy } - -resource "google_compute_ssl_policy" "ssl_policy" { - count = var.enable_ssl_policy ? 1 : 0 - name = "tsa-ingress-ssl-policy" - project = var.project_id - - profile = "MODERN" - min_tls_version = "TLS_1_2" +moved { + from = google_dns_record_set.A_timestamp + to = module.global[0].google_dns_record_set.A_timestamp +} +moved { + from = google_compute_global_address.gce_lb_ipv4 + to = module.global[0].google_compute_global_address.gce_lb_ipv4 +} +moved { + from = google_compute_security_policy.http_security_policy + to = module.global[0].google_compute_security_policy.http_security_policy +} +moved { + from = google_compute_ssl_policy.ssl_policy + to = module.global[0].google_compute_ssl_policy.ssl_policy } diff --git a/gcp/modules/timestamp/variables.tf b/gcp/modules/timestamp/variables.tf index 37308493..f3b27399 100644 --- a/gcp/modules/timestamp/variables.tf +++ b/gcp/modules/timestamp/variables.tf @@ -28,6 +28,12 @@ variable "region" { description = "GCP region" } +variable "single_region" { + description = "Whether this module instance is only deployed in one region, and therefore in charge of managing its own IP address and DNS record but not other load balancer resources." + type = bool + default = true +} + variable "cluster_name" { description = "The name to give the new Kubernetes cluster." type = string @@ -68,6 +74,12 @@ variable "dns_domain_name" { type = string } +variable "manage_dns_a_record" { + description = "Whether this module is in charge of managing the DNS A record. This is to enable transitioning from having DNS managed in a single region to managing the same record globally for all regions." + type = bool + default = true +} + // Network variable "enable_cloud_armor" { description = "Whether to create a Cloud Armor security policy."