Skip to main content

Dev Environment Automation Proposal

Date: 2026-03-02 Status: Proposal Goal: Enable any developer to provision isolated source system environments (Azure, AWS, GCP, ServiceNow) in minutes, so they can run connectors and test the platform without manual UI setup.


Problem

Today every source system is configured by hand through web UIs:

SystemWhat gets created manuallyTime
Azure / Entra IDApp registration, service principal, client secret, RBAC role assignments (Reader, Azure AI User), Graph API permissions, admin consent~15 min
Azure AI FoundryAIServices account, project, AI agents, connections~20 min
ServiceNowBusiness Rules, Script Includes, REST Messages, OAuth Entities/Profiles/Providers~30 min

This means:

  • A new developer spends 1+ hour before they can run a single scan
  • Configuration can't be shared, reviewed, or version-controlled
  • No way to spin up isolated test environments
  • Teardown is manual — leftover resources accumulate
  • Adding AWS/GCP connectors multiplies the problem

Approach: Terraform modules + per-connector Python scripts

Split the automation into two layers based on what each tool does best.

Layer 1 — Terraform modules for cloud identity provisioning

Cloud providers (Azure, AWS, GCP) have mature Terraform providers. Identity provisioning — app registrations, IAM roles, service accounts — maps cleanly to declarative IaC.

sv0-connectors/
infra/
modules/
azure-identity/ # App registration, SP, Graph permissions, RBAC
azure-foundry/ # AIServices account, project, role assignments
aws-identity/ # IAM role, trust policy, managed policies
gcp-identity/ # Service account, IAM role bindings
envs/
dev-<name>/ # Per-developer Terraform workspace
main.tf # Instantiates modules with dev-specific params
terraform.tfvars # Developer-specific values
outputs.tf # Exports credentials for .env

Layer 2 — Python setup scripts for SaaS and connector-specific config

Systems like ServiceNow don't have good Terraform providers. Their configuration model (Business Rules with embedded scripts, OAuth profiles referencing providers) is better handled by API scripts. Foundry agent creation and connection setup also falls here.

Each connector already has a scripts/ folder. Add a setup_dev_env.py following the existing setup_foundry_env.py pattern.

sv0-connectors/
shared/
sv0_devenv/ # NEW shared package
__init__.py
azure.py # create_app_registration(), assign_role(), grant_graph_permissions()
aws.py # create_iam_role(), attach_policy()
gcp.py # create_service_account(), bind_role()
servicenow.py # create_business_rule(), create_rest_message(), create_oauth_entity()
env_writer.py # write_dotenv(), merge_dotenv()
cli.py # Common CLI: --provision / --teardown / --dry-run / --dev-name
integrations/
entra-servicenow/
scripts/
setup_dev_env.py # Calls Terraform + seeds ServiceNow via Table API
azure-foundry/
scripts/
setup_foundry_env.py # Already exists — extend with Terraform identity step
aws-connector/
scripts/
setup_dev_env.py # Calls Terraform for IAM, writes .env

Terraform module designs

azure-identity — Entra ID app registration and permissions

Provisions everything documented in Foundry Pilot Permissions.

variable "dev_name" {
description = "Developer identifier (e.g. alice)"
type = string
}

variable "subscription_id" {
description = "Azure subscription to assign Reader role on"
type = string
}

variable "graph_permissions" {
description = "Graph API permission IDs to request"
type = list(string)
default = [
"9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30", # Application.Read.All
"7ab1d382-f21e-4acd-a863-ba3e13f7da61", # Directory.Read.All
"df021288-bdef-4463-88db-98f22de89214", # User.Read.All
]
}

resource "azuread_application" "connector" {
display_name = "sv0-connector-${var.dev_name}"

required_resource_access {
resource_app_id = "00000003-0000-0000-c000-000000000000" # Microsoft Graph

dynamic "resource_access" {
for_each = var.graph_permissions
content {
id = resource_access.value
type = "Role"
}
}
}
}

resource "azuread_service_principal" "connector" {
client_id = azuread_application.connector.client_id
}

resource "azuread_application_password" "connector" {
application_id = azuread_application.connector.id
display_name = "dev-${var.dev_name}"
end_date = timeadd(timestamp(), "8760h") # 1 year
}

resource "azurerm_role_assignment" "reader" {
scope = "/subscriptions/${var.subscription_id}"
role_definition_name = "Reader"
principal_id = azuread_service_principal.connector.object_id
}

output "azure_tenant_id" {
value = data.azuread_client_config.current.tenant_id
}

output "azure_client_id" {
value = azuread_application.connector.client_id
sensitive = true
}

output "azure_client_secret" {
value = azuread_application_password.connector.value
sensitive = true
}

aws-identity — IAM role for future AWS connector

variable "dev_name" {
type = string
}

variable "external_id" {
description = "External ID for cross-account trust"
type = string
}

resource "aws_iam_role" "connector" {
name = "sv0-connector-${var.dev_name}"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = var.trusted_account_arn }
Action = "sts:AssumeRole"
Condition = { StringEquals = { "sts:ExternalId" = var.external_id } }
}]
})
}

resource "aws_iam_role_policy_attachment" "readonly" {
role = aws_iam_role.connector.name
policy_arn = "arn:aws:iam::aws:policy/SecurityAudit"
}

resource "aws_iam_role_policy_attachment" "iam_read" {
role = aws_iam_role.connector.name
policy_arn = "arn:aws:iam::aws:policy/IAMReadOnlyAccess"
}

output "role_arn" {
value = aws_iam_role.connector.arn
}

gcp-identity — Service account for future GCP connector

variable "dev_name" {
type = string
}

variable "project_id" {
type = string
}

resource "google_service_account" "connector" {
account_id = "sv0-connector-${var.dev_name}"
display_name = "SecurityV0 Connector (${var.dev_name})"
project = var.project_id
}

resource "google_project_iam_member" "viewer" {
project = var.project_id
role = "roles/iam.securityReviewer"
member = "serviceAccount:${google_service_account.connector.email}"
}

resource "google_service_account_key" "connector" {
service_account_id = google_service_account.connector.name
}

output "credentials_json" {
value = base64decode(google_service_account_key.connector.private_key)
sensitive = true
}

Python setup scripts

sv0_devenv shared package

Reusable helpers that connector setup scripts compose. Uses the same SDKs that connectors already depend on.

# shared/sv0_devenv/cli.py

import argparse

def setup_cli(connector_name: str) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=f"Provision dev environment for {connector_name}"
)
parser.add_argument("--dev-name", required=True, help="Developer identifier")
parser.add_argument("--provision", action="store_true", help="Create resources")
parser.add_argument("--teardown", action="store_true", help="Destroy resources")
parser.add_argument("--dry-run", action="store_true", help="Preview without changes")
parser.add_argument("--use-cli-auth", action="store_true",
help="Use CLI login instead of env var credentials")
return parser.parse_args()
# shared/sv0_devenv/servicenow.py

import requests
from typing import Any

class ServiceNowProvisioner:
"""Creates ServiceNow configuration records via Table API."""

def __init__(self, instance: str, username: str, password: str,
dry_run: bool = False):
self.base_url = f"https://{instance}.service-now.com"
self.auth = (username, password)
self.dry_run = dry_run

def create_record(self, table: str, payload: dict[str, Any]) -> dict:
if self.dry_run:
print(f" [dry-run] Would create {table}: {payload.get('name', '?')}")
return {"sys_id": "dry-run"}
resp = requests.post(
f"{self.base_url}/api/now/table/{table}",
auth=self.auth,
json=payload,
headers={"Accept": "application/json"},
)
resp.raise_for_status()
return resp.json()["result"]

def create_oauth_entity(self, name: str, client_id: str,
client_secret: str, token_url: str) -> dict:
return self.create_record("oauth_entity", {
"name": name,
"client_id": client_id,
"client_secret": client_secret,
"token_url": token_url,
"type": "client",
})

def create_rest_message(self, name: str, endpoint: str,
auth_type: str = "oauth2") -> dict:
return self.create_record("sys_rest_message", {
"name": name,
"rest_endpoint": endpoint,
"authentication_type": auth_type,
})

def create_business_rule(self, name: str, table: str, script: str,
when: str = "after", on_insert: bool = True) -> dict:
return self.create_record("sys_script", {
"name": name,
"collection": table,
"when": when,
"action_insert": on_insert,
"script": script,
"active": True,
})

def create_script_include(self, name: str, script: str) -> dict:
return self.create_record("sys_script_include", {
"name": name,
"api_name": f"global.{name}",
"script": script,
"active": True,
"client_callable": False,
})

def teardown(self, created_sys_ids: dict[str, list[str]]) -> None:
for table, sys_ids in created_sys_ids.items():
for sys_id in sys_ids:
if self.dry_run:
print(f" [dry-run] Would delete {table}/{sys_id}")
continue
requests.delete(
f"{self.base_url}/api/now/table/{table}/{sys_id}",
auth=self.auth,
)
# shared/sv0_devenv/env_writer.py

from pathlib import Path

def write_dotenv(values: dict[str, str], path: str | Path = ".env") -> None:
path = Path(path)
lines = []
if path.exists():
existing = path.read_text().splitlines()
keys_to_write = set(values.keys())
for line in existing:
key = line.split("=", 1)[0].strip()
if key not in keys_to_write:
lines.append(line)
for key, val in values.items():
lines.append(f"{key}={val}")
path.write_text("\n".join(lines) + "\n")
print(f" Wrote {len(values)} vars to {path}")

Example: entra-servicenow/scripts/setup_dev_env.py

#!/usr/bin/env python3
"""
Provision a dev environment for the entra-servicenow connector.

Azure identity: delegates to Terraform (infra/modules/azure-identity/).
ServiceNow config: creates records via Table API using sv0_devenv.

Usage:
python scripts/setup_dev_env.py --dev-name alice --provision
python scripts/setup_dev_env.py --dev-name alice --teardown
python scripts/setup_dev_env.py --dev-name alice --provision --dry-run
"""

import json
import subprocess
from pathlib import Path

from sv0_devenv.cli import setup_cli
from sv0_devenv.env_writer import write_dotenv
from sv0_devenv.servicenow import ServiceNowProvisioner

INFRA_DIR = Path(__file__).resolve().parents[2] / "infra"
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"

def provision_azure(dev_name: str, dry_run: bool) -> dict[str, str]:
"""Run Terraform to create Azure app registration."""
env_dir = INFRA_DIR / "envs" / f"dev-{dev_name}"
env_dir.mkdir(parents=True, exist_ok=True)

# Write main.tf referencing the azure-identity module
main_tf = env_dir / "main.tf"
if not main_tf.exists():
main_tf.write_text(f'''
module "azure_identity" {{
source = "../../modules/azure-identity"
dev_name = "{dev_name}"
subscription_id = var.subscription_id
}}

output "azure_tenant_id" {{ value = module.azure_identity.azure_tenant_id }}
output "azure_client_id" {{ value = module.azure_identity.azure_client_id; sensitive = true }}
output "azure_client_secret" {{ value = module.azure_identity.azure_client_secret; sensitive = true }}
''')

cmd_prefix = ["terraform", f"-chdir={env_dir}"]
plan_args = cmd_prefix + ["plan"]
apply_args = cmd_prefix + ["apply", "-auto-approve"]

subprocess.run(cmd_prefix + ["init", "-upgrade"], check=True)

if dry_run:
subprocess.run(plan_args, check=True)
return {}

subprocess.run(apply_args, check=True)
result = subprocess.run(
cmd_prefix + ["output", "-json"],
capture_output=True, text=True, check=True,
)
outputs = json.loads(result.stdout)
return {
"AZURE_TENANT_ID": outputs["azure_tenant_id"]["value"],
"AZURE_CLIENT_ID": outputs["azure_client_id"]["value"],
"AZURE_CLIENT_SECRET": outputs["azure_client_secret"]["value"],
}


def provision_servicenow(dev_name: str, dry_run: bool) -> None:
"""Create ServiceNow configuration records via Table API."""
import os
sn = ServiceNowProvisioner(
instance=os.environ["SERVICENOW_INSTANCE"],
username=os.environ["SERVICENOW_USERNAME"],
password=os.environ["SERVICENOW_PASSWORD"],
dry_run=dry_run,
)

script_include_src = (FIXTURES_DIR / "AzureGraphRouter.js").read_text()
business_rule_src = (FIXTURES_DIR / "auto-route-br.js").read_text()

print(f"\n==> Provisioning ServiceNow for dev-{dev_name}")
sn.create_script_include("AzureGraphRouter", script_include_src)
sn.create_rest_message(
"Graph - sn-ticket-router",
"https://graph.microsoft.com/v1.0",
)
sn.create_business_rule(
"Auto-route identity tickets via Entra",
table="incident",
script=business_rule_src,
)
print(" ServiceNow configuration complete.")


def teardown_azure(dev_name: str, dry_run: bool) -> None:
env_dir = INFRA_DIR / "envs" / f"dev-{dev_name}"
cmd_prefix = ["terraform", f"-chdir={env_dir}"]
if dry_run:
subprocess.run(cmd_prefix + ["plan", "-destroy"], check=True)
else:
subprocess.run(cmd_prefix + ["destroy", "-auto-approve"], check=True)


def main():
args = setup_cli("entra-servicenow")

if args.provision:
print(f"\n==> Provisioning Azure identity for dev-{args.dev_name}")
azure_vars = provision_azure(args.dev_name, args.dry_run)
if azure_vars:
write_dotenv(azure_vars)
provision_servicenow(args.dev_name, args.dry_run)
print(f"\n Done. Run: entra-servicenow --all --submit")

elif args.teardown:
print(f"\n==> Tearing down dev-{args.dev_name}")
teardown_azure(args.dev_name, args.dry_run)
print(" Azure resources destroyed.")
print(" ServiceNow records: delete manually or via PDI reset.")


if __name__ == "__main__":
main()

Developer workflow

New developer — full setup

# Prerequisites: Terraform, Python 3.11+, az login, SN PDI credentials

# 1. Platform (already automated)
cd sv0-platform
docker compose up -d
npx tsx scripts/seed-demo-w1.ts --reset

# 2. Connector setup
cd ../sv0-connectors/integrations/entra-servicenow
make install # venv + dependencies + sv0_devenv

# 3. Provision source systems
python scripts/setup_dev_env.py --dev-name alice --provision
# → Terraform: creates Azure app reg, SP, permissions, role assignments
# → Python: creates ServiceNow BR, SI, REST Message, OAuth config
# → Writes .env with all credentials

# 4. Run the connector
entra-servicenow --all --submit \
--platform-url http://localhost:3000 \
--tenant-id alice

# 5. View results
open http://localhost:5173 # UI, switch tenant to "alice"

Teardown

python scripts/setup_dev_env.py --dev-name alice --teardown
# → Terraform destroy: removes Azure app reg, SP, role assignments
# → ServiceNow: PDI reset or manual cleanup

Preview without changes

python scripts/setup_dev_env.py --dev-name alice --provision --dry-run
# → Terraform plan (no apply)
# → ServiceNow: prints what would be created

Why this split (Terraform + Python scripts)

ConcernTerraformPython scripts
Azure app regs, RBACNative provider, declarative, state-trackedPossible via SDK but no drift detection
AWS IAM roles, policiesNative provider, maturePossible via boto3
GCP service accountsNative provider, maturePossible via google-cloud-iam
ServiceNow configCommunity provider exists but weak — SN's config model (scripts in records, OAuth chains) doesn't map well to HCLTable API is the standard SN automation approach; scripts can embed JS source code
Foundry agents/connectionsNo Terraform providerREST API + setup_foundry_env.py already works
State managementBuilt-in (local or remote backend)Manual — teardown must know what was created
Drift detectionterraform plan shows driftNot available
Team familiarityNew tool for teamSame Python + SDKs already used in connectors

The boundary is clear: Terraform handles cloud IAM, Python handles everything else.


Terraform state management

For developer environments (local state)

Each developer's Terraform state lives in infra/envs/dev-<name>/terraform.tfstate. This is .gitignored. Developers own their own state.

For shared environments (remote state)

Staging, QA, and production environments should use a remote backend:

# infra/envs/staging/backend.tf
terraform {
backend "azurerm" {
resource_group_name = "sv0-infra"
storage_account_name = "sv0tfstate"
container_name = "tfstate"
key = "staging.terraform.tfstate"
}
}

Scaling to new connectors

When adding a new connector (e.g. AWS, GCP, Snowflake, PagerDuty):

If the source system is a cloud provider (Azure, AWS, GCP)

  1. Create a Terraform module in infra/modules/<provider>-identity/
  2. Module provisions the read-only identity the connector needs
  3. Connector's setup_dev_env.py calls Terraform + any provider-specific API setup
  4. Connector's .env.example documents the output variables

If the source system is a SaaS platform (ServiceNow, PagerDuty, Snowflake)

  1. Add helpers to sv0_devenv/<system>.py wrapping the system's API
  2. Connector's setup_dev_env.py calls those helpers
  3. Store fixture data (scripts, config payloads) in scripts/fixtures/

Checklist for new connector dev environment support

  • .env.example lists all required credentials with descriptions
  • scripts/setup_dev_env.py exists with --provision, --teardown, --dry-run
  • Terraform module created (if cloud provider)
  • Fixture data committed (SaaS config payloads, script sources)
  • Makefile target: make setup-dev DEV_NAME=alice
  • README documents the setup flow

File layout summary

sv0-connectors/
infra/ # NEW — Terraform
modules/
azure-identity/
main.tf # App reg, SP, Graph perms, RBAC
variables.tf
outputs.tf
azure-foundry/
main.tf # AIServices, project, AI User role
variables.tf
outputs.tf
aws-identity/
main.tf # IAM role, policies
variables.tf
outputs.tf
gcp-identity/
main.tf # Service account, role bindings
variables.tf
outputs.tf
envs/
dev-alice/ # Per-developer (gitignored state)
main.tf
terraform.tfvars
staging/ # Shared (remote state)
main.tf
backend.tf
terraform.tfvars
.gitignore # *.tfstate, *.tfstate.backup

shared/
sv0_common/ # Exists — platform client
sv0_azure/ # Exists — Entra/ARM clients
sv0_devenv/ # NEW — provisioning helpers
__init__.py
cli.py
env_writer.py
azure.py
aws.py
gcp.py
servicenow.py

integrations/
entra-servicenow/
scripts/
setup_dev_env.py # NEW — Terraform + SN Table API
fixtures/
AzureGraphRouter.js # Script Include source
auto-route-br.js # Business Rule source
azure-foundry/
scripts/
setup_foundry_env.py # Exists — extend with Terraform step
aws-connector/ # Future
scripts/
setup_dev_env.py
gcp-connector/ # Future
scripts/
setup_dev_env.py

Implementation phases

Phase 1 — Foundation (1-2 days)

  • Create sv0_devenv shared package with cli.py, env_writer.py, servicenow.py
  • Create infra/modules/azure-identity/ Terraform module
  • Create entra-servicenow/scripts/setup_dev_env.py
  • Extract ServiceNow fixture scripts from the existing manual setup
  • Test: new developer provisions environment end-to-end

Phase 2 — Foundry extension (1 day)

  • Create infra/modules/azure-foundry/ Terraform module
  • Extend setup_foundry_env.py to use Terraform for identity, keep REST API for agents
  • Verify: azure-foundry connector runs against provisioned environment

Phase 3 — AWS and GCP (when connectors are built)

  • Create infra/modules/aws-identity/ and gcp-identity/
  • Add sv0_devenv/aws.py and sv0_devenv/gcp.py
  • Each new connector ships with its own setup_dev_env.py

Phase 4 — CI integration (when needed)

  • Remote Terraform state backend for shared environments
  • CI workflow provisions ephemeral environment, runs connector, tears down
  • Integration test matrix across connector types

Alternatives considered

ApproachWhy not (for now)
Terraform onlyServiceNow's config model (scripts in DB records, OAuth chains) maps poorly to HCL. No mature provider.
Python scripts onlyWorks but loses Terraform's state tracking and drift detection for cloud resources. Already partially implemented via setup_foundry_env.py.
Pulumi (Python)Single-language appeal but adds a new tool, state backend, and CLI. Doesn't solve the SaaS gap. Reconsider if team grows and Terraform fatigue sets in.
Docker mock APIsGood for CI (already using ci_report.py fixtures) but doesn't exercise real APIs. Complementary, not a replacement.
ServiceNow Update SetsNative SN mechanism but requires manual export/import. Useful as a fallback if Table API scripting proves brittle.

Open questions

  1. Terraform state backend: Use local state for dev environments and Azure Blob for shared? Or start with all-local?
  2. Admin consent automation: Graph API permission grants require admin consent. Can the setup script runner have sufficient privileges, or is this always a manual step?
  3. ServiceNow PDI lifecycle: PDIs reset periodically. Should setup_dev_env.py be idempotent (check-before-create) to handle re-provisioning after reset?
  4. Credential rotation: Should teardown rotate/invalidate secrets, or just delete the resources?
  5. Monorepo or separate repo for infra/: Keep in sv0-connectors (co-located with the code that uses it) or separate sv0-infra repo?