Cloud Onboarding in 10 Minutes: IAM Templates for AWS, GCP, and Azure
TL;DR
- Eight pre-built IAM templates (CloudFormation, Terraform, CLI scripts) connect AWS, GCP, and Azure to agent.ceo in about 10 minutes.
- Every template enforces the same principle: inventory access, never data access. Your agents see that an S3 bucket exists but cannot read a single byte from inside it.
- A 12-hour K8s CronJob keeps your infrastructure map current automatically.
Enterprise security teams always ask the same question first: "What access do your agents actually get?"
The answer is: read-only inventory access with explicit deny-data policies. Your agents can see that an S3 bucket exists, what region it is in, whether encryption is enabled, and what tags it has. They cannot read a single byte from inside that bucket. Same principle across all three major cloud providers.
A cyborgenic organization needs live infrastructure awareness -- agents that understand your cloud topology without ever touching your data. This week we shipped the IAM templates that make that concrete: eight files covering AWS, GCP, and Azure that a customer can review, run, and have their cloud accounts connected to agent.ceo in about ten minutes. No sales calls. No manual credential configuration. Run the template, and your agents have infrastructure visibility.
Here is exactly how it works.
The Flow
Before we get into provider-specific templates, here is the end-to-end flow:
- Customer runs IAM template -- creates a read-only role/service principal in their cloud account
- Credentials stored as Kubernetes secret -- our
setup-cloud-credentials.shscript writes them to a per-org K8s secret - Discovery Engine runs on a 12-hour schedule -- a CronJob triggers the scan cycle
- Infrastructure mapped to Neo4j graph -- resources, relationships, and metadata written to the knowledge graph
- Agents have full topology awareness -- any agent in your org can query infrastructure state through MCP tools
The whole pipeline is automated after step 1. You run the template once. Discovery keeps your infrastructure map current every 12 hours from that point forward.
AWS: Cross-Account Role with Deny-Data Policy
For AWS, we use a cross-account IAM role. Your account trusts our Discovery Engine's AWS account to assume a role with tightly scoped permissions. Two template formats are available: CloudFormation for teams that standardize on AWS-native tooling, and Terraform for multi-cloud shops.
Here is the CloudFormation template:
# aws-readonly-role.cfn.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: agent.ceo read-only discovery role with deny-data policy
Parameters:
TrustedAccountId:
Type: String
Description: agent.ceo Discovery Engine AWS account ID
ExternalId:
Type: String
Description: Unique external ID for your organization (provided during signup)
Resources:
AgentCeoDiscoveryRole:
Type: AWS::IAM::Role
Properties:
RoleName: agent-ceo-discovery
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub 'arn:aws:iam::${TrustedAccountId}:root'
Action: 'sts:AssumeRole'
Condition:
StringEquals:
'sts:ExternalId': !Ref ExternalId
ManagedPolicyArns:
- arn:aws:iam::aws:policy/ReadOnlyAccess
Policies:
- PolicyName: deny-data-access
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Deny
Action:
- 's3:GetObject'
- 's3:GetObjectVersion'
- 'dynamodb:GetItem'
- 'dynamodb:Query'
- 'dynamodb:Scan'
- 'dynamodb:BatchGetItem'
- 'rds-data:ExecuteStatement'
- 'rds-data:BatchExecuteStatement'
- 'sqs:ReceiveMessage'
- 'kinesis:GetRecords'
- 'secretsmanager:GetSecretValue'
- 'ssm:GetParameter*'
Resource: '*'
The deny-data policy is the critical part. AWS's ReadOnlyAccess managed policy includes permissions to read S3 objects, query DynamoDB tables, and pull secrets. We explicitly deny all of those. The result: Discovery Engine can list and describe every resource in the account -- EC2 instances, RDS databases, S3 buckets, VPCs, security groups -- but it cannot read any data stored in those resources.
The ExternalId condition on the trust policy prevents the confused deputy problem. Each customer gets a unique external ID during signup. Even if someone else knows our account ID, they cannot assume your role without your external ID.
The Terraform equivalent:
# aws-readonly-role.tf
resource "aws_iam_role" "agent_ceo_discovery" {
name = "agent-ceo-discovery"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.trusted_account_id}:root" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = { "sts:ExternalId" = var.external_id }
}
}]
})
managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
inline_policy {
name = "deny-data-access"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Deny"
Action = [
"s3:GetObject", "s3:GetObjectVersion",
"dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan",
"dynamodb:BatchGetItem", "rds-data:ExecuteStatement",
"rds-data:BatchExecuteStatement", "sqs:ReceiveMessage",
"kinesis:GetRecords", "secretsmanager:GetSecretValue",
"ssm:GetParameter*"
]
Resource = "*"
}]
})
}
}
Same permissions, same deny-data policy, different provisioning tool. Pick whichever fits your workflow.
GCP: Viewer + Security Reviewer Roles
GCP uses a service account with two predefined roles: roles/viewer for resource inventory and roles/iam.securityReviewer for IAM policy inspection. No custom roles needed.
The gcloud script:
#!/usr/bin/env bash
# gcp-readonly-setup.sh
set -euo pipefail
PROJECT_ID="${1:?Usage: gcp-readonly-setup.sh <project-id>}"
SA_NAME="agent-ceo-discovery"
SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
# Create service account
gcloud iam service-accounts create "$SA_NAME" \
--project="$PROJECT_ID" \
--display-name="agent.ceo Discovery Engine" \
--description="Read-only access for agent.ceo infrastructure discovery"
# Grant viewer role -- enumerate all resources
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/viewer" \
--condition=None
# Grant security reviewer -- inspect IAM policies and security configs
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/iam.securityReviewer" \
--condition=None
# Create and download key
gcloud iam service-accounts keys create "./agent-ceo-discovery-key.json" \
--iam-account="$SA_EMAIL"
echo "Service account created: ${SA_EMAIL}"
echo "Key saved to: ./agent-ceo-discovery-key.json"
echo "Next: run setup-cloud-credentials.sh to store this key as a K8s secret"
GCP's roles/viewer is already read-only -- it does not grant access to data inside resources. There is no equivalent of AWS's ReadOnlyAccess data-read problem. The securityReviewer role adds the ability to inspect IAM bindings and organization policies, which Discovery Engine uses to flag overly permissive service accounts and public-facing resources.
The Terraform version creates the same service account and bindings declaratively:
# gcp-readonly-sa.tf
resource "google_service_account" "agent_ceo_discovery" {
account_id = "agent-ceo-discovery"
display_name = "agent.ceo Discovery Engine"
description = "Read-only access for agent.ceo infrastructure discovery"
project = var.project_id
}
resource "google_project_iam_member" "viewer" {
project = var.project_id
role = "roles/viewer"
member = "serviceAccount:${google_service_account.agent_ceo_discovery.email}"
}
resource "google_project_iam_member" "security_reviewer" {
project = var.project_id
role = "roles/iam.securityReviewer"
member = "serviceAccount:${google_service_account.agent_ceo_discovery.email}"
}
For multi-project orgs, run the script once per project, or use the Terraform module with a for_each over your project IDs. Discovery Engine will merge results across all projects into a single graph.
Azure: Reader Role Service Principal
Azure uses a service principal with the built-in Reader role. This grants list and describe access across all resource types -- VMs, databases, storage accounts, virtual networks -- without any data-plane access.
The az CLI script:
#!/usr/bin/env bash
# azure-readonly-setup.sh
set -euo pipefail
SUBSCRIPTION_ID="${1:?Usage: azure-readonly-setup.sh <subscription-id>}"
SP_NAME="agent-ceo-discovery"
# Create service principal with Reader role scoped to subscription
SP_OUTPUT=$(az ad sp create-for-rbac \
--name "$SP_NAME" \
--role "Reader" \
--scopes "/subscriptions/${SUBSCRIPTION_ID}" \
--output json)
echo "$SP_OUTPUT" > ./agent-ceo-discovery-sp.json
APP_ID=$(echo "$SP_OUTPUT" | jq -r '.appId')
TENANT=$(echo "$SP_OUTPUT" | jq -r '.tenant')
echo "Service principal created:"
echo " App ID: ${APP_ID}"
echo " Tenant: ${TENANT}"
echo " Scope: /subscriptions/${SUBSCRIPTION_ID}"
echo " Credentials saved to: ./agent-ceo-discovery-sp.json"
echo "Next: run setup-cloud-credentials.sh to store these credentials as a K8s secret"
Azure's Reader role is clean -- it is a control-plane-only role that does not include any data-plane actions. No blob reads, no database queries, no key vault secret access. Discovery Engine sees the resource metadata (SKU, location, network config, tags) and nothing else.
The Terraform version:
# azure-readonly-sp.tf
data "azurerm_subscription" "current" {}
resource "azuread_application" "agent_ceo_discovery" {
display_name = "agent-ceo-discovery"
}
resource "azuread_service_principal" "agent_ceo_discovery" {
client_id = azuread_application.agent_ceo_discovery.client_id
}
resource "azuread_service_principal_password" "agent_ceo_discovery" {
service_principal_id = azuread_service_principal.agent_ceo_discovery.id
end_date_relative = "8760h" # 1 year
}
resource "azurerm_role_assignment" "reader" {
scope = data.azurerm_subscription.current.id
role_definition_name = "Reader"
principal_id = azuread_service_principal.agent_ceo_discovery.object_id
}
For orgs with multiple subscriptions, scope the role assignment to a management group instead, or apply per-subscription.
Credential Storage and Discovery Orchestration
Once you have created the IAM role or service principal, two pieces of infrastructure connect it to the Discovery Engine pipeline.
setup-cloud-credentials.sh
This script takes the credentials produced by the provider-specific templates and stores them as Kubernetes secrets, scoped to your organization's namespace:
#!/usr/bin/env bash
# setup-cloud-credentials.sh
set -euo pipefail
ORG_ID="${1:?Usage: setup-cloud-credentials.sh <org-id> <provider> <credentials-file>}"
PROVIDER="${2:?Specify provider: aws|gcp|azure}"
CREDS_FILE="${3:?Path to credentials file}"
SECRET_NAME="cloud-discovery-${ORG_ID}-${PROVIDER}"
NAMESPACE="agent-ceo"
kubectl create secret generic "$SECRET_NAME" \
--namespace="$NAMESPACE" \
--from-file=credentials="$CREDS_FILE" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl label secret "$SECRET_NAME" \
--namespace="$NAMESPACE" \
app.kubernetes.io/component=cloud-discovery \
agent.ceo/org-id="$ORG_ID" \
agent.ceo/provider="$PROVIDER" \
--overwrite
echo "Secret ${SECRET_NAME} created in namespace ${NAMESPACE}"
Each org gets its own secret per provider. Labels make them discoverable by the CronJob. Secrets are namespaced and RBAC-scoped -- the Discovery Engine service account can read them, nothing else can.
The 12-Hour Discovery Refresh CronJob
A Kubernetes CronJob triggers the full discovery cycle every 12 hours:
# cloud-discovery-refresh-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: cloud-discovery-refresh
namespace: agent-ceo
spec:
schedule: "0 */12 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
template:
spec:
serviceAccountName: cloud-discovery
restartPolicy: OnFailure
containers:
- name: discovery-refresh
image: gcr.io/agent-ceo/cloud-discovery:latest
command: ["python", "-m", "discovery.run_all"]
env:
- name: NEO4J_URI
valueFrom:
secretKeyRef:
name: neo4j-credentials
key: uri
- name: NEO4J_PASSWORD
valueFrom:
secretKeyRef:
name: neo4j-credentials
key: password
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
The CronJob runs at midnight and noon UTC. concurrencyPolicy: Forbid ensures only one scan runs at a time -- if a scan takes longer than 12 hours (it should not, but defense in depth), the next scheduled run is skipped rather than stacking. The activeDeadlineSeconds: 3600 kills any stuck job after one hour.
The discovery.run_all entrypoint enumerates all cloud-discovery-* secrets in the namespace, loads credentials for each org and provider, runs the appropriate connector, and writes results to Neo4j. A single CronJob handles all customers across all providers.
What Happens After Discovery Runs
Once the first scan completes, your infrastructure appears in the Neo4j knowledge graph. Every resource becomes a node with typed relationships -- VMs linked to networks, databases linked to projects, storage accounts linked to resource groups.
Your agents immediately gain access to this data through the existing MCP tools:
- List resources by provider, type, region, or tag
- Query relationships -- which VMs are in which VPC, which databases are in which project
- Search for specific conditions -- public IPs, unencrypted storage, resources missing required tags
- Cross-reference with other Discovery data -- the Slack, CI/CD, and GitHub connectors already in the graph
The 12-hour refresh cycle means the graph stays current without manual intervention. New resources appear in the next scan. Terminated resources get marked as decommissioned. Tag changes propagate automatically.
Security Model Summary
Every template in this set enforces the same principle: inventory access, never data access.
| Provider | Role/Policy | Can Inventory | Cannot Access |
|---|---|---|---|
| AWS | ReadOnlyAccess + deny-data | EC2, RDS, S3, VPC, SG, IAM | S3 objects, DynamoDB items, secrets, parameters |
| GCP | Viewer + SecurityReviewer | Compute, Cloud SQL, GCS, VPC, IAM | File contents, database rows, secret values |
| Azure | Reader | VMs, SQL, Storage, VNets, NSGs | Blob data, database contents, Key Vault secrets |
The templates are open for review before you run them. No obfuscated scripts, no opaque binaries. Your security team can read every permission being granted, verify the deny policies, and confirm that data-plane access is explicitly blocked.
Run It
Pick your provider. Run the template. Run setup-cloud-credentials.sh. The next CronJob cycle picks up your credentials and starts mapping.
Total time from "I want to connect my AWS account" to "my agents can see my infrastructure": about ten minutes. Most of that is waiting for CloudFormation to finish creating the role.
The templates, scripts, and CronJob manifest are all available in the onboarding section of your agent.ceo dashboard. If you are evaluating agent.ceo for your team, this is the onboarding experience -- not a sales call and a three-week integration project. Eight files, read-only access, and your agents understand your infrastructure by the next scan cycle.
Cloud onboarding templates for AWS, GCP, and Azure are available now. Read-only access, deny-data policies, 12-hour automated refresh. Build your own cyborgenic organization at agent.ceo.