From 4c93ae8c50ebd0c63f0e29ae6e20ba00e62ba309 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 19:51:06 +0200 Subject: [PATCH 1/5] feat(gcp): Terraform module and OIDC-signed log pushes The setup script and a new Terraform module create the same resources. The push subscription now signs each request as a dedicated push service account that holds no role, so neither the subscription nor Terraform state carries the connection's secret. --- deploy/terraform-google-maple/.gitignore | 3 + deploy/terraform-google-maple/logs.tf | 150 +++++++++++++++++ deploy/terraform-google-maple/main.tf | 30 ++++ deploy/terraform-google-maple/outputs.tf | 14 ++ deploy/terraform-google-maple/reader.tf | 61 +++++++ .../tests/module.tftest.hcl | 151 ++++++++++++++++++ deploy/terraform-google-maple/variables.tf | 62 +++++++ deploy/terraform-google-maple/versions.tf | 15 ++ .../integrations/GcpConnectorService.test.ts | 14 +- .../integrations/GcpConnectorService.ts | 24 +-- .../integrations/gcp/setup-scripts.test.ts | 106 +++++++----- .../integrations/gcp/setup-scripts.ts | 118 ++++++++++---- .../integrations/gcp/terraform.test.ts | 60 +++++++ .../services/integrations/gcp/terraform.ts | 62 +++++++ packages/domain/src/gcp.test.ts | 2 + packages/domain/src/gcp.ts | 14 +- 16 files changed, 799 insertions(+), 87 deletions(-) create mode 100644 deploy/terraform-google-maple/.gitignore create mode 100644 deploy/terraform-google-maple/logs.tf create mode 100644 deploy/terraform-google-maple/main.tf create mode 100644 deploy/terraform-google-maple/outputs.tf create mode 100644 deploy/terraform-google-maple/reader.tf create mode 100644 deploy/terraform-google-maple/tests/module.tftest.hcl create mode 100644 deploy/terraform-google-maple/variables.tf create mode 100644 deploy/terraform-google-maple/versions.tf create mode 100644 packages/backend/src/services/integrations/gcp/terraform.test.ts create mode 100644 packages/backend/src/services/integrations/gcp/terraform.ts diff --git a/deploy/terraform-google-maple/.gitignore b/deploy/terraform-google-maple/.gitignore new file mode 100644 index 0000000000..c261156b3d --- /dev/null +++ b/deploy/terraform-google-maple/.gitignore @@ -0,0 +1,3 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate* diff --git a/deploy/terraform-google-maple/logs.tf b/deploy/terraform-google-maple/logs.tf new file mode 100644 index 0000000000..eab84a4dd0 --- /dev/null +++ b/deploy/terraform-google-maple/logs.tf @@ -0,0 +1,150 @@ +# Log forwarding: a Log Router sink on the scope routes entries into a Pub/Sub topic in the host +# project, whose push subscription delivers each LogEntry to Maple, signed as the push account. + +resource "google_pubsub_topic" "logs" { + count = var.logs_enabled ? 1 : 0 + + project = var.project_id + name = local.name + + depends_on = [google_project_service.this] +} + +# What Pub/Sub signs its pushes as. It holds no role anywhere: an ID token for it proves only +# that a push came from this subscription. +resource "google_service_account" "push" { + count = var.logs_enabled ? 1 : 0 + + project = var.project_id + account_id = local.push_name + display_name = "Maple log push identity" + description = "Signs Pub/Sub push requests to Maple. Holds no roles." + + depends_on = [google_project_service.this] +} + +# Pub/Sub's service agent mints the push tokens. Projects created after April 2021 allow it by +# default; older ones need this grant. After the topic: Google creates the agent on first use. +resource "google_service_account_iam_member" "push_token_creator" { + count = var.logs_enabled ? 1 : 0 + + service_account_id = google_service_account.push[0].name + role = "roles/iam.serviceAccountTokenCreator" + member = "serviceAccount:service-${data.google_project.host.number}@gcp-sa-pubsub.iam.gserviceaccount.com" + + depends_on = [google_pubsub_topic.logs] +} + +# One LogEntry per request, unwrapped. A day of retention bounds what is replayed after an outage, +# and the subscription never expires while it is idle. +resource "google_pubsub_subscription" "logs" { + count = var.logs_enabled ? 1 : 0 + + project = var.project_id + name = local.name + topic = google_pubsub_topic.logs[0].id + ack_deadline_seconds = 30 + message_retention_duration = "86400s" + + expiration_policy { + ttl = "" + } + + retry_policy { + minimum_backoff = "10s" + maximum_backoff = "600s" + } + + push_config { + push_endpoint = var.push_endpoint + + no_wrapper { + write_metadata = false + } + + oidc_token { + service_account_email = google_service_account.push[0].email + audience = var.push_endpoint + } + } + + depends_on = [google_service_account_iam_member.push_token_creator] +} + +# Google keeps routing for a while after a sink is deleted. With the topic gone by then it logs an +# error in the host project and may notify its contacts, so the topic outlives the sink a minute. +resource "time_sleep" "sink_drain" { + count = var.logs_enabled ? 1 : 0 + + destroy_duration = "60s" + + depends_on = [google_pubsub_topic.logs] +} + +locals { + destination = var.logs_enabled ? "pubsub.googleapis.com/${google_pubsub_topic.logs[0].id}" : null +} + +resource "google_logging_project_sink" "logs" { + count = var.logs_enabled && var.scope_type == "project" ? 1 : 0 + + project = var.scope_id + name = local.name + destination = local.destination + filter = var.log_filter + unique_writer_identity = true + + depends_on = [time_sleep.sink_drain] +} + +# A folder or organization sink also routes the logs of every project underneath. +resource "google_logging_folder_sink" "logs" { + count = var.logs_enabled && var.scope_type == "folder" ? 1 : 0 + + folder = var.scope_id + name = local.name + destination = local.destination + filter = var.log_filter + include_children = true + + depends_on = [time_sleep.sink_drain] +} + +resource "google_logging_organization_sink" "logs" { + count = var.logs_enabled && var.scope_type == "organization" ? 1 : 0 + + org_id = var.scope_id + name = local.name + destination = local.destination + filter = var.log_filter + include_children = true + + depends_on = [time_sleep.sink_drain] +} + +locals { + writer_identity = one(concat( + google_logging_project_sink.logs[*].writer_identity, + google_logging_folder_sink.logs[*].writer_identity, + google_logging_organization_sink.logs[*].writer_identity, + )) +} + +# The sink writes as a Google-managed identity: Logs Writer on the project that holds the +# destination, and Pub/Sub Publisher on this one topic. +resource "google_project_iam_member" "sink_log_writer" { + count = var.logs_enabled ? 1 : 0 + + project = var.project_id + role = "roles/logging.logWriter" + member = local.writer_identity +} + +resource "google_pubsub_topic_iam_member" "sink_publisher" { + count = var.logs_enabled ? 1 : 0 + + project = var.project_id + topic = google_pubsub_topic.logs[0].name + role = "roles/pubsub.publisher" + member = local.writer_identity +} diff --git a/deploy/terraform-google-maple/main.tf b/deploy/terraform-google-maple/main.tf new file mode 100644 index 0000000000..5593fc1d89 --- /dev/null +++ b/deploy/terraform-google-maple/main.tf @@ -0,0 +1,30 @@ +locals { + hex = replace(var.connector_id, "-", "") + + # The same names Maple's setup script uses, so either can take over from the other. + name = "maple-${substr(local.hex, 0, 24)}" + push_name = "maple-push-${substr(local.hex, 0, 19)}" + + reader = var.maple_service_account != null + + # Pub/Sub carries the log entries, Cloud Logging routes them, IAM holds the push identity. + # Cloud Monitoring and Cloud Asset answer Maple's reads, IAM Credentials mints its short-lived + # tokens: no key is ever created. Cloud Resource Manager is what IAM grants go through. + apis = toset(concat( + ["cloudresourcemanager.googleapis.com", "iam.googleapis.com"], + var.logs_enabled ? ["pubsub.googleapis.com", "logging.googleapis.com"] : [], + local.reader ? ["monitoring.googleapis.com", "cloudasset.googleapis.com", "iamcredentials.googleapis.com"] : [], + )) +} + +resource "google_project_service" "this" { + for_each = var.enable_apis ? local.apis : toset([]) + + project = var.project_id + service = each.value + disable_on_destroy = false +} + +data "google_project" "host" { + project_id = var.project_id +} diff --git a/deploy/terraform-google-maple/outputs.tf b/deploy/terraform-google-maple/outputs.tf new file mode 100644 index 0000000000..108c90fe57 --- /dev/null +++ b/deploy/terraform-google-maple/outputs.tf @@ -0,0 +1,14 @@ +output "push_service_account_email" { + description = "The identity Pub/Sub signs pushes to Maple as. Null while log forwarding is off." + value = one(google_service_account.push[*].email) +} + +output "reader_service_account_email" { + description = "The read-only account Maple reads metrics and resources as. Null without maple_service_account." + value = one(google_service_account.reader[*].email) +} + +output "sink_writer_identity" { + description = "The Google-managed identity the log sink writes as. Null while log forwarding is off." + value = local.writer_identity +} diff --git a/deploy/terraform-google-maple/reader.tf b/deploy/terraform-google-maple/reader.tf new file mode 100644 index 0000000000..6beee43d66 --- /dev/null +++ b/deploy/terraform-google-maple/reader.tf @@ -0,0 +1,61 @@ +# Read-only access: a service account Maple reads metrics and resources as, through short-lived +# tokens its own account may mint for this one account. No key is created. + +resource "google_service_account" "reader" { + count = local.reader ? 1 : 0 + + project = var.project_id + account_id = local.name + display_name = "Maple metrics and resource reader" + + depends_on = [google_project_service.this] +} + +resource "google_service_account_iam_member" "maple_token_creator" { + count = local.reader ? 1 : 0 + + service_account_id = google_service_account.reader[0].name + role = "roles/iam.serviceAccountTokenCreator" + member = "serviceAccount:${var.maple_service_account}" +} + +# On the scope, inherited by every project under a folder or organization. The narrowest +# predefined roles for Maple's two calls: monitoring.timeSeries.list and +# cloudasset.assets.searchAllResources. Both are read-only, and broader than the calls: Cloud +# Asset Viewer can also read resource metadata and IAM policies. +locals { + scope_roles = local.reader ? toset(["roles/monitoring.viewer", "roles/cloudasset.viewer"]) : toset([]) +} + +resource "google_project_iam_member" "reader_scope" { + for_each = var.scope_type == "project" ? local.scope_roles : toset([]) + + project = var.scope_id + role = each.value + member = "serviceAccount:${google_service_account.reader[0].email}" +} + +resource "google_folder_iam_member" "reader_scope" { + for_each = var.scope_type == "folder" ? local.scope_roles : toset([]) + + folder = "folders/${var.scope_id}" + role = each.value + member = "serviceAccount:${google_service_account.reader[0].email}" +} + +resource "google_organization_iam_member" "reader_scope" { + for_each = var.scope_type == "organization" ? local.scope_roles : toset([]) + + org_id = var.scope_id + role = each.value + member = "serviceAccount:${google_service_account.reader[0].email}" +} + +# Maple's reads name the host project as the one their quota is counted against. +resource "google_project_iam_member" "reader_quota" { + count = local.reader ? 1 : 0 + + project = var.project_id + role = "roles/serviceusage.serviceUsageConsumer" + member = "serviceAccount:${google_service_account.reader[0].email}" +} diff --git a/deploy/terraform-google-maple/tests/module.tftest.hcl b/deploy/terraform-google-maple/tests/module.tftest.hcl new file mode 100644 index 0000000000..de1f429c09 --- /dev/null +++ b/deploy/terraform-google-maple/tests/module.tftest.hcl @@ -0,0 +1,151 @@ +# Plans against mocked providers: no Google credentials, nothing created. Run with +# `terraform test` (1.7+) or `tofu test`. + +mock_provider "google" { + mock_data "google_project" { + defaults = { + number = "123456789012" + } + } + + mock_resource "google_service_account" { + defaults = { + email = "maple-push-0a1b2c3d4e5f4071829@acme-host.iam.gserviceaccount.com" + name = "projects/acme-host/serviceAccounts/maple-push-0a1b2c3d4e5f4071829@acme-host.iam.gserviceaccount.com" + } + } + + mock_resource "google_pubsub_topic" { + defaults = { + id = "projects/acme-host/topics/maple-0a1b2c3d4e5f40718293a4b5" + } + } + + mock_resource "google_logging_project_sink" { + defaults = { + writer_identity = "serviceAccount:service-123456789012@gcp-sa-logging.iam.gserviceaccount.com" + } + } + + mock_resource "google_logging_folder_sink" { + defaults = { + writer_identity = "serviceAccount:service-folder-222222222222@gcp-sa-logging.iam.gserviceaccount.com" + } + } + + mock_resource "google_logging_organization_sink" { + defaults = { + writer_identity = "serviceAccount:service-org-111111111111@gcp-sa-logging.iam.gserviceaccount.com" + } + } +} + +mock_provider "time" {} + +variables { + connector_id = "0a1b2c3d-4e5f-4071-8293-a4b5c6d7e8f9" + scope_type = "project" + scope_id = "acme-prod" + project_id = "acme-prod" + push_endpoint = "https://ingest.maple.dev/v1/logpush/gcp/0a1b2c3d-4e5f-4071-8293-a4b5c6d7e8f9" + maple_service_account = "maple@maple-prd.iam.gserviceaccount.com" + log_filter = "NOT log_id(\"cloudaudit.googleapis.com/data_access\")" +} + +run "project_scope_names_resources_like_the_setup_script" { + command = plan + + assert { + condition = google_pubsub_topic.logs[0].name == "maple-0a1b2c3d4e5f40718293a4b5" + error_message = "topic name differs from the setup script's" + } + + assert { + condition = google_service_account.push[0].account_id == "maple-push-0a1b2c3d4e5f4071829" + error_message = "push account id differs from the setup script's" + } + + assert { + condition = google_service_account.reader[0].account_id == "maple-0a1b2c3d4e5f40718293a4b5" + error_message = "reader account id differs from the setup script's" + } + + assert { + condition = length(google_logging_project_sink.logs) == 1 && length(google_logging_folder_sink.logs) == 0 && length(google_logging_organization_sink.logs) == 0 + error_message = "a project scope gets exactly one project sink" + } + + assert { + condition = google_logging_project_sink.logs[0].filter == var.log_filter + error_message = "the sink filter is not the given one" + } + + assert { + condition = google_pubsub_subscription.logs[0].push_config[0].oidc_token[0].audience == var.push_endpoint + error_message = "pushes are not signed for the connection's endpoint" + } + + assert { + condition = google_service_account_iam_member.push_token_creator[0].member == "serviceAccount:service-123456789012@gcp-sa-pubsub.iam.gserviceaccount.com" + error_message = "the Pub/Sub service agent may not sign as the push account" + } + + assert { + condition = toset(keys(google_project_iam_member.reader_scope)) == toset(["roles/monitoring.viewer", "roles/cloudasset.viewer"]) + error_message = "the reader lacks its read-only roles on the project" + } +} + +run "organization_scope_routes_children_and_grants_on_the_organization" { + command = plan + + variables { + scope_type = "organization" + scope_id = "111111111111" + project_id = "acme-host" + } + + assert { + condition = length(google_logging_organization_sink.logs) == 1 && google_logging_organization_sink.logs[0].include_children + error_message = "an organization scope routes its children's logs" + } + + assert { + condition = length(google_project_iam_member.reader_scope) == 0 && length(google_organization_iam_member.reader_scope) == 2 + error_message = "read-only roles belong on the organization" + } + + assert { + condition = google_project_iam_member.reader_quota[0].project == "acme-host" + error_message = "quota is counted against the host project" + } +} + +run "logs_off_and_no_maple_account_create_nothing" { + command = plan + + variables { + logs_enabled = false + maple_service_account = null + } + + assert { + condition = length(google_pubsub_topic.logs) == 0 && length(google_service_account.push) == 0 && length(google_logging_project_sink.logs) == 0 + error_message = "log forwarding resources exist while it is off" + } + + assert { + condition = length(google_service_account.reader) == 0 && length(google_project_iam_member.reader_scope) == 0 + error_message = "read-only access exists without Maple's account" + } +} + +run "rejects_a_push_endpoint_with_a_secret" { + command = plan + + variables { + push_endpoint = "https://ingest.maple.dev/v1/logpush/gcp/0a1b2c3d-4e5f-4071-8293-a4b5c6d7e8f9?secret=x" + } + + expect_failures = [var.push_endpoint] +} diff --git a/deploy/terraform-google-maple/variables.tf b/deploy/terraform-google-maple/variables.tf new file mode 100644 index 0000000000..04fdbad452 --- /dev/null +++ b/deploy/terraform-google-maple/variables.tf @@ -0,0 +1,62 @@ +variable "connector_id" { + description = "The connection's ID in Maple. Every resource name is derived from it." + type = string + + validation { + condition = can(regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", var.connector_id)) + error_message = "connector_id is the UUID Maple shows for the connection." + } +} + +variable "scope_type" { + description = "What the connection covers: \"project\", \"folder\" or \"organization\"." + type = string + + validation { + condition = contains(["project", "folder", "organization"], var.scope_type) + error_message = "scope_type is \"project\", \"folder\" or \"organization\"." + } +} + +variable "scope_id" { + description = "The project ID, or the numeric folder or organization ID." + type = string +} + +variable "project_id" { + description = "The host project: it holds the Pub/Sub topic, the subscription and both service accounts. For a project scope, the project itself." + type = string +} + +variable "push_endpoint" { + description = "Maple's receiver for this connection. It carries no secret: Pub/Sub signs each push as the push service account, and Maple accepts only that account's tokens." + type = string + + validation { + condition = can(regex("^https://[^?#]+/v1/logpush/gcp/[0-9a-f-]{36}$", var.push_endpoint)) + error_message = "push_endpoint is the https URL Maple shows for the connection, without a query string." + } +} + +variable "maple_service_account" { + description = "Maple's own service account, allowed to read as the read-only account. Null leaves read-only access out: live metric queries and key metrics then can't read this scope." + type = string + default = null +} + +variable "logs_enabled" { + description = "Forward logs to Maple through a Log Router sink and a Pub/Sub push subscription." + type = bool + default = true +} + +variable "log_filter" { + description = "The sink's inclusion filter. Maple's generated configuration fills it in; see https://maple.dev/docs/integrations/gcp#which-logs-are-forwarded." + type = string +} + +variable "enable_apis" { + description = "Switch on the APIs the resources need in the host project. Set false where APIs are managed elsewhere. They are never switched off on destroy." + type = bool + default = true +} diff --git a/deploy/terraform-google-maple/versions.tf b/deploy/terraform-google-maple/versions.tf new file mode 100644 index 0000000000..2a5bbaf295 --- /dev/null +++ b/deploy/terraform-google-maple/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + google = { + source = "hashicorp/google" + # `push_config.no_wrapper` arrived in 5.0. + version = ">= 5.0, < 8.0" + } + time = { + source = "hashicorp/time" + version = ">= 0.9" + } + } +} diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts index 2bbdb35a25..f4433e734f 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.test.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -89,14 +89,16 @@ describe("GcpConnectorService", () => { assert.isNull(connector.appliedLogsEnabled) assert.isNull(connector.setupReportedAt) - const { setupScript } = yield* gcp.scripts(orgId, connector.id, scriptOptions) - const endpoint = /PUSH_ENDPOINT='([^']+)'/.exec(setupScript)?.[1] + const { setupScript, terraform } = yield* gcp.scripts(orgId, connector.id, scriptOptions) + // What Google Cloud is given carries no secret: Pub/Sub signs its pushes instead. + const pushEndpoint = `https://ingest.test/v1/logpush/gcp/${connector.id}` + assert.include(setupScript, `PUSH_ENDPOINT='${pushEndpoint}'\n`) + assert.match(terraform, new RegExp(` push_endpoint += "${pushEndpoint}"\n`)) + assert.notInclude(terraform, "secret=") + const endpoint = /REPORT_ENDPOINT='([^']+)'/.exec(setupScript)?.[1] assert.isDefined(endpoint) const url = new URL(endpoint!) - assert.strictEqual( - url.origin + url.pathname, - `https://ingest.test/v1/logpush/gcp/${connector.id}`, - ) + assert.strictEqual(url.origin + url.pathname, pushEndpoint) const secret = url.searchParams.get("secret")! assert.match(secret, /^maple_gcp_[A-Za-z0-9_-]{32}$/) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.ts b/packages/backend/src/services/integrations/GcpConnectorService.ts index 72fff42805..ae1db0ec72 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.ts @@ -27,6 +27,7 @@ import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" import { Env } from "@maple/backend/platform/Env" import { renderGcpCleanupScript, renderGcpSetupScript } from "./gcp/setup-scripts" +import { renderGcpTerraform } from "./gcp/terraform" export interface GcpConnector { readonly id: GcpConnectorId @@ -114,7 +115,7 @@ export interface GcpConnectorServiceApi { connectorId: GcpConnectorId, options: { readonly applicationLogs: ReadonlyArray | null }, ) => Effect.Effect< - { readonly setupScript: string; readonly cleanupScript: string }, + { readonly setupScript: string; readonly cleanupScript: string; readonly terraform: string }, IntegrationsNotFoundError | IntegrationsPersistenceError > /** What the latest inventory syncs of the org's connectors found. */ @@ -472,22 +473,27 @@ export class GcpConnectorService extends Context.Service ({ ...SCOPES[scope].target, pushEndpoint: PUSH_ENDPOINT, + reportEndpoint: REPORT_ENDPOINT, mapleServiceAccountEmail: MAPLE_ACCOUNT, logsEnabled, applicationLogs: null, }) const setup = (input: GcpSetupScriptInput) => Effect.runSync(renderGcpSetupScript(input)) -const cleanup = (scope: Scope, pushEndpoint?: string) => - Effect.runSync(renderGcpCleanupScript({ ...scope.target, pushEndpoint })) +const cleanup = (scope: Scope, reportEndpoint?: string) => + Effect.runSync(renderGcpCleanupScript({ ...scope.target, reportEndpoint })) // A `gcloud` that records its commands, and each argument on its own line so a value split by // bad quoting shows. It talks on stderr like the real one, which a script must not pass on. @@ -114,6 +117,7 @@ case "$*" in "config get-value account") echo "$GCLOUD_ACCOUNT" ;; "config get-value auth/impersonate_service_account") echo "$GCLOUD_IMPERSONATE" ;; "auth print-access-token") echo "ya29.token" ;; + "projects describe"*"projectNumber"*) echo 987654321098 ;; "projects describe"*) echo "Acme Production" ;; "organizations describe"* | "resource-manager folders describe"*) echo "acme.com" ;; "billing projects describe"*) echo "$GCLOUD_BILLING" ;; @@ -285,7 +289,7 @@ const RESOURCE_MANAGER_ON = "services enable cloudresourcemanager.googleapis.com const isAccessCheck = (command: string) => command === API_LOOKUP || command === RESOURCE_MANAGER_ON || - /^(version|config get-value|auth print-access-token|projects describe|organizations describe|resource-manager folders describe|billing projects describe|logging read)/.test( + /^(version|config get-value|auth print-access-token|projects describe(?!.*projectNumber)|organizations describe|resource-manager folders describe|billing projects describe|logging read)/.test( command, ) @@ -301,14 +305,22 @@ const publish = (members: string) => ), ) +const PUSH_ACCOUNT_DESCRIBE = `iam service-accounts describe ${PUSH_ACCOUNT} --project=acme-host` +const PUSH_FLAGS = `--push-endpoint=${PUSH_ENDPOINT} --push-no-wrapper --push-auth-service-account=${PUSH_ACCOUNT} --push-auth-token-audience=${PUSH_ENDPOINT}` +const PUSH_GRANT = `iam service-accounts add-iam-policy-binding ${PUSH_ACCOUNT} --project=acme-host --member=serviceAccount:service-987654321098@gcp-sa-pubsub.iam.gserviceaccount.com --role=roles/iam.serviceAccountTokenCreator --condition=None` + const logsSetupCommands = (scope: Scope) => [ // The last access check: whether the sink exists decides whether the filter is tried out. `logging sinks describe ${NAME} ${scope.sink}`, - "services enable pubsub.googleapis.com logging.googleapis.com --project=acme-host", + "services enable pubsub.googleapis.com logging.googleapis.com iam.googleapis.com --project=acme-host", `pubsub topics describe ${NAME} --project=acme-host`, `pubsub topics create ${NAME} --project=acme-host`, + PUSH_ACCOUNT_DESCRIBE, + `iam service-accounts create maple-push-018f2b3c4d5e4f70819 --project=acme-host --display-name=Maple log push identity --description=Signs Pub/Sub push requests to Maple. Holds no roles.`, + "projects describe acme-host --format=value(projectNumber)", + PUSH_GRANT, `pubsub subscriptions describe ${NAME} --project=acme-host --format=value(topic)`, - `pubsub subscriptions create ${NAME} --topic=${NAME} --project=acme-host --push-endpoint=${PUSH_ENDPOINT} --push-no-wrapper --ack-deadline=30 --message-retention-duration=1d --expiration-period=never --min-retry-delay=10s --max-retry-delay=600s`, + `pubsub subscriptions create ${NAME} --topic=${NAME} --project=acme-host ${PUSH_FLAGS} --ack-deadline=30 --message-retention-duration=1d --expiration-period=never --min-retry-delay=10s --max-retry-delay=600s`, `logging sinks create ${NAME} pubsub.googleapis.com/projects/acme-host/topics/${NAME} --log-filter=${DEFAULT_LOG_FILTER} ${scope.sinkWrite}`, `logging sinks describe ${NAME} ${scope.sink} --format=value(writerIdentity)`, `projects add-iam-policy-binding acme-host --member=${WRITER} --role=roles/logging.logWriter --condition=None`, @@ -318,11 +330,12 @@ const logsSetupCommands = (scope: Scope) => [ const ACCOUNT_DESCRIBE = `iam service-accounts describe ${ACCOUNT} --project=acme-host` -// The account is looked for before the permission check and, missing, again once its APIs are on. -// Maple's grant comes before the roles on the scope. -const readerSetupCommands = (scope: Scope) => [ +// The account is looked for before the permission check and, missing, again once its APIs are on +// (IAM is on already when log forwarding switched it on). Maple's grant comes before the roles on +// the scope. +const readerSetupCommands = (scope: Scope, logs = true) => [ ACCOUNT_DESCRIBE, - "services enable monitoring.googleapis.com cloudasset.googleapis.com iam.googleapis.com iamcredentials.googleapis.com --project=acme-host", + `services enable monitoring.googleapis.com cloudasset.googleapis.com ${logs ? "" : "iam.googleapis.com "}iamcredentials.googleapis.com --project=acme-host`, ACCOUNT_DESCRIBE, `iam service-accounts create ${NAME} --project=acme-host --display-name=Maple metrics and resource reader`, `iam service-accounts add-iam-policy-binding ${ACCOUNT} --project=acme-host --member=serviceAccount:${MAPLE_ACCOUNT} --role=roles/iam.serviceAccountTokenCreator --condition=None`, @@ -336,14 +349,18 @@ const logsRemovalCommands = (scope: Scope) => [ `logging sinks delete ${NAME} ${scope.sink} --quiet`, `pubsub subscriptions describe ${NAME} --project=acme-host`, `pubsub subscriptions delete ${NAME} --project=acme-host --quiet`, + PUSH_ACCOUNT_LOOKUP, + `iam service-accounts delete ${PUSH_ACCOUNT} --project=acme-host --quiet`, `pubsub topics describe ${NAME} --project=acme-host`, `pubsub topics delete ${NAME} --project=acme-host --quiet`, ] const ACCOUNT_LOOKUP = `iam service-accounts list --project=acme-host --filter=email=${ACCOUNT} --format=value(email)` +const PUSH_ACCOUNT_LOOKUP = `iam service-accounts list --project=acme-host --filter=email=${PUSH_ACCOUNT} --format=value(email)` -/** A command that only looks: a describe, or the service account list. */ -const isLookup = (command: string) => command.includes(" describe ") || command === ACCOUNT_LOOKUP +/** A command that only looks: a describe, or a service account list. */ +const isLookup = (command: string) => + command.includes(" describe ") || command === ACCOUNT_LOOKUP || command === PUSH_ACCOUNT_LOOKUP const readerRemovalCommands = (scope: Scope) => [ ACCOUNT_LOOKUP, @@ -391,8 +408,10 @@ ${[...scope.found, ` ✓ ${host} acme-host found (Acme Production)`].join("\n") ✓ Log filter accepted Log forwarding - ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager) + ✓ APIs enabled (Pub/Sub, Cloud Logging, IAM, Cloud Resource Manager) ✓ Topic created + ✓ Push service account created + ✓ Pub/Sub allowed to sign pushes as that account ✓ Push subscription created ✓ Log sink created ✓ Sink allowed to write to the project @@ -427,7 +446,7 @@ Done. Google Cloud is set up for Maple. const { status, commands } = run(setup(setupInput(scopeType, logs)), { describe: "found" }) expect(status).toBe(0) expect(commands).not.toContainEqual(expect.stringContaining("remove-iam-policy-binding")) - expect(commands).not.toContainEqual(expect.stringContaining("service-accounts delete")) + expect(commands).not.toContainEqual(`iam service-accounts delete ${ACCOUNT} --project=acme-host --quiet`) // Every grant is made again, so a run also restores one that was removed. expect(commands).toEqual( expect.arrayContaining( @@ -450,7 +469,7 @@ Done. Google Cloud is set up for Maple. expect(commands).toContain( `${scope.iam} add-iam-policy-binding ${scope.id} --member=serviceAccount:${ACCOUNT} --role=roles/cloudasset.viewer --condition=None`, ) - expect(curl.filter((call) => call.startsWith(PUSH_ENDPOINT))).toEqual([ + expect(curl.filter((call) => call.startsWith(REPORT_ENDPOINT))).toEqual([ expect.stringMatching(REPORT('"logs":false').source.slice(1)), expect.stringMatching(REPORT('"metrics":true').source.slice(1)), ]) @@ -462,13 +481,14 @@ Log forwarding ✓ Log sink deleted … waiting a minute for Google to stop routing to the topic ✓ Push subscription deleted + ✓ Push service account deleted ✓ Topic deleted ✓ Maple notified `) }) it("cleans up everything and tells Maple, and touches nothing when nothing exists", () => { - const script = cleanup(scope, PUSH_ENDPOINT) + const script = cleanup(scope, REPORT_ENDPOINT) const existing = run(script, { describe: "found" }) expect(existing.status).toBe(0) expect(existing.commands).toEqual([...logsRemovalCommands(scope), ...readerRemovalCommands(scope)]) @@ -485,6 +505,7 @@ Log forwarding ✓ Log sink deleted … waiting a minute for Google to stop routing to the topic ✓ Push subscription deleted + ✓ Push service account deleted ✓ Topic deleted Read-only access @@ -582,8 +603,10 @@ describe("renderGcpSetupScript", () => { expect(commands).not.toContainEqual(expect.stringContaining("--log-filter")) expect(stdout).toContain(` Log forwarding - ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager) + ✓ APIs enabled (Pub/Sub, Cloud Logging, IAM, Cloud Resource Manager) ✓ Topic already exists + ✓ Push service account already exists + ✓ Pub/Sub allowed to sign pushes as that account ✓ Push subscription up to date ✓ Log sink up to date (filter kept) ✓ Sink allowed to write to the project @@ -668,8 +691,10 @@ Done. Everything is in place. expect( stdout.endsWith(` Log forwarding - ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager) + ✓ APIs enabled (Pub/Sub, Cloud Logging, IAM, Cloud Resource Manager) ✓ Topic created + ✓ Push service account created + ✓ Pub/Sub allowed to sign pushes as that account ✓ Push subscription created ✗ Couldn't create the log sink. @@ -709,7 +734,7 @@ ${UNFINISHED}`), const quoted = run(script, { fail: "subscriptions create", - answer: `ERROR: (gcloud) INVALID_ARGUMENT: Invalid push endpoint given (endpoint=${PUSH_ENDPOINT}).`, + answer: `ERROR: (gcloud) INVALID_ARGUMENT: Invalid push endpoint given (endpoint=${REPORT_ENDPOINT}).`, }) expect(quoted.stdout).toContain("?secret=HIDDEN).") expect(quoted.stdout).not.toContain(SECRET) @@ -762,7 +787,7 @@ ${UNFINISHED}`) }) expect(on.status).toBe(0) expect(on.commands).toEqual(expect.arrayContaining(logsSetupCommands(SCOPES.organization))) - expect(on.commands).not.toContainEqual(expect.stringContaining("service-accounts create")) + expect(on.commands).not.toContainEqual(expect.stringContaining(`service-accounts create ${NAME} `)) expect(on.stdout).toContain( ` ✗ jane@acme.com is missing permissions on organization 123456789012: resourcemanager.organizations.setIamPolicy. @@ -789,7 +814,10 @@ ${UNFINISHED}`) it("reads an organization policy that refuses Maple's account as a note when logs are forwarded", () => { const refusal = "ERROR: (gcloud.iam.service-accounts.add-iam-policy-binding) FAILED_PRECONDITION: One or more users named in the policy do not belong to a permitted customer." - const options = { fail: "roles/iam.serviceAccountTokenCreator", answer: refusal } + const options = { + fail: `--member=serviceAccount:${MAPLE_ACCOUNT} --role=roles/iam.serviceAccountTokenCreator`, + answer: refusal, + } const on = run(setup(setupInput("project", true)), options) expect(on.status).toBe(0) expect(on.stdout).toContain(" ✗ Couldn't let Maple use the service account.") @@ -823,7 +851,7 @@ ${UNFINISHED}`) expect(allOn.status).toBe(0) expect(allOn.commands).not.toContainEqual(expect.stringContaining("services enable")) expect(allOn.access).not.toContain(RESOURCE_MANAGER_ON) - expect(allOn.stdout).toContain(" ✓ APIs enabled (Pub/Sub, Cloud Logging, Cloud Resource Manager)\n") + expect(allOn.stdout).toContain(" ✓ APIs enabled (Pub/Sub, Cloud Logging, IAM, Cloud Resource Manager)\n") // One is off: only that one is switched on, and a refusal names it and the role. const oneOff = run(script, { apis: all.filter((api) => api !== "iam.googleapis.com") }) @@ -897,7 +925,7 @@ ${UNFINISHED}`) }) it("ends read-only access at a failed step, with no report on the account", () => { - const created = run(setup(setupInput("folder", true)), { fail: "service-accounts create" }) + const created = run(setup(setupInput("folder", true)), { fail: `service-accounts create ${NAME} ` }) expect(created.status).toBe(0) expect(created.commands).not.toContainEqual(expect.stringContaining(`serviceAccount:${ACCOUNT}`)) expect(created.commands).not.toContainEqual(expect.stringContaining('"metrics"')) @@ -928,16 +956,21 @@ ${UNFINISHED}`) it("sets log forwarding up when an API read-only access needs is off and may not be switched on", () => { const { status, stdout, commands } = run(setup(setupInput("project", true)), { - apis: ["pubsub.googleapis.com", "logging.googleapis.com", "cloudresourcemanager.googleapis.com"], + apis: [ + "pubsub.googleapis.com", + "logging.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + ], lacking: "serviceusage.services.enable", }) expect(status).toBe(0) expect(commands).toContainEqual(publish('"logs":true')) - expect(commands).not.toContainEqual(expect.stringContaining("service-accounts create")) + expect(commands).not.toContainEqual(expect.stringContaining(`service-accounts create ${NAME} `)) expect(stdout) .toContain(` ✗ jane@acme.com is missing permissions on project acme-host: serviceusage.services.enable. - What to do: This script has to switch on: monitoring.googleapis.com cloudasset.googleapis.com iam.googleapis.com iamcredentials.googleapis.com. Ask for Service Usage Admin there, or have an administrator switch them on. + What to do: This script has to switch on: monitoring.googleapis.com cloudasset.googleapis.com iamcredentials.googleapis.com. Ask for Service Usage Admin there, or have an administrator switch them on. `) expect(stdout).toContain("\nDone, except read-only access") }) @@ -1112,8 +1145,8 @@ esac " Read-only access not available on this Maple deployment, left as is\n", ) expect(stdout).not.toContain("\nRead-only access\n") - expect(commands).not.toContainEqual(expect.stringContaining("service-accounts")) - expect(commands).not.toContainEqual(expect.stringContaining(`serviceAccount:${ACCOUNT}`)) + expect(commands).not.toContainEqual(expect.stringContaining(ACCOUNT)) + expect(commands).not.toContainEqual(expect.stringContaining(`service-accounts create ${NAME} `)) // Nothing is said about the account: what an earlier run reported stands. expect(script).not.toContain('"metrics":') } @@ -1129,7 +1162,7 @@ esac // A binding that could not be removed keeps the account, so a later run can still name it. const denied = run(script, { describe: "found", unbind: "denied" }) - expect(denied.commands).not.toContainEqual(expect.stringContaining("service-accounts delete")) + expect(denied.commands).not.toContainEqual(`iam service-accounts delete ${ACCOUNT} --project=acme-host --quiet`) expect(denied.status).toBe(1) expect(denied.stdout).toContain(` ✗ Couldn't remove the read-only roles.`) expect(denied.stdout).toContain( @@ -1148,7 +1181,7 @@ esac fail: "remove-iam-policy-binding", answer: "ERROR: (gcloud) NOT_FOUND: Requested entity was not found.", }) - expect(unknown.commands).not.toContainEqual(expect.stringContaining("service-accounts delete")) + expect(unknown.commands).not.toContainEqual(`iam service-accounts delete ${ACCOUNT} --project=acme-host --quiet`) expect(unknown.status).toBe(1) }) @@ -1212,8 +1245,8 @@ esac }) expect(status).toBe(0) // The new account has none of the deleted one's roles: all four grants are made again. - expect(commands.slice(-readerSetupCommands(SCOPES.folder).length)).toEqual( - readerSetupCommands(SCOPES.folder), + expect(commands.slice(-readerSetupCommands(SCOPES.folder, false).length)).toEqual( + readerSetupCommands(SCOPES.folder, false), ) }) @@ -1248,9 +1281,8 @@ esac env: { GCLOUD_RACE: "add-iam-policy-binding" }, }) expect(granted.status).toBe(0) - expect( - granted.commands.filter((command) => command.includes("roles/logging.logWriter")), - ).toHaveLength(2) + // The first grant of the run: Pub/Sub's, on the push service account. + expect(granted.commands.filter((command) => command === PUSH_GRANT)).toHaveLength(2) expect(granted.stdout).not.toContain("waiting for Google") const removed = run(cleanup(SCOPES.folder), { @@ -1283,7 +1315,7 @@ esac }) it("keeps gcloud from writing its arguments, the secret among them, to its log files", () => { - for (const script of [setup(setupInput("project", true)), cleanup(SCOPES.project, PUSH_ENDPOINT)]) { + for (const script of [setup(setupInput("project", true)), cleanup(SCOPES.project, REPORT_ENDPOINT)]) { const { fileLogging } = run(script, { describe: "found" }) expect(fileLogging.length).toBeGreaterThan(0) expect(new Set(fileLogging)).toEqual(new Set(["1"])) @@ -1305,7 +1337,7 @@ esac const script = cleanup(SCOPES.project) expect(script).not.toMatch(/maple_gcp_[A-Za-z0-9_-]{20,}/) expect(script).not.toContain("/v1/logpush/") - expect(script).not.toContain("PUSH_ENDPOINT=") + expect(script).not.toContain("REPORT_ENDPOINT=") const { status, stdout, curl } = run(script, { describe: "found" }) expect(status).toBe(0) expect(curl).toEqual([]) diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.ts index 5072c8cf83..e4be137177 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.ts @@ -118,6 +118,8 @@ SUBSCRIPTION=${sh(names.subscription)} SINK=${sh(names.sink)} SERVICE_ACCOUNT=${sh(names.serviceAccountId)} SERVICE_ACCOUNT_EMAIL="$SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com" +PUSH_SERVICE_ACCOUNT=${sh(names.pushServiceAccountId)} +PUSH_SERVICE_ACCOUNT_EMAIL="$PUSH_SERVICE_ACCOUNT@$PROJECT_ID.iam.gserviceaccount.com" ${more.map((line) => `${line}\n`).join("")}MAPLE_URL=${sh(target.mapleUrl)} SCOPE="${target.scopeType} $SCOPE_ID" OWNER="Owner on project $PROJECT_ID" @@ -214,7 +216,7 @@ notify() { if ${ throughTopic ? `gcloud pubsub topics publish "$TOPIC" --project="$PROJECT_ID" --message="$(report "$2")"` - : `${POST_REPORT} --data "$(report "$2")" "$PUSH_ENDPOINT"` + : `${POST_REPORT} --data "$(report "$2")" "$REPORT_ENDPOINT"` } >/dev/null 2>&1; then ok "$1" elif [ "$CONFIRMED" = 1 ]; then @@ -253,6 +255,22 @@ api_off() { UNCHECKED=1 } +# account_remains : looked up in the list. For +# 30 days after a service account is deleted, describing it answers PERMISSION_DENIED even to an +# Owner, which would read as "may still be there" on every re-run. A list that fails is read like +# a failed describe. +account_remains() { + local what="$1" output + shift + if output="$("$@" 2>/dev/null)"; then + [ -n "$output" ] + return + fi + output="$("$@" 2>&1 || true)" + case "$output" in *"has not been used"* | *"is disabled"*) api_off "$what"; return 1 ;; esac + stop "Couldn't check whether $what still exists." "$(fix "$output" "$OWNER")" "$output" +} + # remove : # one that is gone already, a moment after it was seen, is fine. remove() { @@ -283,7 +301,12 @@ unbind() { // APIs in the host project. Cloud Resource Manager is what gcloud reads projects and grants // roles through. -const LOG_APIS = ["pubsub.googleapis.com", "logging.googleapis.com", "cloudresourcemanager.googleapis.com"] +const LOG_APIS = [ + "pubsub.googleapis.com", + "logging.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", +] const READER_APIS = [ "monitoring.googleapis.com", "cloudasset.googleapis.com", @@ -436,6 +459,12 @@ const logPermissionChecks = (scopeType: GcpScopeType): AccessChecks["permissions "pubsub.subscriptions.get", "pubsub.subscriptions.create", "pubsub.subscriptions.update", + // The push service account: created, Pub/Sub's agent allowed to sign as it, and attached to + // the subscription, which takes actAs. + "iam.serviceAccounts.get", + "iam.serviceAccounts.create", + "iam.serviceAccounts.setIamPolicy", + "iam.serviceAccounts.actAs", ] const sink = ["logging.sinks.get", "logging.sinks.create", "logging.sinks.update"] const askHost = `Ask for Owner there, ${ADMINISTRATOR}` @@ -465,7 +494,7 @@ const GRANT = ` # grant # Waited out, for a minute at most: a change that raced another one, and a service account Google # has not published yet. Until then it answers that the account does not exist, or for the policy -# of a reader this run created (gcloud iam service-accounts ...), that the caller may not change it. +# of an account this run created (gcloud iam service-accounts ...), that the caller may not change it. WAITED=0 grant() { local failed="$1" role="$2" output tries=0 @@ -474,7 +503,7 @@ grant() { tries=$((tries + 1)) conflict "$output" || case "$output" in *"does not exist"* | *NOT_FOUND*) ;; - *PERMISSION_DENIED*) { [ "$ACCOUNT_CREATED" = 1 ] && [ "$3" = service-accounts ]; } || tries=20 ;; + *PERMISSION_DENIED*) { [ "$NEW_ACCOUNT" = 1 ] && [ "$3" = service-accounts ]; } || tries=20 ;; *) tries=20 ;; esac if [ "$tries" -ge 20 ]; then stop "$failed" "$(fix "$output" "$role")" "$output"; fi @@ -493,8 +522,9 @@ section "Log forwarding" DESTINATION="pubsub.googleapis.com/projects/$PROJECT_ID/topics/$TOPIC" # APIs in ${hostProject(scopeType)}: Pub/Sub carries the log entries, Cloud Logging routes them, -# and Cloud Resource Manager is what gcloud grants the roles below through. -apis "Pub/Sub, Cloud Logging, Cloud Resource Manager" ${LOG_APIS.join(" ")} +# IAM holds the push service account, and Cloud Resource Manager is what gcloud grants the roles +# below through. +apis "Pub/Sub, Cloud Logging, IAM, Cloud Resource Manager" ${LOG_APIS.join(" ")} if exists gcloud pubsub topics describe "$TOPIC" --project="$PROJECT_ID"; then ok "Topic already exists" @@ -503,11 +533,34 @@ else gcloud pubsub topics create "$TOPIC" --project="$PROJECT_ID" fi -# One LogEntry per request, unwrapped. A day of retention bounds what is replayed after an -# outage, and the subscription never expires while it is idle. +# What Pub/Sub signs each push to Maple as. It holds no role anywhere. Maple accepts a push only +# with an ID token for this account, so PUSH_ENDPOINT carries no secret. +if exists gcloud iam service-accounts describe "$PUSH_SERVICE_ACCOUNT_EMAIL" --project="$PROJECT_ID"; then + ok "Push service account already exists" +else + run "Push service account created" "Couldn't create the push service account." "$OWNER" \\ + gcloud iam service-accounts create "$PUSH_SERVICE_ACCOUNT" --project="$PROJECT_ID" \\ + --display-name="Maple log push identity" --description="Signs Pub/Sub push requests to Maple. Holds no roles." + NEW_ACCOUNT=1 +fi +# Pub/Sub's service agent mints those tokens. Projects created after April 2021 allow it by +# default; older ones need this grant. After the topic: Google creates the agent on first use. +PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)' 2>/dev/null || true)" +[ -n "$PROJECT_NUMBER" ] || + stop "Couldn't read the number of project $PROJECT_ID." "Wait a minute and paste the script again." +grant "Couldn't let Pub/Sub sign pushes as the push service account." "$OWNER" \\ + gcloud iam service-accounts add-iam-policy-binding "$PUSH_SERVICE_ACCOUNT_EMAIL" --project="$PROJECT_ID" \\ + --member="serviceAccount:service-$PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com" \\ + --role=roles/iam.serviceAccountTokenCreator --condition=None +ok "Pub/Sub allowed to sign pushes as that account" + +# One LogEntry per request, unwrapped, signed as the push service account for this connection. A +# day of retention bounds what is replayed after an outage, and the subscription never expires +# while it is idle. SUBSCRIPTION_FLAGS=( --project="$PROJECT_ID" --push-endpoint="$PUSH_ENDPOINT" --push-no-wrapper + --push-auth-service-account="$PUSH_SERVICE_ACCOUNT_EMAIL" --push-auth-token-audience="$PUSH_ENDPOINT" --ack-deadline=30 --message-retention-duration=1d --expiration-period=never --min-retry-delay=10s --max-retry-delay=600s ) @@ -584,6 +637,11 @@ if remains "the push subscription" "$OWNER" gcloud pubsub subscriptions describe remove "Push subscription deleted" "Couldn't delete the push subscription." "$OWNER" \\ gcloud pubsub subscriptions delete "$SUBSCRIPTION" --project="$PROJECT_ID" --quiet fi +if account_remains "the push service account" gcloud iam service-accounts list --project="$PROJECT_ID" \\ + --filter="email=$PUSH_SERVICE_ACCOUNT_EMAIL" --format='value(email)'; then + remove "Push service account deleted" "Couldn't delete the push service account." "$OWNER" \\ + gcloud iam service-accounts delete "$PUSH_SERVICE_ACCOUNT_EMAIL" --project="$PROJECT_ID" --quiet +fi if remains "the topic" "$OWNER" gcloud pubsub topics describe "$TOPIC" --project="$PROJECT_ID"; then remove "Topic deleted" "Couldn't delete the Pub/Sub topic." "$OWNER" \\ gcloud pubsub topics delete "$TOPIC" --project="$PROJECT_ID" --quiet @@ -639,6 +697,7 @@ else gcloud iam service-accounts create "$SERVICE_ACCOUNT" --project="$PROJECT_ID" \\ --display-name="Maple metrics and resource reader" ACCOUNT_CREATED=1 + NEW_ACCOUNT=1 fi # Maple's own account may mint short-lived tokens for this one service account, nothing else. @@ -684,20 +743,7 @@ esac const readerRemoval = (scopeType: GcpScopeType): string => ` # ---- Read-only access: remove what a setup run created. ---- section "Read-only access" -# Looked up in the list: for 30 days after a service account is deleted, describing it answers -# PERMISSION_DENIED even to an Owner, which would read as "may still be there" on every re-run. -# A list that fails is read like a failed describe. -account_remains() { - local output - if output="$("$@" 2>/dev/null)"; then - [ -n "$output" ] - return - fi - output="$("$@" 2>&1 || true)" - case "$output" in *"has not been used"* | *"is disabled"*) api_off "the read-only service account"; return 1 ;; esac - stop "Couldn't check whether the read-only service account still exists." "$(fix "$output" "$OWNER")" "$output" -} -if account_remains gcloud iam service-accounts list --project="$PROJECT_ID" \\ +if account_remains "the read-only service account" gcloud iam service-accounts list --project="$PROJECT_ID" \\ --filter="email=$SERVICE_ACCOUNT_EMAIL" --format='value(email)'; then # Role bindings first: once the account is deleted they can no longer be removed by name. for ROLE in roles/monitoring.viewer roles/cloudasset.viewer; do @@ -743,8 +789,10 @@ ${body}${marker} ) export interface GcpSetupScriptInput extends GcpScriptTarget { - /** The ingest gateway's receiver URL for this connector, including its secret. */ + /** The ingest gateway's receiver URL for this connector, and the audience of its push tokens. */ readonly pushEndpoint: string + /** The receiver URL with the connector's secret: the script's own reports go there directly. */ + readonly reportEndpoint: string /** Maple's own Google service account. Undefined: the read-only access is left as it is. */ readonly mapleServiceAccountEmail: string | undefined readonly logsEnabled: boolean @@ -774,7 +822,7 @@ export const renderGcpSetupScript = (input: GcpSetupScriptInput) => { # Log forwarding: ${logs ? "on" : "off"} # Read-only access: ${readerState} # Safe to run again: it sets up what is on and removes log forwarding when it is off. -# Keep it private: PUSH_ENDPOINT contains this connection's secret. +# Keep it private: REPORT_ENDPOINT contains this connection's secret. Google Cloud never gets it. set -euo pipefail # gcloud would log every argument, the secret too, and the access token to ~/.config/gcloud/logs. export CLOUDSDK_CORE_DISABLE_PROMPTS=1 CLOUDSDK_CORE_DISABLE_FILE_LOGGING=1 @@ -804,10 +852,12 @@ LOG_FILTER=${sh(gcpLogFilter(applicationLogs))} : "" }${variables(input, [ `PUSH_ENDPOINT=${sh(input.pushEndpoint)}`, + `REPORT_ENDPOINT=${sh(input.reportEndpoint)}`, ...(maple === undefined ? [] : [`MAPLE_SERVICE_ACCOUNT=${sh(maple)}`]), ])}SINK_CREATED=0 FILTER_REPLACED=0 ACCOUNT_CREATED=0 +NEW_ACCOUNT=0 REMOVED=0 READER_FAILED=0 ${output( @@ -839,13 +889,15 @@ missing() { # apis : switches on the ones the access check found off. apis() { - local names="$1" missing + local names="$1" missing api shift missing="$(missing "$@")" if [ -n "$missing" ]; then # Unquoted on purpose: one argument per API. try "Couldn't switch on:$missing." "Service Usage Admin on project $PROJECT_ID" \\ gcloud services enable $missing --project="$PROJECT_ID" + # A later section that needs one of them too finds it on. + for api in $missing; do APIS_OFF="\${APIS_OFF/ $api/}"; done fi ok "APIs enabled ($names)" } @@ -880,22 +932,22 @@ if [ "$ACCOUNT_CREATED" = 1 ]; then printf ' Metrics: Maple can query them with } export interface GcpCleanupScriptInput extends GcpScriptTarget { - /** Set while the connector exists: the script then tells Maple that it ran. */ - readonly pushEndpoint: string | undefined + /** Set while the connector exists, with its secret: the script then tells Maple that it ran. */ + readonly reportEndpoint: string | undefined } -/** Removes what the setup script created. Without a push endpoint it carries no secret. */ +/** Removes what the setup script created. Without a report endpoint it carries no secret. */ export const renderGcpCleanupScript = (input: GcpCleanupScriptInput) => forPaste( "MAPLE_CLEANUP_SCRIPT", `#!/usr/bin/env bash # Maple cleanup for Google Cloud ${input.scopeType} ${input.scopeId} # Removes what the Maple setup script created. The APIs it switched on stay on. -# Safe to run again.${input.pushEndpoint === undefined ? "" : "\n# Keep it private: PUSH_ENDPOINT contains this connection's secret."} +# Safe to run again.${input.reportEndpoint === undefined ? "" : "\n# Keep it private: REPORT_ENDPOINT contains this connection's secret."} set -euo pipefail # gcloud would log every argument, the secret too, and the access token to ~/.config/gcloud/logs. export CLOUDSDK_CORE_DISABLE_PROMPTS=1 CLOUDSDK_CORE_DISABLE_FILE_LOGGING=1 -${variables(input, input.pushEndpoint === undefined ? [] : [`PUSH_ENDPOINT=${sh(input.pushEndpoint)}`])}REMOVED=0 +${variables(input, input.reportEndpoint === undefined ? [] : [`REPORT_ENDPOINT=${sh(input.reportEndpoint)}`])}REMOVED=0 ANY_API_OFF_MATTERS=1 ${output( "Cleanup did not finish. Fix this and paste the script again: it continues where it stopped.", @@ -903,11 +955,11 @@ ${output( )}${REMOVAL_HELPERS} printf 'Maple cleanup for %s\\n' "$SCOPE" ${access(input.scopeType, { apis: [], sink: undefined, permissions: [] })}${logsRemoval(input.scopeType, false)}${readerRemoval(input.scopeType)}${ - input.pushEndpoint === undefined + input.reportEndpoint === undefined ? "" : ` # Tells Maple the cleanup ran. Maple refuses this once the connection is deleted, which is fine. -${POST_REPORT} --data "$(report '"logs":false,"metrics":false')" "$PUSH_ENDPOINT" >/dev/null 2>&1 || true +${POST_REPORT} --data "$(report '"logs":false,"metrics":false')" "$REPORT_ENDPOINT" >/dev/null 2>&1 || true ` } if [ "$UNCHECKED" = 1 ]; then @@ -917,5 +969,5 @@ else fi printf ' Left in place: the APIs it switched on, and the Logs Writer role of Google'"'"'s logging\\n' printf ' service account on %s, which other sinks share.\\n' "$PROJECT_ID" -${input.pushEndpoint === undefined ? "" : `printf ' Now disconnect in Maple: %s\\n' "$MAPLE_URL"\n`}`, +${input.reportEndpoint === undefined ? "" : `printf ' Now disconnect in Maple: %s\\n' "$MAPLE_URL"\n`}`, ) diff --git a/packages/backend/src/services/integrations/gcp/terraform.test.ts b/packages/backend/src/services/integrations/gcp/terraform.test.ts new file mode 100644 index 0000000000..40ebefb4ed --- /dev/null +++ b/packages/backend/src/services/integrations/gcp/terraform.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { GcpConnectorId, GcpProjectId, GcpResourceNumber } from "@maple/domain/primitives" +import { gcpLogFilter } from "./setup-scripts" +import { renderGcpTerraform, type GcpTerraformInput } from "./terraform" + +const connectorId = Schema.decodeUnknownSync(GcpConnectorId)("018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") +const PUSH_ENDPOINT = `https://ingest.test/v1/logpush/gcp/${connectorId}` + +const input: GcpTerraformInput = { + connectorId, + scopeType: "organization", + scopeId: Schema.decodeUnknownSync(GcpResourceNumber)("123456789012"), + projectId: Schema.decodeUnknownSync(GcpProjectId)("acme-host"), + pushEndpoint: PUSH_ENDPOINT, + mapleServiceAccountEmail: "collector@maple-prod.iam.gserviceaccount.com", + logsEnabled: true, + applicationLogs: null, +} + +/** The value an attribute is set to, read back as HCL would. */ +const attribute = (rendered: string, name: string) => { + const literal = new RegExp(`^ {2}${name} += (.+)$`, "m").exec(rendered)?.[1] + return literal === undefined ? undefined : (JSON.parse(literal) as unknown) +} + +describe("renderGcpTerraform", () => { + it("sets every module input from the connection", () => { + const rendered = renderGcpTerraform(input) + expect(rendered).toContain('module "maple_gcp_123456789012" {\n') + expect(attribute(rendered, "connector_id")).toBe(connectorId) + expect(attribute(rendered, "scope_type")).toBe("organization") + expect(attribute(rendered, "scope_id")).toBe("123456789012") + expect(attribute(rendered, "project_id")).toBe("acme-host") + expect(attribute(rendered, "push_endpoint")).toBe(PUSH_ENDPOINT) + expect(attribute(rendered, "maple_service_account")).toBe("collector@maple-prod.iam.gserviceaccount.com") + expect(attribute(rendered, "logs_enabled")).toBe(true) + }) + + it("writes the log filter as a literal HCL reads back unchanged", () => { + const rendered = renderGcpTerraform({ ...input, applicationLogs: [] }) + // Quotes are escaped, so JSON reads the literal back. No `${` in Maple's filter needs it yet. + expect(attribute(rendered, "log_filter")).toBe(gcpLogFilter([])) + }) + + it("escapes what HCL would read as a template", () => { + const rendered = renderGcpTerraform({ + ...input, + mapleServiceAccountEmail: 'a"${b}%{c}\\d', + }) + expect(rendered).toContain('maple_service_account = "a\\"$${b}%%{c}\\\\d"\n') + }) + + it("leaves read-only access out without Maple's account, and says nothing secret", () => { + const rendered = renderGcpTerraform({ ...input, mapleServiceAccountEmail: undefined, logsEnabled: false }) + expect(rendered).not.toContain("maple_service_account") + expect(attribute(rendered, "logs_enabled")).toBe(false) + expect(rendered).not.toContain("secret=") + }) +}) diff --git a/packages/backend/src/services/integrations/gcp/terraform.ts b/packages/backend/src/services/integrations/gcp/terraform.ts new file mode 100644 index 0000000000..8119b9db70 --- /dev/null +++ b/packages/backend/src/services/integrations/gcp/terraform.ts @@ -0,0 +1,62 @@ +import type { GcpLogRuntime } from "@maple/domain/primitives" +import { gcpLogFilter, type GcpScriptTarget } from "./setup-scripts" + +/** + * The module the configuration calls: `deploy/terraform-google-maple` in Maple's public repository, + * pinned to a release tag so a plan never changes under an administrator's feet. + */ +export const GCP_TERRAFORM_MODULE_SOURCE = + "github.com/MapleTechLabs/maple//deploy/terraform-google-maple?ref=terraform-google-maple-v1.0.0" +export const GCP_TERRAFORM_MODULE_URL = + "https://github.com/MapleTechLabs/maple/tree/terraform-google-maple-v1.0.0/deploy/terraform-google-maple" + +/** An HCL string literal: `${` and `%{` would start a template, a newline would end the line. */ +const hcl = (value: string): string => + `"${value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + // A function: in a replacement string, `$$` is itself an escape for one `$`. + .replaceAll("${", () => "$${") + .replaceAll("%{", "%%{")}"` + +export interface GcpTerraformInput extends Omit { + /** The receiver URL without a secret: the push tokens' audience. */ + readonly pushEndpoint: string + /** Maple's own Google service account. Undefined: the module leaves read-only access out. */ + readonly mapleServiceAccountEmail: string | undefined + readonly logsEnabled: boolean + /** Terraform always sets the sink's filter: null is the filter a new sink gets. */ + readonly applicationLogs: ReadonlyArray | null +} + +/** + * A module block for the administrator's own Terraform: the same resources the setup script + * creates, named the same, with push requests signed by Google instead of carrying a secret. It + * holds nothing private, so it can be committed. + */ +export const renderGcpTerraform = (input: GcpTerraformInput): string => { + const attributes: ReadonlyArray = [ + ["connector_id", hcl(input.connectorId)], + ["scope_type", hcl(input.scopeType)], + ["scope_id", hcl(input.scopeId)], + ["project_id", hcl(input.projectId)], + ["push_endpoint", hcl(input.pushEndpoint)], + ...(input.mapleServiceAccountEmail === undefined + ? [] + : [["maple_service_account", hcl(input.mapleServiceAccountEmail)] as const]), + ["logs_enabled", String(input.logsEnabled)], + ["log_filter", hcl(gcpLogFilter(input.applicationLogs ?? []))], + ] + // `terraform fmt` aligns the equals signs of consecutive attributes. + const width = Math.max(...attributes.map(([name]) => name.length)) + const label = `maple_gcp_${input.scopeId.replaceAll(/[^a-z0-9]/g, "_")}` + return `# Maple connection for Google Cloud ${input.scopeType} ${input.scopeId}. It holds no secret. +# What it creates: ${GCP_TERRAFORM_MODULE_URL} +module "${label}" { + source = ${hcl(GCP_TERRAFORM_MODULE_SOURCE)} + +${attributes.map(([name, value]) => ` ${name.padEnd(width)} = ${value}`).join("\n")} +} +` +} diff --git a/packages/domain/src/gcp.test.ts b/packages/domain/src/gcp.test.ts index 502ea6aa0f..05f2130827 100644 --- a/packages/domain/src/gcp.test.ts +++ b/packages/domain/src/gcp.test.ts @@ -13,8 +13,10 @@ describe("gcpConnectorResourceNames", () => { topic: "maple-018f2b3c4d5e4f708192a3b4", subscription: "maple-018f2b3c4d5e4f708192a3b4", sink: "maple-018f2b3c4d5e4f708192a3b4", + pushServiceAccountId: "maple-push-018f2b3c4d5e4f70819", }) // Google: 6-30 characters, [a-z]([-a-z0-9]*[a-z0-9]). expect(names.serviceAccountId).toMatch(/^[a-z][-a-z0-9]{4,28}[a-z0-9]$/) + expect(names.pushServiceAccountId).toMatch(/^[a-z][-a-z0-9]{4,28}[a-z0-9]$/) }) }) diff --git a/packages/domain/src/gcp.ts b/packages/domain/src/gcp.ts index 0eee9d3a69..3eb2802f20 100644 --- a/packages/domain/src/gcp.ts +++ b/packages/domain/src/gcp.ts @@ -7,6 +7,16 @@ import type { GcpConnectorId } from "./primitives" * the service account id at Google's 30-character limit. */ export const gcpConnectorResourceNames = (connectorId: GcpConnectorId) => { - const name = `maple-${connectorId.replaceAll("-", "").slice(0, 24)}` - return { serviceAccountId: name, topic: name, subscription: name, sink: name } + const hex = connectorId.replaceAll("-", "") + const name = `maple-${hex.slice(0, 24)}` + return { + serviceAccountId: name, + topic: name, + subscription: name, + sink: name, + // The identity the push subscription signs its requests as. It holds no role. The ingest + // gateway accepts a push only with this account's email for the connector's host project, + // and an audience naming the connector in full, so the shorter suffix is enough. + pushServiceAccountId: `maple-push-${hex.slice(0, 19)}`, + } } From 9ed7fd8615bae12036c2284475bac4fc4a3b3c60 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 19:56:55 +0200 Subject: [PATCH 2/5] feat(gcp): Terraform configuration and access check over the v2 API setup_scripts now also returns the Terraform module block. check_access signs in as the connection's read-only account and records it as in place, which a Terraform run cannot report the way the setup script does. --- .../routes/v2/integrations-gcp.http.test.ts | 20 ++++- .../src/routes/v2/integrations-gcp.http.ts | 17 +++- apps/api/src/routes/v2/v2-test-support.ts | 5 +- .../integrations/GcpPromQlService.test.ts | 80 +++++++++++++++++++ .../services/integrations/GcpPromQlService.ts | 70 +++++++++++++++- .../domain/src/http/v2/integrations-gcp.ts | 41 ++++++++++ packages/domain/src/http/v2/openapi.test.ts | 1 + 7 files changed, 227 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.test.ts b/apps/api/src/routes/v2/integrations-gcp.http.test.ts index afb28127b8..af40631462 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.test.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.test.ts @@ -216,11 +216,19 @@ describe("v2 gcp integration over HTTP", () => { application_logs: ["cloud_run", "cloud_functions", "app_engine", "gke"], }) expect(both.status).toBe(200) - expect(Object.keys(both.body).sort()).toEqual(["cleanup_script", "object", "setup_script"]) + expect(Object.keys(both.body).sort()).toEqual(["cleanup_script", "object", "setup_script", "terraform"]) expect(both.body.object).toBe("gcp_connector.setup_scripts") + // The subscription is given no secret; only the script's own reports carry it. expect(both.body.setup_script).toMatch( - /PUSH_ENDPOINT='https:\/\/ingest\.test\/v1\/logpush\/gcp\/[0-9a-f-]{36}\?secret=maple_gcp_/, + /PUSH_ENDPOINT='https:\/\/ingest\.test\/v1\/logpush\/gcp\/[0-9a-f-]{36}'\n/, ) + expect(both.body.setup_script).toMatch( + /REPORT_ENDPOINT='https:\/\/ingest\.test\/v1\/logpush\/gcp\/[0-9a-f-]{36}\?secret=maple_gcp_/, + ) + expect(both.body.terraform).toMatch(/^ {2}scope_type += "folder"$/m) + expect(both.body.terraform).toMatch(/^ {2}project_id += "acme-host"$/m) + expect(both.body.terraform).toContain('NOT protoPayload.methodName=\\"io.k8s.coordination.v1.leases.update\\"') + expect(both.body.terraform).not.toMatch(/maple_gcp_[A-Za-z0-9_-]{32}|secret=/) expect(both.body.setup_script).toContain("PROJECT_ID='acme-host'") expect(both.body.setup_script).toContain("SCOPE_ID='123456789012'") expect(both.body.setup_script).toContain('--folder="$SCOPE_ID" --include-children') @@ -262,7 +270,7 @@ describe("v2 gcp integration over HTTP", () => { expect(reader.body.setup_script).toContain( "MAPLE_SERVICE_ACCOUNT='collector@maple-prod.iam.gserviceaccount.com'", ) - expect(reader.body.setup_script).not.toContain("service-accounts delete") + expect(reader.body.setup_script).not.toContain('service-accounts delete "$SERVICE_ACCOUNT_EMAIL"') await harness.dispose() }) @@ -295,6 +303,11 @@ describe("v2 gcp integration over HTTP", () => { const turnOff = await harness.request("PATCH", path, admin.secret, { logs_enabled: false }) expect(turnOff.status).toBe(400) expect(turnOff.body.error.code).toBe("integration_request_invalid") + + // Without a Google identity there is no account to read as. + const check = await harness.request("POST", `${path}/check_access`, admin.secret) + expect(check.status).toBe(409) + expect(check.body.error.code).toBe("gcp_metrics_unavailable") await harness.dispose() }) @@ -310,6 +323,7 @@ describe("v2 gcp integration over HTTP", () => { ["POST", CONNECTORS, { scope_type: "project", scope_id: "other-project" }], ["PATCH", path, { metrics_enabled: true }], ["POST", `${path}/setup_scripts`, {}], + ["POST", `${path}/check_access`, undefined], ["DELETE", path, undefined], ] as const) { const asMember = await harness.request(method, target, member.secret, body) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.ts b/apps/api/src/routes/v2/integrations-gcp.http.ts index dbfb87d6ec..4b6837a1cb 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.ts @@ -1,5 +1,5 @@ import { HttpApiBuilder } from "effect/http-api" -import { CurrentTenant } from "@maple/domain/http" +import { CurrentTenant, GcpMetricsUnavailableError } from "@maple/domain/http" import type { V2GcpConnector } from "@maple/domain/http/v2" import { MapleApiV2, @@ -14,6 +14,7 @@ import { GcpConnectorService, type GcpConnector, } from "@maple/backend/services/integrations/GcpConnectorService" +import { GcpPromQlService } from "@maple/backend/services/integrations/GcpPromQlService" // Reading the status is open to every member. Everything else is admin-only: a connector decides // what may write logs into the org and what Maple reads, and its setup script carries the push @@ -51,6 +52,7 @@ const auditMetadata = (connector: GcpConnector) => ({ export const HttpV2GcpIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "gcpIntegration", (handlers) => Effect.gen(function* () { const gcp = yield* GcpConnectorService + const gcpPromQl = yield* GcpPromQlService return handlers .handle("status", () => @@ -109,9 +111,22 @@ export const HttpV2GcpIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "gcpIn object: "gcp_connector.setup_scripts" as const, setup_script: scripts.setupScript, cleanup_script: scripts.cleanupScript, + terraform: scripts.terraform, } }), ) + .handle("checkAccess", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const checked = yield* gcpPromQl.checkAccess(tenant.orgId, params.id).pipe( + Effect.catchTag("@maple/http/errors/GcpPromQlUnavailableError", (error) => + Effect.fail(new GcpMetricsUnavailableError({ message: error.message })), + ), + ) + return { object: "gcp_connector.access_check" as const, ...checked } + }), + ) .handle("deleteConnector", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 56033f7649..5c6a254f3b 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -45,6 +45,7 @@ import { HttpV2ChatIntegrationsLive } from "./integrations-chat.http" import { HttpV2GcpIntegrationsLive } from "./integrations-gcp.http" import { HttpV2PlanetScaleIntegrationsLive } from "./integrations.http" import { GcpConnectorService } from "@maple/backend/services/integrations/GcpConnectorService" +import { GcpPromQlService } from "@maple/backend/services/integrations/GcpPromQlService" import { HttpV2ErrorIssuesLive } from "./error-issues.http" import { HttpV2AnomaliesLive } from "./anomalies.http" import { HttpV2InvestigationsLive } from "./investigations.http" @@ -158,7 +159,9 @@ const v2GroupLayersExceptOnboardingChecklist = (chatWorkspace: Layer.Layer { }, ) }) + +describe("GcpPromQlService.checkAccess", () => { + const checkAccess = (connectorId: string) => + GcpPromQlService.use((gcp) => gcp.checkAccess(ORG, connectorId as never)) + + it.effect("records the read-only account as in place once the scope reads, so live queries use it", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + return run( + testDb, + stubFetch(calls, { + folderProjects: FOLDER_PROJECTS, + promql: (call) => matrix({ metric: { project_id: call.project }, values: [[T0, "1"]] }), + }), + Effect.gen(function* () { + yield* insertConnector(testDb, FOLDER_CONNECTOR, { + projectId: "acme-host", + folder: "123", + accountInPlace: null, + }) + const before = yield* Effect.flip(queryRange(range("up"))) + assert.strictEqual(before._tag, "@maple/http/errors/GcpPromQlFailedError") + + assert.deepStrictEqual(yield* checkAccess(FOLDER_CONNECTOR), { ok: true, message: null }) + assert.deepStrictEqual(calls.impersonate, [FOLDER_READER]) + assert.strictEqual(calls.assets.length, 1) + assert.include(calls.assets[0]!, "/v1/folders/123:searchAllResources") + + const after = yield* queryRange(range("up")) + assert.deepStrictEqual>(after.projects, ["acme-data", "acme-shop"]) + }), + ) + }) + + it.effect("says what is missing when the account can't read, and records nothing", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + return run( + testDb, + stubFetch(calls, { impersonate: () => denied() }), + Effect.gen(function* () { + yield* insertConnector(testDb, PROJECT_CONNECTOR, { accountInPlace: null }) + const checked = yield* checkAccess(PROJECT_CONNECTOR) + assert.isFalse(checked.ok) + assert.isString(checked.message) + + const still = yield* Effect.flip(queryRange(range("up"))) + assert.include(still.message, "Run the setup script again: it creates the account.") + }), + ) + }) + + it.effect("answers not found for another organization's connection, unavailable without a key", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + return Effect.gen(function* () { + const missing = yield* run( + testDb, + stubFetch(calls), + Effect.gen(function* () { + yield* insertConnector(testDb, OTHER_CONNECTOR, { orgId: OTHER_ORG, projectId: "rival-prod" }) + return yield* Effect.flip(checkAccess(OTHER_CONNECTOR)) + }), + ) + assert.strictEqual(missing._tag, "@maple/http/errors/IntegrationsNotFoundError") + + const unavailable = yield* run( + testDb, + stubFetch(calls), + Effect.gen(function* () { + yield* insertConnector(testDb, PROJECT_CONNECTOR) + return yield* Effect.flip(checkAccess(PROJECT_CONNECTOR)) + }), + { key: null }, + ) + assert.strictEqual(unavailable._tag, "@maple/http/errors/GcpPromQlUnavailableError") + assert.deepStrictEqual(calls.impersonate, []) + }) + }) +}) diff --git a/packages/backend/src/services/integrations/GcpPromQlService.ts b/packages/backend/src/services/integrations/GcpPromQlService.ts index eaabf193f1..0935213782 100644 --- a/packages/backend/src/services/integrations/GcpPromQlService.ts +++ b/packages/backend/src/services/integrations/GcpPromQlService.ts @@ -30,7 +30,7 @@ import { IntegrationsUpstreamError, IntegrationsValidationError, } from "@maple/domain/http" -import { GcpProjectId, type OrgId } from "@maple/domain/primitives" +import { type GcpConnectorId, GcpProjectId, type OrgId } from "@maple/domain/primitives" import { Clock, Context, Effect, Layer, Option, Redacted, Schema } from "effect" import { FetchHttpClient, HttpClient } from "effect/http" import { Database } from "@maple/backend/platform/DatabaseLive" @@ -112,6 +112,17 @@ export interface GcpPromQlServiceApi { orgId: OrgId, request: { readonly type: string; readonly project?: GcpProjectId | undefined }, ) => Effect.Effect + /** + * Reads one page of a connection's projects as its read-only account. Success records the + * account as in place, as a setup script's report does: Terraform sends no report. + */ + readonly checkAccess: ( + orgId: OrgId, + connectorId: GcpConnectorId, + ) => Effect.Effect< + { readonly ok: boolean; readonly message: string | null }, + GcpPromQlUnavailableError | IntegrationsNotFoundError | IntegrationsPersistenceError + > } const toPersistenceError = makePersistenceErrorMapper( @@ -608,7 +619,62 @@ export class GcpPromQlService extends Context.Service + db.run( + PG.from(GcpConnectors) + .select("id", "scopeType", "scopeId", "projectId", "lastMetricsReceivedAt") + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectorId)]) + .limit(1), + ), + ) + if (connector === undefined) { + return yield* new IntegrationsNotFoundError({ message: "No such Google Cloud connector." }) + } + const key = yield* signingKey + // A cached reader would answer for an account that was removed since. + readers.delete(connector.id) + const checked = yield* readerFor(key, connector).pipe( + Effect.flatMap((reader) => + searchProjectsPage( + httpClient, + reader, + `${connector.scopeType}s/${encodeURIComponent(connector.scopeId)}`, + undefined, + ), + ), + Effect.as({ ok: true, message: null }), + Effect.catchTags({ + "@maple/api/integrations/GcpApiError": (error) => + Effect.succeed({ ok: false, message: describeGcpApiError(error, connector, true) }), + "@maple/http/errors/IntegrationsUpstreamError": (error) => + Effect.succeed({ ok: false, message: `${error.message}. Try again in a minute.` }), + }), + ) + if (checked.ok) { + const now = yield* Clock.currentTimeMillis + yield* dbExecute((db) => + db.run( + PG.update(GcpConnectors) + .set({ appliedMetricsEnabled: true, setupReportedAt: now, updatedAt: now }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectorId)]), + ), + ) + } + return checked + }) + + return { + queryRange, + queryInstant, + searchMetrics, + describeMetric, + checkAccess, + } satisfies GcpPromQlServiceApi }), }, ) { diff --git a/packages/domain/src/http/v2/integrations-gcp.ts b/packages/domain/src/http/v2/integrations-gcp.ts index 4240765c72..370df49172 100644 --- a/packages/domain/src/http/v2/integrations-gcp.ts +++ b/packages/domain/src/http/v2/integrations-gcp.ts @@ -236,6 +236,8 @@ export type V2GcpSetupScriptsRequest = Schema.Schema.Type +export const V2GcpAccessCheck = Schema.Struct({ + object: Schema.Literal("gcp_connector.access_check").annotate({ + description: 'The object type — always `"gcp_connector.access_check"`.', + }), + ok: Schema.Boolean.annotate({ + description: + "Whether Maple could read the scope as the connector's read-only account. `true` records the account as in place, as a setup script's report would.", + }), + message: Schema.NullOr(Schema.String).annotate({ + description: "Why the check failed and what to do about it. `null` when `ok` is `true`.", + }), +}).annotate({ + identifier: "GcpAccessCheck", + title: "Google Cloud access check", + description: "The outcome of Maple reading a connector's scope as its read-only account.", + examples: [wireExample({ object: "gcp_connector.access_check", ok: true, message: null })], +}) +export type V2GcpAccessCheck = Schema.Schema.Type + export const V2GcpConnectorDeleteResponse = Schema.Struct({ id: GcpConnectorPublicId, object: Schema.Literal("gcp_connector").annotate({ @@ -372,6 +399,20 @@ export class V2GcpIntegrationsApiGroup extends HttpApiGroup.make("gcpIntegration }), ), ) + .add( + HttpApiEndpoint.post("checkAccess", "/connectors/:id/check_access", { + params: { id: GcpConnectorPublicId }, + success: V2GcpAccessCheck, + error: [V2InsufficientPermissions.schema, connectorNotFound, metricsUnavailable, integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "checkGcpConnectorAccess", + summary: "Check a connector's read-only access", + description: + "Signs in as the connector's read-only service account and lists one page of the scope's projects. On success Maple records the account as in place, which live metric queries wait for. Use it after setting a connector up with Terraform, which sends Maple no report. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) .add( HttpApiEndpoint.delete("deleteConnector", "/connectors/:id", { params: { id: GcpConnectorPublicId }, diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 24031e89b4..8ccebada16 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -189,6 +189,7 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/integrations/chat_connectors/{connector}/identity/link", "POST /v2/integrations/chat_connectors/{connector}/install", "POST /v2/integrations/gcp/connectors", + "POST /v2/integrations/gcp/connectors/{id}/check_access", "POST /v2/integrations/gcp/connectors/{id}/setup_scripts", "POST /v2/integrations/planetscale/connect", "POST /v2/integrations/planetscale/events", From cf7824ec686c48b4a3aefd1bf0fd4786b895f807 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 20:05:48 +0200 Subject: [PATCH 3/5] feat(ingest): accept GCP pushes Pub/Sub signs as the connection's push account A push carrying a Google ID token is accepted when Google signed it for the connector's push service account in its host project and for an audience naming the connector. The first such push to a connector nothing has reported on (Terraform sends no report) records log forwarding as set up. Pushes with the URL secret keep working for subscriptions an earlier script created. --- apps/ingest/Cargo.lock | 1 + apps/ingest/Cargo.toml | 3 + apps/ingest/src/gcp_push_auth.rs | 437 ++++++++++++++++++ apps/ingest/src/lib.rs | 1 + apps/ingest/src/main.rs | 429 +++++++++++++++-- apps/ingest/src/testdata/gcp-push-test-key.n | 1 + .../ingest/src/testdata/gcp-push-test-key.pk8 | Bin 0 -> 1216 bytes 7 files changed, 846 insertions(+), 26 deletions(-) create mode 100644 apps/ingest/src/gcp_push_auth.rs create mode 100644 apps/ingest/src/testdata/gcp-push-test-key.n create mode 100644 apps/ingest/src/testdata/gcp-push-test-key.pk8 diff --git a/apps/ingest/Cargo.lock b/apps/ingest/Cargo.lock index 6155738101..a11dfc44a9 100644 --- a/apps/ingest/Cargo.lock +++ b/apps/ingest/Cargo.lock @@ -1590,6 +1590,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", + "ring", "rustls", "serde", "serde_json", diff --git a/apps/ingest/Cargo.toml b/apps/ingest/Cargo.toml index a04dc0151e..0157dd8e76 100644 --- a/apps/ingest/Cargo.toml +++ b/apps/ingest/Cargo.toml @@ -60,6 +60,9 @@ tokio-postgres = "0.7" deadpool-postgres = "0.14" tokio-postgres-rustls = "0.13" rustls = { version = "0.23", features = ["ring"] } +# RS256 signatures of the ID tokens Pub/Sub signs its GCP pushes with. Already +# in the tree through rustls. +ring = "0.17" webpki-roots = "0.26" # Opt-in profiler (https://hotpath.rs). Every macro is a no-op and no profiler # code is compiled unless `--features hotpath` is passed; the `tokio` and diff --git a/apps/ingest/src/gcp_push_auth.rs b/apps/ingest/src/gcp_push_auth.rs new file mode 100644 index 0000000000..f3383aa5a0 --- /dev/null +++ b/apps/ingest/src/gcp_push_auth.rs @@ -0,0 +1,437 @@ +//! Pub/Sub push authentication. A push subscription set up with a service +//! account sends `Authorization: Bearer `, signed by Google, naming +//! that account (`email`) and the audience the subscription was given (`aud`). +//! Only someone who may act as the account can make Pub/Sub sign as it, so the +//! token stands in for a secret in the push URL. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use ring::signature::{RsaPublicKeyComponents, RSA_PKCS1_2048_8192_SHA256}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use tokio::sync::RwLock; + +use crate::telemetry::HttpClient; + +const GOOGLE_CERTS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs"; +const GOOGLE_ISSUERS: [&str; 2] = ["https://accounts.google.com", "accounts.google.com"]; +/// Tolerated difference between Google's clock and this host's. +const CLOCK_SKEW_SECS: u64 = 60; +/// Google publishes a signing key well before it uses it, and answers with a +/// `max-age` of several hours. An hour keeps rotation far inside that. +const KEYS_TTL: Duration = Duration::from_hours(1); +/// A token naming a key not in hand fetches the keys again at most this often, +/// so made-up key ids can't turn every push into a fetch. +const REFETCH_INTERVAL: Duration = Duration::from_mins(1); + +/// An RSA public key, as the big-endian bytes of its modulus and exponent. +#[derive(Clone, Debug)] +pub struct RsaKey { + pub n: Vec, + pub e: Vec, +} + +/// Google's signing keys by key id. +pub type Keys = HashMap; + +/// What a valid token says. +#[derive(Debug, PartialEq, Eq)] +pub struct VerifiedPush { + /// The service account the subscription signs as. + pub email: String, + pub audience: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum TokenError { + /// Signed with a key not among those given. + UnknownKey, + /// Never a token to accept: malformed, forged, expired, not Google's. + Invalid(&'static str), +} + +#[derive(Debug)] +pub enum PushAuthError { + /// The push is refused. + Invalid(&'static str), + /// Google's keys could not be fetched: the push is not to blame and is + /// worth delivering again. + KeysUnavailable(String), +} + +#[derive(Deserialize)] +struct Header { + alg: String, + kid: Option, +} + +#[derive(Deserialize)] +struct Claims { + iss: String, + aud: String, + exp: u64, + #[serde(default)] + iat: u64, + email: Option, + #[serde(default)] + email_verified: bool, +} + +#[derive(Deserialize)] +struct Jwk { + kid: String, + n: String, + e: String, +} + +#[derive(Deserialize)] +struct JwkSet { + keys: Vec, +} + +fn decode_json(segment: &str) -> Option { + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).ok()?).ok() +} + +/// Checks a token's RS256 signature against `keys`, and its claims at +/// `now_secs`. The algorithm is fixed: a token that names another is refused. +pub fn verify_with_keys( + token: &str, + keys: &Keys, + now_secs: u64, +) -> Result { + let invalid = TokenError::Invalid; + let mut segments = token.split('.'); + let (Some(header), Some(claims), Some(signature), None) = ( + segments.next(), + segments.next(), + segments.next(), + segments.next(), + ) else { + return Err(invalid("not a JWT")); + }; + let parsed: Header = decode_json(header).ok_or(invalid("malformed header"))?; + if parsed.alg != "RS256" { + return Err(invalid("unsupported algorithm")); + } + let key = parsed + .kid + .as_deref() + .and_then(|kid| keys.get(kid)) + .ok_or(TokenError::UnknownKey)?; + let signature = URL_SAFE_NO_PAD + .decode(signature) + .map_err(|_| invalid("malformed signature"))?; + let signed = &token[..header.len() + 1 + claims.len()]; + RsaPublicKeyComponents { + n: &key.n, + e: &key.e, + } + .verify(&RSA_PKCS1_2048_8192_SHA256, signed.as_bytes(), &signature) + .map_err(|_| invalid("bad signature"))?; + + let claims: Claims = decode_json(claims).ok_or(invalid("malformed claims"))?; + if !GOOGLE_ISSUERS.contains(&claims.iss.as_str()) { + return Err(invalid("not issued by Google")); + } + if claims.exp.saturating_add(CLOCK_SKEW_SECS) < now_secs { + return Err(invalid("expired")); + } + if claims.iat > now_secs.saturating_add(CLOCK_SKEW_SECS) { + return Err(invalid("issued in the future")); + } + let email = claims + .email + .filter(|_| claims.email_verified) + .ok_or(invalid("no verified email"))?; + Ok(VerifiedPush { + email, + audience: claims.aud, + }) +} + +fn parse_keys(body: &[u8]) -> Result { + let set: JwkSet = serde_json::from_slice(body).map_err(|error| error.to_string())?; + set.keys + .into_iter() + .map(|jwk| { + let n = URL_SAFE_NO_PAD + .decode(&jwk.n) + .map_err(|error| error.to_string())?; + let e = URL_SAFE_NO_PAD + .decode(&jwk.e) + .map_err(|error| error.to_string())?; + Ok((jwk.kid, RsaKey { n, e })) + }) + .collect() +} + +enum Source { + Google(HttpClient), + Fixed, +} + +struct Cached { + keys: Arc, + fetched_at: Option, +} + +/// Verifies push tokens against Google's published keys, fetched on first use +/// and kept for `KEYS_TTL`. +pub struct GooglePushAuth { + source: Source, + cached: RwLock, +} + +impl std::fmt::Debug for GooglePushAuth { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("GooglePushAuth") + .finish_non_exhaustive() + } +} + +impl GooglePushAuth { + pub fn new(client: impl Into) -> Self { + Self { + source: Source::Google(client.into()), + cached: RwLock::new(Cached { + keys: Arc::new(Keys::new()), + fetched_at: None, + }), + } + } + + /// Never fetches: for tests and hosts without network access. + pub fn with_keys(keys: Keys) -> Self { + Self { + source: Source::Fixed, + cached: RwLock::new(Cached { + keys: Arc::new(keys), + fetched_at: Some(Instant::now()), + }), + } + } + + pub async fn verify(&self, token: &str, now_secs: u64) -> Result { + let keys = self.keys(false).await?; + let verified = match verify_with_keys(token, &keys, now_secs) { + // Google may have started signing with a key published since. + Err(TokenError::UnknownKey) => { + verify_with_keys(token, &*self.keys(true).await?, now_secs) + } + other => other, + }; + verified.map_err(|error| match error { + TokenError::UnknownKey => PushAuthError::Invalid("unknown key"), + TokenError::Invalid(reason) => PushAuthError::Invalid(reason), + }) + } + + async fn keys(&self, unknown_key: bool) -> Result, PushAuthError> { + let due = |cached: &Cached| match cached.fetched_at { + None => true, + Some(at) => at.elapsed() > KEYS_TTL || (unknown_key && at.elapsed() > REFETCH_INTERVAL), + }; + { + let cached = self.cached.read().await; + if !due(&cached) { + return Ok(Arc::clone(&cached.keys)); + } + } + let Source::Google(client) = &self.source else { + return Ok(Arc::clone(&self.cached.read().await.keys)); + }; + let mut cached = self.cached.write().await; + // Another push may have fetched while this one waited for the lock. + if !due(&cached) { + return Ok(Arc::clone(&cached.keys)); + } + match fetch_keys(client).await { + Ok(keys) => { + cached.keys = Arc::new(keys); + cached.fetched_at = Some(Instant::now()); + Ok(Arc::clone(&cached.keys)) + } + // Keys still in hand keep verifying until a fetch succeeds. + Err(_) if !cached.keys.is_empty() => Ok(Arc::clone(&cached.keys)), + Err(error) => Err(PushAuthError::KeysUnavailable(error)), + } + } +} + +async fn fetch_keys(client: &HttpClient) -> Result { + let response = client + .get(GOOGLE_CERTS_URL) + .send() + .await + .map_err(|error| format!("fetching Google's signing keys failed: {error}"))?; + if !response.status().is_success() { + return Err(format!( + "fetching Google's signing keys failed: HTTP {}", + response.status() + )); + } + let body = response + .bytes() + .await + .map_err(|error| format!("reading Google's signing keys failed: {error}"))?; + parse_keys(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + use ring::rand::SystemRandom; + use ring::signature::{RsaKeyPair, RSA_PKCS1_SHA256}; + use serde_json::json; + + const TEST_KEY: &[u8] = include_bytes!("testdata/gcp-push-test-key.pk8"); + const TEST_KEY_N: &str = include_str!("testdata/gcp-push-test-key.n"); + const NOW: u64 = 1_792_000_000; + + fn keys() -> Keys { + Keys::from([( + "key-1".to_owned(), + RsaKey { + n: URL_SAFE_NO_PAD.decode(TEST_KEY_N.trim()).unwrap(), + e: vec![1, 0, 1], + }, + )]) + } + + fn sign(header: &serde_json::Value, claims: &serde_json::Value) -> String { + let encode = |value: &serde_json::Value| URL_SAFE_NO_PAD.encode(value.to_string()); + let signed = format!("{}.{}", encode(header), encode(claims)); + let pair = RsaKeyPair::from_pkcs8(TEST_KEY).unwrap(); + let mut signature = vec![0; pair.public().modulus_len()]; + pair.sign( + &RSA_PKCS1_SHA256, + &SystemRandom::new(), + signed.as_bytes(), + &mut signature, + ) + .unwrap(); + format!("{signed}.{}", URL_SAFE_NO_PAD.encode(signature)) + } + + fn claims() -> serde_json::Value { + json!({ + "iss": "https://accounts.google.com", + "aud": "https://ingest.test/v1/logpush/gcp/c1", + "exp": NOW + 3600, + "iat": NOW, + "email": "maple-push-0a1b@acme.iam.gserviceaccount.com", + "email_verified": true, + "sub": "1234", + }) + } + + fn token(claims: &serde_json::Value) -> String { + sign( + &json!({ "alg": "RS256", "kid": "key-1", "typ": "JWT" }), + claims, + ) + } + + #[test] + fn accepts_a_token_google_signed_for_a_verified_account() { + assert_eq!( + verify_with_keys(&token(&claims()), &keys(), NOW), + Ok(VerifiedPush { + email: "maple-push-0a1b@acme.iam.gserviceaccount.com".to_owned(), + audience: "https://ingest.test/v1/logpush/gcp/c1".to_owned(), + }) + ); + } + + #[test] + fn refuses_tokens_that_are_not_google_signed_current_and_verified() { + let changed = |key: &str, value: serde_json::Value| { + let mut claims = claims(); + claims[key] = value; + verify_with_keys(&token(&claims), &keys(), NOW) + }; + assert_eq!( + changed("iss", json!("https://evil.test")), + Err(TokenError::Invalid("not issued by Google")) + ); + assert_eq!( + changed("exp", json!(NOW - 61)), + Err(TokenError::Invalid("expired")) + ); + assert_eq!( + changed("iat", json!(NOW + 61)), + Err(TokenError::Invalid("issued in the future")) + ); + assert_eq!( + changed("email_verified", json!(false)), + Err(TokenError::Invalid("no verified email")) + ); + // Within the tolerated clock difference. + assert!(changed("exp", json!(NOW - 59)).is_ok()); + + // Another account's claims under a genuine signature. + let valid = token(&claims()); + let segments: Vec<&str> = valid.split('.').collect(); + let mut forged = claims(); + forged["email"] = json!("someone-else@acme.iam.gserviceaccount.com"); + let swapped = format!( + "{}.{}.{}", + segments[0], + URL_SAFE_NO_PAD.encode(forged.to_string()), + segments[2] + ); + assert_eq!( + verify_with_keys(&swapped, &keys(), NOW), + Err(TokenError::Invalid("bad signature")) + ); + + let none = sign(&json!({ "alg": "none", "kid": "key-1" }), &claims()); + assert_eq!( + verify_with_keys(&none, &keys(), NOW), + Err(TokenError::Invalid("unsupported algorithm")) + ); + let other_key = sign(&json!({ "alg": "RS256", "kid": "key-2" }), &claims()); + assert_eq!( + verify_with_keys(&other_key, &keys(), NOW), + Err(TokenError::UnknownKey) + ); + assert_eq!( + verify_with_keys("a.b", &keys(), NOW), + Err(TokenError::Invalid("not a JWT")) + ); + } + + #[test] + fn reads_google_jwk_sets() { + let parsed = parse_keys( + json!({ "keys": [{ "kid": "abc", "n": TEST_KEY_N.trim(), "e": "AQAB", "kty": "RSA", "alg": "RS256", "use": "sig" }] }) + .to_string() + .as_bytes(), + ) + .unwrap(); + assert_eq!(parsed["abc"].e, vec![1, 0, 1]); + assert!(verify_with_keys( + &sign(&json!({ "alg": "RS256", "kid": "abc" }), &claims()), + &parsed, + NOW + ) + .is_ok()); + } + + #[tokio::test] + async fn fixed_keys_verify_without_fetching() { + let auth = GooglePushAuth::with_keys(keys()); + assert!(auth.verify(&token(&claims()), NOW).await.is_ok()); + let other_key = sign(&json!({ "alg": "RS256", "kid": "key-2" }), &claims()); + assert!(matches!( + auth.verify(&other_key, NOW).await, + Err(PushAuthError::Invalid("unknown key")) + )); + } +} diff --git a/apps/ingest/src/lib.rs b/apps/ingest/src/lib.rs index f39f4baf07..22c1e5e57c 100644 --- a/apps/ingest/src/lib.rs +++ b/apps/ingest/src/lib.rs @@ -2,6 +2,7 @@ pub mod ai_session; pub mod aws; pub mod clickhouse_insert_mappings; pub mod gcp_logging; +pub mod gcp_push_auth; pub mod metrics; pub mod otel; pub mod otlp_json; diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index 5a29d2a077..5c0a4b5fdd 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -47,6 +47,7 @@ use maple_ingest::ai_session; use maple_ingest::aws::CredentialsProvider as AwsCredentialsProvider; use maple_ingest::clickhouse_insert_mappings::SCHEMA_VERSION as CLICKHOUSE_SCHEMA_VERSION; use maple_ingest::gcp_logging; +use maple_ingest::gcp_push_auth::{GooglePushAuth, PushAuthError}; use maple_ingest::metrics; use maple_ingest::otel::{ accept_internal_span, auth_internal_span, build_resource, decode_internal_span, @@ -817,6 +818,8 @@ struct GcpConnectorResolver { recent_health_writes: Cache<(String, bool), ()>, /// Setup reports per connector in the current `GCP_SETUP_REPORT_WINDOW`. recent_setup_reports: Cache>, + /// Verifies the ID tokens Pub/Sub signs pushes with. + push_auth: GooglePushAuth, } struct OrgRoutingResolver { @@ -900,6 +903,12 @@ trait KeyStore: Send + Sync { secret_hash: &str, ) -> Result, String>; + /// For a push signed by Google: the caller checks the signing account. + async fn fetch_gcp_connector_by_id( + &self, + connector_id: &str, + ) -> Result, String>; + async fn fetch_sampling_policy( &self, org_id: &str, @@ -962,7 +971,11 @@ struct ConnectorRow { #[derive(Clone, Debug)] struct GcpConnectorRow { org_id: String, + /// The host project: its push service account signs the connector's pushes. + project_id: String, logs_enabled: bool, + /// What a setup run last reported; `None` before any report. + applied_logs_enabled: Option, self_managed: bool, clickhouse_ready: bool, } @@ -1031,12 +1044,14 @@ struct GcpConnectorIdentity { org_id: String, secret_key_id: String, logs_enabled: bool, + confirms_log_forwarding: bool, } impl GcpConnectorIdentity { fn into_resolved(self, routing: &OrgRouting) -> ResolvedGcpConnector { ResolvedGcpConnector { logs_enabled: self.logs_enabled, + confirms_log_forwarding: self.confirms_log_forwarding, key: ResolvedIngestKey { org_id: self.org_id, key_type: IngestKeyType::Connector, @@ -1263,6 +1278,10 @@ struct ResolvedCloudflareConnector { struct ResolvedGcpConnector { /// False once the org turns log forwarding off; the sink may still push. logs_enabled: bool, + /// A signed push to a connector no setup run has reported on: one set up + /// with Terraform, which sends no report. The push itself proves the sink, + /// topic and subscription are in place. + confirms_log_forwarding: bool, key: ResolvedIngestKey, } @@ -2566,6 +2585,7 @@ async fn main() { config.lookup_hmac_key.clone(), org_routing_resolver, Duration::from_secs(config.ingest_key_cache_ttl_secs), + GooglePushAuth::new(http_client.clone()), ), telemetry_pipeline, http_client, @@ -4831,6 +4851,7 @@ async fn handle_gcp_logpush( State(state): State>, Path(connector_id): Path, Query(query): Query, + headers: HeaderMap, body: Bytes, ) -> Response { let start = Instant::now(); @@ -4854,6 +4875,7 @@ async fn handle_gcp_logpush( "maple.org_id" = tracing::field::Empty, "maple.gcp.connector_id" = tracing::field::Empty, "maple.gcp.setup_report" = tracing::field::Empty, + "maple.gcp.push_auth" = tracing::field::Empty, "maple.ingest.self_managed" = tracing::field::Empty, "maple.ingest.clickhouse_ready" = tracing::field::Empty, "maple.ingest.destination" = tracing::field::Empty, @@ -4861,7 +4883,18 @@ async fn handle_gcp_logpush( ); let span_handle = span.clone(); - let result = handle_gcp_logpush_inner(&state, &connector_id, query.secret.as_deref(), body) + // Set up by the current script or Terraform: a token Pub/Sub signed. + // Earlier subscriptions carry the secret in the URL. + let token = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + let credential = match (token, query.secret.as_deref()) { + (Some(token), _) => Some(GcpPushCredential::Token(token)), + (None, Some(secret)) => Some(GcpPushCredential::Secret(secret)), + (None, None) => None, + }; + let result = handle_gcp_logpush_inner(&state, &connector_id, credential, body) .instrument(span) .await; let duration = start.elapsed().as_secs_f64(); @@ -4923,12 +4956,17 @@ async fn handle_gcp_logpush( } } +enum GcpPushCredential<'a> { + Token(&'a str), + Secret(&'a str), +} + /// Returns Ok((ack response, what the ack means)) or /// Err((ApiError, error_kind_label)). async fn handle_gcp_logpush_inner( state: &AppState, connector_id: &str, - secret: Option<&str>, + credential: Option>, body: Bytes, ) -> Result<(Response, GcpPushOutcome), (ApiError, &'static str)> { let unauthorized = || { @@ -4937,17 +4975,34 @@ async fn handle_gcp_logpush_inner( "auth", ) }; - let secret = secret.map(str::trim).filter(|value| !value.is_empty()); - let (Some(secret), true) = (secret, is_connector_id(connector_id)) else { - warn!("Missing GCP connector secret or malformed connector id"); + let credential = credential.filter(|credential| match credential { + GcpPushCredential::Token(value) | GcpPushCredential::Secret(value) => { + !value.trim().is_empty() + } + }); + let (Some(credential), true) = (credential, is_connector_id(connector_id)) else { + warn!("Missing GCP connector credentials or malformed connector id"); return Err(unauthorized()); }; Span::current().record("maple.gcp.connector_id", connector_id); - let resolved = state - .gcp_resolver - .resolve_connector(connector_id, secret) - .await + let lookup = match credential { + GcpPushCredential::Token(token) => { + Span::current().record("maple.gcp.push_auth", "oidc"); + state + .gcp_resolver + .resolve_connector_by_token(connector_id, token.trim()) + .await + } + GcpPushCredential::Secret(secret) => { + Span::current().record("maple.gcp.push_auth", "secret"); + state + .gcp_resolver + .resolve_connector(connector_id, secret.trim()) + .await + } + }; + let resolved = lookup .map_err(|error| { error!(error = %error, connector_id, "GCP connector resolution failed"); ( @@ -4962,6 +5017,12 @@ async fn handle_gcp_logpush_inner( let org_id = resolved.key.org_id.as_str(); Span::current().record("maple.org_id", org_id); + if resolved.confirms_log_forwarding { + state + .gcp_resolver + .confirm_log_forwarding(connector_id) + .await; + } Span::current().record("maple.ingest.self_managed", resolved.key.self_managed); Span::current().record( "maple.ingest.clickhouse_ready", @@ -5104,6 +5165,17 @@ async fn handle_gcp_logpush_inner( /// Connector ids are generated by the API; anything outside this alphabet is /// refused before it reaches this handler's span, its logs, or Postgres. +/// The push service account a connector's setup creates in its host project, +/// named as `gcpConnectorResourceNames` in `@maple/domain/gcp` names it. +fn gcp_push_service_account(connector_id: &str, project_id: &str) -> String { + let hex: String = connector_id + .chars() + .filter(|c| *c != '-') + .take(19) + .collect(); + format!("maple-push-{hex}@{project_id}.iam.gserviceaccount.com") +} + fn is_connector_id(value: &str) -> bool { (1..=128).contains(&value.len()) && value @@ -6129,6 +6201,7 @@ impl GcpConnectorResolver { lookup_hmac_key: String, routing: Arc, cache_ttl: Duration, + push_auth: GooglePushAuth, ) -> Self { Self { store, @@ -6151,6 +6224,7 @@ impl GcpConnectorResolver { .time_to_live(GCP_SETUP_REPORT_WINDOW) .max_capacity(10_000) .build(), + push_auth, } } @@ -6195,6 +6269,7 @@ impl GcpConnectorResolver { org_id: row.org_id, secret_key_id: cache_key.1.chars().take(16).collect(), logs_enabled: row.logs_enabled, + confirms_log_forwarding: false, }; self.cache.insert(cache_key, identity.clone()).await; @@ -6205,6 +6280,102 @@ impl GcpConnectorResolver { Ok(Some(identity.into_resolved(&routing))) } + /// A push Pub/Sub signed as the connector's push service account, for an + /// audience naming this connector. `Err` only when Google's keys can't be + /// fetched, which a redelivery may get past. + async fn resolve_connector_by_token( + &self, + connector_id: &str, + token: &str, + ) -> Result, String> { + let now_secs = u64::try_from(current_time_millis() / 1000).unwrap_or_default(); + let push = match self.push_auth.verify(token, now_secs).await { + Ok(push) => push, + Err(PushAuthError::Invalid(reason)) => { + debug!(connector_id, reason, "GCP push token refused"); + return Ok(None); + } + Err(PushAuthError::KeysUnavailable(error)) => return Err(error), + }; + // The audience is the receiver URL; its host is whatever the + // subscription was given, the path names the connector. + let audience_path = url::Url::parse(&push.audience) + .map(|url| url.path().to_owned()) + .unwrap_or_default(); + if audience_path != format!("/v1/logpush/gcp/{connector_id}") { + debug!(connector_id, "GCP push token is for another audience"); + return Ok(None); + } + + let cache_key = (connector_id.to_owned(), format!("oidc:{}", push.email)); + if let Some(identity) = self.cache.get(&cache_key).await { + let routing = self.routing.resolve_org_routing(&identity.org_id).await?; + return Ok(Some(identity.into_resolved(&routing))); + } + if self.negative_cache.contains_key(&cache_key) { + return Ok(None); + } + let row = self + .store + .fetch_gcp_connector_by_id(connector_id) + .await? + .filter(|row| push.email == gcp_push_service_account(connector_id, &row.project_id)); + let Some(row) = row else { + self.negative_cache.insert(cache_key, ()).await; + return Ok(None); + }; + + let routing = OrgRouting { + self_managed: row.self_managed, + clickhouse_ready: row.clickhouse_ready, + }; + let identity = GcpConnectorIdentity { + org_id: row.org_id, + // Names the signing account, as a secret's hash prefix names the secret. + secret_key_id: push.email.chars().take(16).collect(), + logs_enabled: row.logs_enabled, + confirms_log_forwarding: row.applied_logs_enabled.is_none(), + }; + self.cache.insert(cache_key, identity.clone()).await; + self.routing + .remember_org_routing(&identity.org_id, routing.clone()) + .await; + Ok(Some(identity.into_resolved(&routing))) + } + + /// Records log forwarding as set up, once. The cached identities stop + /// asking first, so a busy sink writes once rather than once per entry + /// until the write lands; a failed write lets a later push try again. + async fn confirm_log_forwarding(&self, connector_id: &str) { + // Collected first: the cache's iterator is not held across an await. + let pending: Vec<_> = self + .cache + .iter() + .filter(|(key, identity)| key.0 == connector_id && identity.confirms_log_forwarding) + .collect(); + for (key, identity) in &pending { + let confirmed = GcpConnectorIdentity { + confirms_log_forwarding: false, + ..identity.clone() + }; + self.cache.insert((**key).clone(), confirmed).await; + } + let report = gcp_logging::SetupReport { + logs: Some(true), + metrics: None, + }; + if let Err(error) = self + .store + .record_gcp_setup_report(connector_id, report, current_time_millis()) + .await + { + warn!(error = %error, connector_id, "Failed to record GCP log forwarding as set up"); + for (key, _) in &pending { + self.cache.invalidate(&**key).await; + } + } + } + /// Writes `last_received_at` (`error == None`) or `last_error`, at most /// once per connector and outcome per interval. Best-effort: the request's /// outcome is already decided, so a failed write is only logged. @@ -6374,6 +6545,27 @@ fn schema_revision_at_least(stamped: &str, needed: i32) -> bool { const SCHEMA_REVISION_COMPATIBLE_SQL: &str = "(CASE WHEN s.schema_version ~ '^[0-9]+$' THEN s.schema_version::int ELSE -1 END >= $1)"; +/// A `GcpConnectorRow`'s columns over `gcp_connectors c` joined to +/// `org_clickhouse_settings s`, with the schema revision as `$1`. +fn gcp_connector_columns() -> String { + format!( + "c.org_id, c.project_id, c.logs_enabled, c.applied_logs_enabled, \ + COALESCE(s.sync_status = 'connected', false) AS self_managed, \ + COALESCE(s.sync_status = 'connected' AND {SCHEMA_REVISION_COMPATIBLE_SQL}, false) AS clickhouse_ready" + ) +} + +fn gcp_connector_row(row: &tokio_postgres::Row) -> GcpConnectorRow { + GcpConnectorRow { + org_id: row.get("org_id"), + project_id: row.get("project_id"), + logs_enabled: row.get("logs_enabled"), + applied_logs_enabled: row.get("applied_logs_enabled"), + self_managed: row.get("self_managed"), + clickhouse_ready: row.get("clickhouse_ready"), + } +} + #[async_trait::async_trait] impl ClickHouseTargetProvider for ClickHouseTargetResolver { async fn resolve_clickhouse_target( @@ -6678,10 +6870,9 @@ impl KeyStore for PostgresKeyStore { let revision = required_schema_revision(); let client = self.client().await?; // No `enabled` column: deleting the row is how a connector is turned off. + let columns = gcp_connector_columns(); let sql = format!( - "SELECT c.org_id, c.logs_enabled, \ - COALESCE(s.sync_status = 'connected', false) AS self_managed, \ - COALESCE(s.sync_status = 'connected' AND {SCHEMA_REVISION_COMPATIBLE_SQL}, false) AS clickhouse_ready \ + "SELECT {columns} \ FROM gcp_connectors c \ LEFT JOIN org_clickhouse_settings s ON s.org_id = c.org_id \ WHERE c.id = $2 AND c.secret_hash = $3 LIMIT 1" @@ -6696,15 +6887,33 @@ impl KeyStore for PostgresKeyStore { )) .await .map_err(|error| format!("postgres fetch_gcp_connector failed: {error}"))?; - let Some(row) = rows.into_iter().next() else { - return Ok(None); - }; - Ok(Some(GcpConnectorRow { - org_id: row.get("org_id"), - logs_enabled: row.get("logs_enabled"), - self_managed: row.get("self_managed"), - clickhouse_ready: row.get("clickhouse_ready"), - })) + Ok(rows.first().map(gcp_connector_row)) + } + + async fn fetch_gcp_connector_by_id( + &self, + connector_id: &str, + ) -> Result, String> { + let revision = required_schema_revision(); + let client = self.client().await?; + let columns = gcp_connector_columns(); + let sql = format!( + "SELECT {columns} \ + FROM gcp_connectors c \ + LEFT JOIN org_clickhouse_settings s ON s.org_id = c.org_id \ + WHERE c.id = $2 LIMIT 1" + ); + let rows = client + .query(&sql, &[&revision, &connector_id]) + .instrument(postgres_client_span( + "fetch_gcp_connector_by_id", + "SELECT", + GCP_CONNECTORS_TABLE, + &self.target, + )) + .await + .map_err(|error| format!("postgres fetch_gcp_connector_by_id failed: {error}"))?; + Ok(rows.first().map(gcp_connector_row)) } async fn fetch_sampling_policy( @@ -6958,6 +7167,13 @@ impl KeyStore for StaticKeyStore { Ok(None) } + async fn fetch_gcp_connector_by_id( + &self, + _connector_id: &str, + ) -> Result, String> { + Ok(None) + } + async fn fetch_sampling_policy( &self, _org_id: &str, @@ -8212,6 +8428,18 @@ mod tests { .get(&(connector_id.to_owned(), secret_hash.to_owned())) .cloned()) } + async fn fetch_gcp_connector_by_id( + &self, + connector_id: &str, + ) -> Result, String> { + Ok(self + .gcp_connectors + .lock() + .unwrap() + .iter() + .find(|((id, _), _)| id == connector_id) + .map(|(_, row)| row.clone())) + } async fn fetch_sampling_policy( &self, _org_id: &str, @@ -8573,6 +8801,7 @@ mod tests { "test-hmac-key".to_owned(), routing, Duration::from_mins(1), + GooglePushAuth::with_keys(gcp_push_test_keys()), ), autumn_tracker: None, autumn_entitlements: None, @@ -9610,24 +9839,29 @@ mod tests { (url, rx) } - /// One org with two connectors: `gcp_conn_1`, and `gcp_conn_off` whose log - /// forwarding is turned off. + /// One org with three connectors: `gcp_conn_1`, `gcp_conn_off` whose log + /// forwarding is turned off, both set up by an earlier script that put the + /// secret in the push URL, and `GCP_SIGNED_CONNECTOR`, set up with + /// Terraform and never reported on, whose pushes Pub/Sub signs. async fn gcp_logpush_state( name: &str, clickhouse_url: &str, ) -> (Arc, Arc, PathBuf) { let queue_dir = unique_main_test_dir(name); let store = Arc::new(FakeKeyStore::default()); - for (connector_id, secret, logs_enabled) in [ - ("gcp_conn_1", "gcp-secret", true), - ("gcp_conn_off", "gcp-secret-off", false), + for (connector_id, secret, logs_enabled, applied_logs_enabled) in [ + ("gcp_conn_1", "gcp-secret", true, Some(true)), + ("gcp_conn_off", "gcp-secret-off", false, Some(true)), + (GCP_SIGNED_CONNECTOR, "gcp-secret-signed", true, None), ] { store.insert_gcp_connector( connector_id, secret, GcpConnectorRow { org_id: "org_gcp".to_owned(), + project_id: "acme-host".to_owned(), logs_enabled, + applied_logs_enabled, self_managed: true, clickhouse_ready: true, }, @@ -9667,11 +9901,154 @@ mod tests { Query(LogpushQuery { secret: secret.map(str::to_owned), }), + HeaderMap::new(), Bytes::from_static(body.as_bytes()), ) .await } + const GCP_SIGNED_CONNECTOR: &str = "0a1b2c3d-4e5f-4071-8293-a4b5c6d7e8f9"; + const GCP_SIGNED_ACCOUNT: &str = + "maple-push-0a1b2c3d4e5f4071829@acme-host.iam.gserviceaccount.com"; + const GCP_PUSH_TEST_KEY: &[u8] = include_bytes!("testdata/gcp-push-test-key.pk8"); + const GCP_PUSH_TEST_KEY_N: &str = include_str!("testdata/gcp-push-test-key.n"); + + fn gcp_push_test_keys() -> maple_ingest::gcp_push_auth::Keys { + maple_ingest::gcp_push_auth::Keys::from([( + "key-1".to_owned(), + maple_ingest::gcp_push_auth::RsaKey { + n: URL_SAFE_NO_PAD.decode(GCP_PUSH_TEST_KEY_N.trim()).unwrap(), + e: vec![1, 0, 1], + }, + )]) + } + + /// An ID token as Pub/Sub signs it for a push subscription's account. + fn gcp_push_token(email: &str, audience: &str) -> String { + use ring::signature::{RsaKeyPair, RSA_PKCS1_SHA256}; + let now = current_time_millis() / 1000; + let encode = |value: serde_json::Value| URL_SAFE_NO_PAD.encode(value.to_string()); + let signed = format!( + "{}.{}", + encode(serde_json::json!({ "alg": "RS256", "kid": "key-1", "typ": "JWT" })), + encode(serde_json::json!({ + "iss": "https://accounts.google.com", + "aud": audience, + "iat": now, + "exp": now + 3600, + "email": email, + "email_verified": true, + })), + ); + let pair = RsaKeyPair::from_pkcs8(GCP_PUSH_TEST_KEY).unwrap(); + let mut signature = vec![0; pair.public().modulus_len()]; + pair.sign( + &RSA_PKCS1_SHA256, + &ring::rand::SystemRandom::new(), + signed.as_bytes(), + &mut signature, + ) + .unwrap(); + format!("{signed}.{}", URL_SAFE_NO_PAD.encode(signature)) + } + + async fn gcp_signed_push( + state: &Arc, + connector_id: &str, + token: &str, + body: &'static str, + ) -> Response { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + handle_gcp_logpush( + State(Arc::clone(state)), + Path(connector_id.to_owned()), + Query(LogpushQuery { secret: None }), + headers, + Bytes::from_static(body.as_bytes()), + ) + .await + } + + #[tokio::test] + async fn gcp_logpush_accepts_a_push_signed_as_the_connectors_push_account() { + let (ch_url, mut ch_rx) = spawn_fake_clickhouse().await; + let (state, store, queue_dir) = gcp_logpush_state("gcp-logpush-signed", &ch_url).await; + let token = gcp_push_token( + GCP_SIGNED_ACCOUNT, + &format!("https://ingest.test/v1/logpush/gcp/{GCP_SIGNED_CONNECTOR}"), + ); + + for _ in 0..2 { + let response = + gcp_signed_push(&state, GCP_SIGNED_CONNECTOR, &token, GCP_LOG_ENTRY).await; + assert_eq!(response.status(), StatusCode::OK); + tokio::time::timeout(Duration::from_secs(2), ch_rx.recv()) + .await + .expect("the entry is stored") + .expect("fake ClickHouse is running"); + } + // Nothing reported on it before: the first push proves log forwarding + // is set up, and only the first records it. + assert_eq!( + *store.gcp_setup_reports.lock().unwrap(), + [( + GCP_SIGNED_CONNECTOR.to_owned(), + gcp_logging::SetupReport { + logs: Some(true), + metrics: None + } + )] + ); + + drop(std::fs::remove_dir_all(queue_dir)); + } + + #[tokio::test] + async fn gcp_logpush_refuses_a_token_for_another_account_or_connector() { + let (state, store, queue_dir) = + gcp_logpush_state("gcp-logpush-signed-refused", "http://127.0.0.1:1").await; + let audience = format!("https://ingest.test/v1/logpush/gcp/{GCP_SIGNED_CONNECTOR}"); + + for (case, connector_id, token) in [ + ( + "the same name in another project", + GCP_SIGNED_CONNECTOR, + gcp_push_token( + "maple-push-0a1b2c3d4e5f4071829@attacker.iam.gserviceaccount.com", + &audience, + ), + ), + ( + "a token for another connector's endpoint", + GCP_SIGNED_CONNECTOR, + gcp_push_token( + GCP_SIGNED_ACCOUNT, + "https://ingest.test/v1/logpush/gcp/gcp_conn_1", + ), + ), + ( + "another connector's endpoint with this token", + "gcp_conn_1", + gcp_push_token(GCP_SIGNED_ACCOUNT, &audience), + ), + ( + "not a token", + GCP_SIGNED_CONNECTOR, + "gcp-secret-signed".to_owned(), + ), + ] { + let response = gcp_signed_push(&state, connector_id, &token, GCP_LOG_ENTRY).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{case}"); + } + assert!(store.gcp_setup_reports.lock().unwrap().is_empty()); + + drop(std::fs::remove_dir_all(queue_dir)); + } + #[tokio::test] async fn gcp_logpush_rejects_bad_credentials() { let (state, _store, queue_dir) = diff --git a/apps/ingest/src/testdata/gcp-push-test-key.n b/apps/ingest/src/testdata/gcp-push-test-key.n new file mode 100644 index 0000000000..423ab89096 --- /dev/null +++ b/apps/ingest/src/testdata/gcp-push-test-key.n @@ -0,0 +1 @@ +xw7kNmTxqS6r6IoeOEQW1iQ1oWnhL99UYQgthpdP8KgzW-FVhltnHG-X8tu8t-GIaC9j_Uhf9YH3268I0hpFbakiGNwy-WnctTy4UG5kY-S1RlTaTEgbIuI-N6BUoCCCbLQCBg3IP9e7O-c2fAZQ1pn7EAIV3qDtTGE4b4aBRrD3f6pJ-h8UgnhWn4u-1QzzhdPgRe22E5lceZyuMiFtZhzyxvak82FirJ5xGgJx-zVYpGDjEioczBLM-RR6LTFYo9Zk7wZugaGl0dgQfqwpQp3fMf0POjU0QDa5Dct76HvFf6CWAEUdwZpYlfdRJOOgZFe49bhmKhB_yTBOQpVocw \ No newline at end of file diff --git a/apps/ingest/src/testdata/gcp-push-test-key.pk8 b/apps/ingest/src/testdata/gcp-push-test-key.pk8 new file mode 100644 index 0000000000000000000000000000000000000000..d39622efe5f13fa2029553724cbcc9c710dee725 GIT binary patch literal 1216 zcmV;x1V8&Qf&{z*0RS)!1_>&LNQUrrZ9p8q5=T`0)hbn0LKpGHe~Us zF01H@9yml6)+9BdY2h#5RAC4$hL=z9s54vPRfbz<9B-HM+q}2oh-fck{YYQ+f%n_5 z2+|rwZK)y{+%oxT+_gNoP;O*nba009Dm0RSf0 zG!~soKVF$LZpet}SfGDCYx+`jS{sNrQ-xp0O zRH}g}z?O3nr(DPNoNiPQG38wZwOA6MO{nf9m96 z!!z-%_11d>@KVRy!vU+e2_aHhCFclLVQ)v6Rh;5YTihbBAfg0KKe7=_=EHKhsQ_ls z*S!J&YDRIR`0fBNy0QJ7oE+9|m8v@dISY9uleUBjU}RrkL#Vvd9LZjU(tscV>OSKQ<0A>J1;c21CWBtgN=Ix$ms4XF8$Dkud!8A$&D({gfX1%KlVbvb zfKW3}^WJvvpTPD1dp2VTKL4$Y-Sr0V@`VcO?g#E|4?ASI2grbSB) zY64D6im1z$7}r;Y>)4S6#r6g361g(~q*2J;rKLvE}LzHd)aN51M45dmBOG|&>h zd~lXXs~&%9a2?WH;2jwk-8vHFu^4%XQj{oh50N|Yth$+M(;!<12t#GaL_AwJ# zSA=_k(ALF21lCKzIT`&o@LA-#3QZ^khAz*{zXLh%-iV(6V!JE>+sO8`nLkplX1DJZ z0)c=o5!U4MyK|T$o5lPL87Xlck?k)>xVe~wv_Y}B>gb<)JCKHPyF-cl=d*WN)h+v7 zdNcG3bi>DN6el&CiyVvOX#`CCnK539XF0ZIrO_+sK4&wROp6|5yC@ZYg4!aXm}@TP ecUe)y#mdOSL2)NwRBL>wGA>^0YkD>@>qu&*=Rj8g literal 0 HcmV?d00001 From 4f31deabb35c1e8e4ab489c3d2dfc32e7f7bf193 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 20:07:19 +0200 Subject: [PATCH 4/5] feat(web): apply a Google Cloud connection with Terraform The apply stage offers the Terraform module beside the Cloud Shell script, with a check that confirms the read-only access, since a Terraform run reports nothing. --- .../routes/v2/integrations-gcp.http.test.ts | 11 +- .../src/routes/v2/integrations-gcp.http.ts | 12 +- .../components/integrations/gcp-configure.tsx | 384 ++++++++++++++---- .../integrations/GcpPromQlService.test.ts | 60 +-- .../services/integrations/GcpPromQlService.ts | 14 +- .../integrations/gcp/setup-scripts.test.ts | 16 +- .../integrations/gcp/terraform.test.ts | 10 +- .../domain/src/http/v2/integrations-gcp.ts | 7 +- 8 files changed, 388 insertions(+), 126 deletions(-) diff --git a/apps/api/src/routes/v2/integrations-gcp.http.test.ts b/apps/api/src/routes/v2/integrations-gcp.http.test.ts index af40631462..63c396a8aa 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.test.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.test.ts @@ -216,7 +216,12 @@ describe("v2 gcp integration over HTTP", () => { application_logs: ["cloud_run", "cloud_functions", "app_engine", "gke"], }) expect(both.status).toBe(200) - expect(Object.keys(both.body).sort()).toEqual(["cleanup_script", "object", "setup_script", "terraform"]) + expect(Object.keys(both.body).sort()).toEqual([ + "cleanup_script", + "object", + "setup_script", + "terraform", + ]) expect(both.body.object).toBe("gcp_connector.setup_scripts") // The subscription is given no secret; only the script's own reports carry it. expect(both.body.setup_script).toMatch( @@ -227,7 +232,9 @@ describe("v2 gcp integration over HTTP", () => { ) expect(both.body.terraform).toMatch(/^ {2}scope_type += "folder"$/m) expect(both.body.terraform).toMatch(/^ {2}project_id += "acme-host"$/m) - expect(both.body.terraform).toContain('NOT protoPayload.methodName=\\"io.k8s.coordination.v1.leases.update\\"') + expect(both.body.terraform).toContain( + 'NOT protoPayload.methodName=\\"io.k8s.coordination.v1.leases.update\\"', + ) expect(both.body.terraform).not.toMatch(/maple_gcp_[A-Za-z0-9_-]{32}|secret=/) expect(both.body.setup_script).toContain("PROJECT_ID='acme-host'") expect(both.body.setup_script).toContain("SCOPE_ID='123456789012'") diff --git a/apps/api/src/routes/v2/integrations-gcp.http.ts b/apps/api/src/routes/v2/integrations-gcp.http.ts index 4b6837a1cb..374948a99a 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.ts @@ -119,11 +119,13 @@ export const HttpV2GcpIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "gcpIn Effect.gen(function* () { const tenant = yield* CurrentTenant.Context yield* requireAdmin(tenant.roles, adminOnly) - const checked = yield* gcpPromQl.checkAccess(tenant.orgId, params.id).pipe( - Effect.catchTag("@maple/http/errors/GcpPromQlUnavailableError", (error) => - Effect.fail(new GcpMetricsUnavailableError({ message: error.message })), - ), - ) + const checked = yield* gcpPromQl + .checkAccess(tenant.orgId, params.id) + .pipe( + Effect.catchTag("@maple/http/errors/GcpPromQlUnavailableError", (error) => + Effect.fail(new GcpMetricsUnavailableError({ message: error.message })), + ), + ) return { object: "gcp_connector.access_check" as const, ...checked } }), ) diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx index 44e4b9fa32..05f8dd1a63 100644 --- a/apps/web/src/components/integrations/gcp-configure.tsx +++ b/apps/web/src/components/integrations/gcp-configure.tsx @@ -24,6 +24,7 @@ import { Input } from "@maple/ui/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Spinner } from "@maple/ui/components/ui/spinner" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { countLabel } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" @@ -344,6 +345,53 @@ function Script({ ) } +/** + * The Terraform module block: short enough to show whole, and free of secrets, so it reads as + * what it is before anyone copies it. + */ +function TerraformBlock({ + terraform, + failure, +}: { + terraform: string | null + failure: { readonly message: string; readonly retry: () => void; readonly retrying: boolean } | null +}) { + const text = terraform?.trimEnd() ?? null + if (failure !== null) { + return ( +
+

+ {failure.message} +

+ +
+ ) + } + return text === null ? ( + + ) : ( +
+
+ Terraform + +
+
+				{text}
+			
+
+ ) +} + type StepState = "current" | "pending" | "waiting" | "done" /** @@ -1133,6 +1181,20 @@ function ApplyStage({ const scopeRoles = gcpScopeRoles(connector.scope_type, connector.logs_enabled, metricsAvailable) const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase() const role = (name: string) => {name} + const [method, setMethod] = useState<"script" | "terraform">("script") + const whoRuns = ( + <> + {role("Owner")} of {connector.scope_type === "project" ? null : "the host project "} + {connector.project_id} + {scopeRoles.map((name, index) => ( + + {index === 0 ? " who also has " : " and "} + {role(name)} + + ))} + {scopeRoles.length > 0 ? ` on the ${scopeName}` : null} + + ) return ( <> @@ -1175,88 +1237,114 @@ function ApplyStage({ ) : ( -
    - - Sign in as an {role("Owner")} of{" "} - {connector.scope_type === "project" ? null : "the host project "} - {connector.project_id} - {scopeRoles.map((name, index) => ( - - {index === 0 ? " who also has " : " and "} - {role(name)} - - ))} - {scopeRoles.length > 0 ? ` on the ${scopeName}` : null}.{" "} - - Roles if you aren't an Owner - - - } - > -
    - -
    -
    - -