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.
This commit is contained in:
Will Anderson
2026-05-02 12:53:16 -05:00
parent 0c32964ead
commit 40c6a31784
4 changed files with 579 additions and 0 deletions
+404
View File
@@ -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 <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
}
+168
View File
@@ -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/<id>`
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://<ref>.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://<ref>.supabase.co' | gcloud secrets versions add \
sandbox-supabase-url --data-file=- --project=neuron-785695
echo -n '<anon-key>' | gcloud secrets versions add \
sandbox-supabase-anon-key --data-file=- --project=neuron-785695
echo -n '<service-key>' | 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:<tag>
```
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) |
+1
View File
@@ -1,3 +1,4 @@
cloudflare_zone_id_neurontechnologies = "e844374f203dca4944d77d40ca0710ae"
cloudflare_api_key = "007bbefe03eac0e502c339423c50dd911776a"
cloudflare_email = "andersonwilliam85@gmail.com"
cloudflare_account_id = "651161e0a3d321561b4c90b5bcd5f15b"
+6
View File
@@ -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