Files
infrastructure/servers/gcp/sandbox.tf
Will Anderson 40c6a31784 add sandbox.neurontechnologies.ai - internal-only experimentation env
Locked down to email_domain == neurontechnologies.ai via Cloudflare
Access. Dedicated SA with zero prod IAM. Isolated Supabase project
(not a schema in prod). No outbound email path. /api/share and /said
return 410 Gone in sandbox so the public-artifact surface stays
strictly in prod.

Stub deploy ready. Real soul build pipeline lands separately.
2026-05-02 12:53:16 -05:00

405 lines
14 KiB
Terraform

# ── 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 <secret> --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
}