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:
| System | What gets created manually | Time |
|---|---|---|
| Azure / Entra ID | App registration, service principal, client secret, RBAC role assignments (Reader, Azure AI User), Graph API permissions, admin consent | ~15 min |
| Azure AI Foundry | AIServices account, project, AI agents, connections | ~20 min |
| ServiceNow | Business 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)
| Concern | Terraform | Python scripts |
|---|---|---|
| Azure app regs, RBAC | Native provider, declarative, state-tracked | Possible via SDK but no drift detection |
| AWS IAM roles, policies | Native provider, mature | Possible via boto3 |
| GCP service accounts | Native provider, mature | Possible via google-cloud-iam |
| ServiceNow config | Community provider exists but weak — SN's config model (scripts in records, OAuth chains) doesn't map well to HCL | Table API is the standard SN automation approach; scripts can embed JS source code |
| Foundry agents/connections | No Terraform provider | REST API + setup_foundry_env.py already works |
| State management | Built-in (local or remote backend) | Manual — teardown must know what was created |
| Drift detection | terraform plan shows drift | Not available |
| Team familiarity | New tool for team | Same 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)
- Create a Terraform module in
infra/modules/<provider>-identity/ - Module provisions the read-only identity the connector needs
- Connector's
setup_dev_env.pycalls Terraform + any provider-specific API setup - Connector's
.env.exampledocuments the output variables
If the source system is a SaaS platform (ServiceNow, PagerDuty, Snowflake)
- Add helpers to
sv0_devenv/<system>.pywrapping the system's API - Connector's
setup_dev_env.pycalls those helpers - Store fixture data (scripts, config payloads) in
scripts/fixtures/
Checklist for new connector dev environment support
-
.env.examplelists all required credentials with descriptions -
scripts/setup_dev_env.pyexists with--provision,--teardown,--dry-run - Terraform module created (if cloud provider)
- Fixture data committed (SaaS config payloads, script sources)
-
Makefiletarget: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_devenvshared package withcli.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.pyto 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/andgcp-identity/ - Add
sv0_devenv/aws.pyandsv0_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
| Approach | Why not (for now) |
|---|---|
| Terraform only | ServiceNow's config model (scripts in DB records, OAuth chains) maps poorly to HCL. No mature provider. |
| Python scripts only | Works 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 APIs | Good for CI (already using ci_report.py fixtures) but doesn't exercise real APIs. Complementary, not a replacement. |
| ServiceNow Update Sets | Native SN mechanism but requires manual export/import. Useful as a fallback if Table API scripting proves brittle. |
Open questions
- Terraform state backend: Use local state for dev environments and Azure Blob for shared? Or start with all-local?
- 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?
- ServiceNow PDI lifecycle: PDIs reset periodically. Should
setup_dev_env.pybe idempotent (check-before-create) to handle re-provisioning after reset? - Credential rotation: Should
teardownrotate/invalidate secrets, or just delete the resources? - Monorepo or separate repo for
infra/: Keep insv0-connectors(co-located with the code that uses it) or separatesv0-infrarepo?