diff --git a/servers/gcp/sandbox.tf b/servers/gcp/sandbox.tf new file mode 100644 index 0000000..0fa2aae --- /dev/null +++ b/servers/gcp/sandbox.tf @@ -0,0 +1,404 @@ +# ── 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 +} diff --git a/servers/gcp/sandbox/README.md b/servers/gcp/sandbox/README.md new file mode 100644 index 0000000..8b78eca --- /dev/null +++ b/servers/gcp/sandbox/README.md @@ -0,0 +1,168 @@ +# sandbox.neurontechnologies.ai + +Internal-only experimentation environment. The real soul (with safety stripped) +runs here when the build pipeline lands. Sandbox is intentionally separate +from stage so the stage path stays clean for normal QA work. + +The terraform itself lives at `servers/gcp/sandbox.tf` (single file, mirrors +the existing flat-file layout under `servers/gcp/`). This directory holds +documentation only. + +## What is sandbox + +| Layer | Where | +|------------------|-------------------------------------------------------------| +| Domain | `sandbox.neurontechnologies.ai` | +| Cloud Run | `neuron-sandbox-us` (us-central1, single region) | +| Image registry | `us-central1-docker.pkg.dev/neuron-785695/neuron-sandbox/` | +| Service account | `neuron-sandbox@neuron-785695.iam.gserviceaccount.com` | +| Access control | Cloudflare Zero Trust Access, single rule | +| Identity rule | `email_domain == neurontechnologies.ai` | +| Supabase | Separate project (NOT a schema in prod's project) | +| Secret Manager | `sandbox-supabase-{url,anon-key,service-key}` | + +## How to get in + +1. Sign in with your `@neurontechnologies.ai` Google account at + `https://sandbox.neurontechnologies.ai/`. Cloudflare Access intercepts + the request, redirects you to the Google IdP, and only allows + `email_domain == neurontechnologies.ai` through. +2. After auth, you land on the running stub. With the stub deployed, the + root response is: + ```json + {"env":"sandbox","status":"ready","soul":"not_loaded"} + ``` +3. There are no service tokens, no mTLS certs, no bypass paths. The single + policy is the entire access surface. + +Verify the lockdown from a shell at any time: + +```bash +curl -sI https://sandbox.neurontechnologies.ai/ | head -2 +# HTTP/2 302 +# location: https://neuralplatform.cloudflareaccess.com/cdn-cgi/access/login/sandbox.neurontechnologies.ai?... +``` + +If the first line is anything other than 302 to the cloudflareaccess domain, +something has broken. Treat it as a P0. + +## What's NOT in sandbox + +- **No outbound email.** `RESEND_API_KEY` is intentionally not mounted on + `neuron-sandbox-us`. Defense-in-depth against an experimental soul + accidentally emailing real users. +- **No public share endpoints.** `/api/share`, `/said`, and `/share/` + return `410 Gone` with `{"error":"not_available_in_sandbox"}`. Public + artifact distribution stays in prod. +- **No prod data.** Sandbox uses an isolated Supabase project. Nothing in + the prod Supabase is reachable from the sandbox SA. +- **No prod IAM.** The `neuron-sandbox` SA has zero project-level role + bindings. Its only grants are `roles/secretmanager.secretAccessor` on the + three sandbox-specific secrets. Verify with: + ```bash + # As neuron-sandbox SA, all of these MUST return PERMISSION_DENIED: + gcloud secrets versions access latest --secret=stripe-secret-key + gcloud secrets versions access latest --secret=anthropic-api-key + gcloud secrets versions access latest --secret=supabase-service-key + gcloud run services describe marketing-prod-us --region=us-central1 + ``` +- **No prod LLM credentials.** Sandbox does NOT mount `anthropic-api-key`. + When the real soul lands, it will need its own sandbox-only LLM key + (cheaper key, lower rate limits) provisioned and granted to the sandbox SA. + +## Provision the isolated Supabase project (one-time, manual) + +There is no Supabase Management API token in `~/Secrets/api-keys/` or in +GCP Secret Manager today, so the project creation is a manual step. + +1. Sign in to https://supabase.com/dashboard with the Neuron Technologies + org owner account. +2. New project. Name: `neuron-sandbox`. Region: `us-east-1` (closest to + us-central1 Cloud Run latency-wise; single-region experimental env, so + geo doesn't matter much). +3. After provisioning, capture from Project Settings -> API: + - Project URL: `https://.supabase.co` + - `anon` public key + - `service_role` secret key +4. Push them into Secret Manager (this overwrites the placeholder versions + that terraform created): + ```bash + echo -n 'https://.supabase.co' | gcloud secrets versions add \ + sandbox-supabase-url --data-file=- --project=neuron-785695 + echo -n '' | gcloud secrets versions add \ + sandbox-supabase-anon-key --data-file=- --project=neuron-785695 + echo -n '' | gcloud secrets versions add \ + sandbox-supabase-service-key --data-file=- --project=neuron-785695 + ``` +5. Force a new sandbox revision so it picks up the secrets: + ```bash + gcloud run services update neuron-sandbox-us --region=us-central1 \ + --project=neuron-785695 \ + --image=us-central1-docker.pkg.dev/neuron-785695/neuron-sandbox/sandbox:initial + ``` +6. Do NOT seed any tables in this project yet. Real soul might want a + different schema than the demo soul. Schema lands when real soul lands. + +When a Supabase Management API token does become available, automate this +by writing a small script in `scripts/` that calls +`POST https://api.supabase.com/v1/projects` and immediately writes the +returned values back to Secret Manager. Track that as a follow-up. + +## Deploy a new build (manual, until CI is wired) + +```bash +cd ~/Development/neuron-technologies/products/sandbox-stub # or real soul source +bash ./build.sh # cross-compile + push to AR +gcloud run services update neuron-sandbox-us \ + --region=us-central1 \ + --project=neuron-785695 \ + --image=us-central1-docker.pkg.dev/neuron-785695/neuron-sandbox/sandbox: +``` + +The terraform `google_cloud_run_v2_service.neuron_sandbox` has +`lifecycle.ignore_changes = [template[0].containers[0].image]` so CI +rollouts won't be undone by the next `terraform apply`. + +CI wiring lands as part of the real-soul-build follow-up backlog item +(see `mcp__neuron__planWork` for the tracked item). + +## Disaster recovery + +Sandbox is disposable. If state gets weird, nuke it and rebuild: + +```bash +cd ~/Development/infrastructure/servers/gcp +terraform destroy \ + -target=cloudflare_zero_trust_access_policy.sandbox_only_neurontech \ + -target=cloudflare_zero_trust_access_application.sandbox \ + -target=cloudflare_record.sandbox \ + -target=google_cloud_run_domain_mapping.sandbox \ + -target=google_cloud_run_v2_service_iam_member.neuron_sandbox_public \ + -target=google_cloud_run_v2_service.neuron_sandbox \ + -target=google_secret_manager_secret_iam_member.sandbox_url_accessor \ + -target=google_secret_manager_secret_iam_member.sandbox_anon_accessor \ + -target=google_secret_manager_secret_iam_member.sandbox_service_accessor \ + -target=google_secret_manager_secret_version.sandbox_supabase_url_placeholder \ + -target=google_secret_manager_secret_version.sandbox_supabase_anon_key_placeholder \ + -target=google_secret_manager_secret_version.sandbox_supabase_service_key_placeholder \ + -target=google_secret_manager_secret.sandbox_supabase_url \ + -target=google_secret_manager_secret.sandbox_supabase_anon_key \ + -target=google_secret_manager_secret.sandbox_supabase_service_key \ + -target=google_service_account.neuron_sandbox \ + -target=google_artifact_registry_repository.sandbox + +# Then re-apply: +terraform apply +``` + +If the Supabase project was provisioned, also delete it from the Supabase +dashboard and re-create with a new name. The Secret Manager secrets carry +forward as placeholders; rewrite them with the new project's values. + +## Files + +| File | What | +|-------------------------------------------------------|-------------------------------------| +| `servers/gcp/sandbox.tf` | All sandbox terraform | +| `servers/gcp/sandbox/README.md` | This document | +| `~/Development/neuron-technologies/products/sandbox-stub/` | Stub source code (Go, 4 routes) | diff --git a/servers/gcp/terraform.tfvars b/servers/gcp/terraform.tfvars index f3475c2..08655d8 100644 --- a/servers/gcp/terraform.tfvars +++ b/servers/gcp/terraform.tfvars @@ -1,3 +1,4 @@ cloudflare_zone_id_neurontechnologies = "e844374f203dca4944d77d40ca0710ae" cloudflare_api_key = "007bbefe03eac0e502c339423c50dd911776a" cloudflare_email = "andersonwilliam85@gmail.com" +cloudflare_account_id = "651161e0a3d321561b4c90b5bcd5f15b" diff --git a/servers/gcp/variables.tf b/servers/gcp/variables.tf index 3b42fba..0678419 100644 --- a/servers/gcp/variables.tf +++ b/servers/gcp/variables.tf @@ -41,6 +41,12 @@ variable "cloudflare_email" { type = string } +variable "cloudflare_account_id" { + description = "Cloudflare account ID (used for Zero Trust Access policies)" + type = string + sensitive = true +} + locals { project_id = var.project_id