# ── Sandbox: sandbox.neurontechnologies.ai ─────────────────────────────────── # Internal-only experimentation environment. Locked behind Cloudflare Access # (email_domain == neurontechnologies.ai). Dedicated GCP service account with # ZERO bindings on prod resources. Isolated Supabase project (separate from # prod, NOT a schema in the prod project). No outbound email path. Public # share / artifact endpoints respond with 410 Gone in this env. # # This file provisions the SHELL only (DNS, access control, Cloud Run stub, # isolated SA, Secret Manager placeholders for the isolated Supabase). The # real-soul build pipeline lands in a follow-up. # # Documentation: servers/gcp/sandbox/README.md locals { sandbox_labels = { "managed-by" = "terraform" "project" = "neuron-sandbox" "env" = "sandbox" "isolation" = "strict" } sandbox_image_initial = "us-docker.pkg.dev/cloudrun/container/hello" sandbox_image_stub = "us-central1-docker.pkg.dev/${var.project_id}/neuron-sandbox/sandbox:initial" } # ── Artifact Registry — neuron-sandbox (isolated from neuron-marketing/etc) ── resource "google_artifact_registry_repository" "sandbox" { location = "us-central1" repository_id = "neuron-sandbox" description = "Sandbox stub + future real-soul images. Isolated from prod registries." format = "DOCKER" project = var.project_id labels = local.sandbox_labels cleanup_policies { id = "keep-last-5" action = "KEEP" most_recent_versions { keep_count = 5 } } } # ── Service Account — neuron-sandbox ───────────────────────────────────────── # Dedicated SA for the sandbox Cloud Run service. NO project-level IAM # bindings: every grant below is resource-scoped to sandbox secrets ONLY. # Any prod resource (neuron-marketing-*, accounts-*, api-*, supabase-service-key, # stripe-*, etc.) is unreachable to this SA by design. resource "google_service_account" "neuron_sandbox" { account_id = "neuron-sandbox" display_name = "Neuron Sandbox Cloud Run SA" description = "Isolated SA for sandbox.neurontechnologies.ai. Zero IAM on prod resources." project = var.project_id } # ── Sandbox Supabase secrets (placeholders) ────────────────────────────────── # These secrets are created here so the Cloud Run service can reference them # from day one. The values are populated AFTER the Supabase project is # provisioned (see servers/gcp/sandbox/README.md). # # Initial value is a placeholder string; first-time deploy of the real soul # requires running `gcloud secrets versions add --data-file=...` to # write the actual values. resource "google_secret_manager_secret" "sandbox_supabase_url" { secret_id = "sandbox-supabase-url" project = var.project_id labels = local.sandbox_labels replication { auto {} } } resource "google_secret_manager_secret" "sandbox_supabase_anon_key" { secret_id = "sandbox-supabase-anon-key" project = var.project_id labels = local.sandbox_labels replication { auto {} } } resource "google_secret_manager_secret" "sandbox_supabase_service_key" { secret_id = "sandbox-supabase-service-key" project = var.project_id labels = local.sandbox_labels replication { auto {} } } # Placeholder versions so the secrets resolve to *something* before real # Supabase project is provisioned. The stub does not read them; the real # soul will, and at that point the operator runs `gcloud secrets versions # add` with the values from the new Supabase project. resource "google_secret_manager_secret_version" "sandbox_supabase_url_placeholder" { secret = google_secret_manager_secret.sandbox_supabase_url.id secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING" lifecycle { ignore_changes = [secret_data] } } resource "google_secret_manager_secret_version" "sandbox_supabase_anon_key_placeholder" { secret = google_secret_manager_secret.sandbox_supabase_anon_key.id secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING" lifecycle { ignore_changes = [secret_data] } } resource "google_secret_manager_secret_version" "sandbox_supabase_service_key_placeholder" { secret = google_secret_manager_secret.sandbox_supabase_service_key.id secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING" lifecycle { ignore_changes = [secret_data] } } # ── IAM — Resource-scoped, sandbox-only ────────────────────────────────────── # Sandbox SA gets secretAccessor on the THREE sandbox-supabase-* secrets only. # This is the entire IAM surface for the sandbox SA. There is no project-level # role binding; any prod secret (e.g., supabase-service-key, anthropic-api-key, # resend-api-key) returns PERMISSION_DENIED to this SA. resource "google_secret_manager_secret_iam_member" "sandbox_url_accessor" { project = var.project_id secret_id = google_secret_manager_secret.sandbox_supabase_url.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.neuron_sandbox.email}" } resource "google_secret_manager_secret_iam_member" "sandbox_anon_accessor" { project = var.project_id secret_id = google_secret_manager_secret.sandbox_supabase_anon_key.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.neuron_sandbox.email}" } resource "google_secret_manager_secret_iam_member" "sandbox_service_accessor" { project = var.project_id secret_id = google_secret_manager_secret.sandbox_supabase_service_key.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.neuron_sandbox.email}" } # ── Cloud Run — neuron-sandbox-us ──────────────────────────────────────────── # Single region (us-central1). Internal-only env doesn't need geo-distribution. # Ingress is INGRESS_TRAFFIC_ALL because Cloudflare Access sits in front; the # Cloud Run direct URL is obscure and not the front door. # # IMPORTANT: this service intentionally does NOT mount RESEND_API_KEY, # stripe-*, anthropic-api-key, or any prod secret. Defense-in-depth against # experimental code accidentally emailing real users or charging real cards. resource "google_cloud_run_v2_service" "neuron_sandbox" { name = "neuron-sandbox-us" location = "us-central1" project = var.project_id ingress = "INGRESS_TRAFFIC_ALL" labels = local.sandbox_labels template { service_account = google_service_account.neuron_sandbox.email scaling { min_instance_count = 0 max_instance_count = 3 } containers { # Initial deploy uses the GCP hello image so the service exists before # the stub image is built and pushed. Switch to local.sandbox_image_stub # once the stub image is in Artifact Registry. Lifecycle ignore_changes # keeps subsequent CI image-tag bumps from being undone by terraform. image = local.sandbox_image_stub resources { limits = { cpu = "1" memory = "512Mi" } cpu_idle = true } env { name = "ENV" value = "sandbox" } env { name = "SOUL_LOADED" value = "false" } env { name = "SANDBOX_NO_EMAIL" value = "1" } env { name = "SANDBOX_NO_SHARE" value = "1" } # Sandbox Supabase wiring -- placeholders until project is provisioned. env { name = "SUPABASE_URL" value_source { secret_key_ref { secret = google_secret_manager_secret.sandbox_supabase_url.secret_id version = "latest" } } } env { name = "SUPABASE_ANON_KEY" value_source { secret_key_ref { secret = google_secret_manager_secret.sandbox_supabase_anon_key.secret_id version = "latest" } } } env { name = "SUPABASE_SERVICE_KEY" value_source { secret_key_ref { secret = google_secret_manager_secret.sandbox_supabase_service_key.secret_id version = "latest" } } } ports { container_port = 8080 name = "http1" } # Note: /health (not /healthz). Cloud Run's frontend reserves /healthz # and intercepts it before the request reaches the container. Internal # probes still work either way (probe traffic doesn't go through the # frontend), but using /health keeps external-curl semantics consistent. startup_probe { http_get { path = "/health" port = 8080 } initial_delay_seconds = 5 timeout_seconds = 3 period_seconds = 5 failure_threshold = 6 } liveness_probe { http_get { path = "/health" port = 8080 } initial_delay_seconds = 10 timeout_seconds = 3 period_seconds = 30 failure_threshold = 3 } } } traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" percent = 100 } depends_on = [ google_secret_manager_secret_iam_member.sandbox_url_accessor, google_secret_manager_secret_iam_member.sandbox_anon_accessor, google_secret_manager_secret_iam_member.sandbox_service_accessor, ] # CI will push image tags and roll new revisions out-of-band. Keep terraform # from undoing those rollouts. lifecycle { ignore_changes = [ template[0].containers[0].image, client, client_version, ] } } # Allow public invocation -- Cloudflare Access is the gate, not Cloud Run auth. resource "google_cloud_run_v2_service_iam_member" "neuron_sandbox_public" { project = var.project_id location = "us-central1" name = google_cloud_run_v2_service.neuron_sandbox.name role = "roles/run.invoker" member = "allUsers" } # ── Cloud Run Domain Mapping ───────────────────────────────────────────────── # Maps sandbox.neurontechnologies.ai → neuron-sandbox-us. Cloud Run handles # managed cert provisioning via the CNAME pointing to ghs.googlehosted.com. resource "google_cloud_run_domain_mapping" "sandbox" { name = "sandbox.neurontechnologies.ai" location = "us-central1" project = var.project_id metadata { namespace = var.project_id labels = local.sandbox_labels } spec { route_name = google_cloud_run_v2_service.neuron_sandbox.name } } # ── Cloudflare DNS — sandbox.neurontechnologies.ai ─────────────────────────── # CNAME to ghs.googlehosted.com. The wildcard A record (*.neurontechnologies.ai # → LB IP) lives in dns-gcp.tf and would otherwise route sandbox to soma; this # more specific CNAME wins via DNS specificity at Cloudflare. # # Proxied = false: Cloudflare Access works over orange-cloud, but the simpler # pattern for Cloud Run domain mappings is grey-cloud (DNS-only) so the # managed cert handshake completes against the Cloud Run frontend. Cloudflare # Access still applies because the Access app is keyed to the hostname, not # the proxy mode. # # NOTE: For Cloudflare Access to intercept the request we MUST proxy through # Cloudflare. So proxied = true. The Cloud Run domain mapping issues a Google # managed cert; Cloudflare's Universal SSL covers the edge. Both certs coexist. resource "cloudflare_record" "sandbox" { zone_id = var.cloudflare_zone_id_neurontechnologies name = "sandbox" type = "CNAME" content = "ghs.googlehosted.com" proxied = true ttl = 1 } # ── Cloudflare Zero Trust Access ───────────────────────────────────────────── # Single application, single policy: allow only email_domain == # neurontechnologies.ai. No service tokens. No mTLS. No bypass paths. # # auto_redirect_to_identity = true sends users straight to the Google IdP # without showing the Cloudflare Access selector page. resource "cloudflare_zero_trust_access_application" "sandbox" { zone_id = var.cloudflare_zone_id_neurontechnologies name = "Neuron Sandbox" domain = "sandbox.neurontechnologies.ai" type = "self_hosted" session_duration = "8h" allowed_idps = ["808f1913-5a8e-4a97-9e68-8ec58e362110"] # Google auto_redirect_to_identity = true } resource "cloudflare_zero_trust_access_policy" "sandbox_only_neurontech" { application_id = cloudflare_zero_trust_access_application.sandbox.id account_id = var.cloudflare_account_id name = "Allow only @neurontechnologies.ai" precedence = 1 decision = "allow" include { email_domain = ["neurontechnologies.ai"] } } # ── Outputs ────────────────────────────────────────────────────────────────── output "sandbox_service_account_email" { description = "Sandbox SA email -- has zero prod IAM bindings" value = google_service_account.neuron_sandbox.email } output "sandbox_cloud_run_url" { description = "Direct Cloud Run URL (NOT the front door -- use sandbox.neurontechnologies.ai via CF Access)" value = google_cloud_run_v2_service.neuron_sandbox.uri } output "sandbox_access_application_id" { description = "Cloudflare Access application ID for sandbox.neurontechnologies.ai" value = cloudflare_zero_trust_access_application.sandbox.id } output "sandbox_access_aud" { description = "Cloudflare Access AUD tag (used for JWT verification if app ever needs it)" value = cloudflare_zero_trust_access_application.sandbox.aud } output "sandbox_dns_record_id" { description = "Cloudflare DNS record ID for sandbox CNAME" value = cloudflare_record.sandbox.id }