Terraform rebuilds a console-built AWS account from prod — read-only spec, parity diff, GitHub OIDC

Codifying a console-built AWS data layer in Terraform from a read-only capture of prod, verified by a config diff and run through GitHub OIDC.

The Terraform mark, four purple parallelograms forming a T, beside the Terraform wordmark in near-black on white
On this page

Introduction

Every resource in a production AWS data layer I work on had been built by hand in the console: S3 buckets, Glue tables, an OpenSearch Serverless collection, two Bedrock knowledge bases, a Lambda, and an AgentCore gateway and code interpreter. Nothing described any of it, so its configuration was hard to copy into another environment.

Then a test account was added, for testing new features before they reach production. It already ran the main application’s stack, and its data layer was empty.

I used it to write the data layer as a Terraform module based on a read-only capture of prod’s configuration, applied the module there, and diffed the two accounts until no unexplained difference was left.

The test account now rebuilds from the repository, which is the only place the data layer is defined. Every pull request gets a terraform plan from GitHub Actions through OIDC, and merging to main applies it to the test account.

Why Terraform and not CDK

Adopting existing resources decided it. The repository is Python, and CDK can be written in Python too, so the language would have matched. Terraform was ahead on import and on blast radius:

  1. Import. Both tools can bring a Bedrock knowledge base under management, and the difference is who writes the definition. terraform plan -generate-config-out writes the HCL from the live resource, so the next plan shows what changed in the configuration. cdk import needs a hand-written construct that exactly matches the live resource, and cdk import does not check that its properties are correct and complete.
Terraform writes the definition from the live resource; with CDK you write it by handOne console-built knowledge base on the left feeds two lanes. In the Terraform lane, an import block naming only the resource ID goes to terraform plan -generate-config-out, which reads the live resource and writes the HCL; the next plan shows any mismatch, and the resource ends up in Terraform state. In the CDK lane, a hand-written construct that must exactly match the live resource goes to cdk import, which asks for the resource names and runs a CloudFormation IMPORT change set, and the resource ends up in a CloudFormation stack. A warning under the CDK lane says nothing checks that the construct’s properties are correct and complete.Terraform: Terraform writes the definitionCDK: you write the definitionConsole-builtknowledge baselive resourceimport blockresource ID onlyterraform plan-generate-config-outreads thelive resourceGenerated HCLnext plan showsany mismatchTerraform stateunder managementHand-writtenconstructmust match the liveresource exactlycdk importasks for theresource namesCloudFormationIMPORT change setCloudFormationstackunder managementNo check that the construct’s properties are correct and complete
Both paths end with the knowledge base under management. Terraform writes the definition from the live resource and the next plan checks it; with CDK the construct is written by hand, and no step checks it.
  1. Blast radius. A CloudFormation stack that adopts resources ties them together, so one failed update rolls the whole stack back. Terraform applies resource by resource, so whatever succeeded stays and is recorded in state.
A failed resource undoes a whole CloudFormation update, but only itself in TerraformTwo panels run the same update, which changes resources A, B and C, and B fails. In the CloudFormation stack panel, A is rolled back, B failed, and C is rolled back or never started, so the stack returns to where it was before the update. In the Terraform panel, A is applied and recorded in state, B failed and is tried again on the next apply, and C is applied unless it depends on B, so state keeps whatever succeeded.One update changes A, B and C. B fails.CloudFormation stackARolled backits change is undoneBFailedtriggers the rollbackCRolled backundone, or never startedThe stack is back where it was before the updateTerraformAAppliedrecorded in stateBFailedtried again on the next applyCAppliedskipped only if it depends on BState keeps whatever succeeded
One update changes A, B and C, and B fails. CloudFormation undoes the whole update; Terraform keeps A in state and leaves only what depends on B undone.

CDK had one more advantage besides the language: state. CloudFormation keeps each stack’s state inside the service, and AWS handles its locking, history and rollback. The bucket CDK does create, through the CDKToolkit bootstrap stack both accounts already had, only stages assets.

Terraform’s state is a file in an S3 bucket in the test account, and that bucket needs a manual bootstrap. The bucket is itself created by Terraform, so until it exists the state has nowhere to go. The first apply keeps state on local disk, and once the bucket exists, terraform init -migrate-state copies that local state file into it.

The bootstrap happens once; the ownership does not. The state file can hold resource attributes in plain text, so the bucket needs the access control and versioning of any sensitive bucket, and a mismatch between state and the real resources is fixed with terraform state commands.

CDK state lives inside CloudFormation; Terraform state is a file you keep managingTwo panels. In the CDK panel, stack state lives inside the CloudFormation service, and the CDKToolkit stack, already bootstrapped, has a staging bucket that holds assets only; a strip says AWS handles locking, history and rollback. In the Terraform panel, a one-time bootstrap runs in three steps: the first apply writes state to local disk, the state bucket now exists, and terraform init -migrate-state copies the state into it. The bucket then holds terraform.tfstate, which can hold attributes in plain text and is locked with use_lockfile; a strip says you handle access, versioning and terraform state fixes.CDKCDKToolkit stackstaging bucket: assets onlyalready bootstrappedAWS handles locking, history, rollbackTerraformterraform.tfstate in the bucketcan hold attributes in plain textlocked with use_lockfileYou handle access, versioning, terraform state fixesCloudFormation servicestack state lives hereBootstrap, once1. applystate goes tolocal disk2. state bucketnow exists3. init-migrate-statecopies it in
CDK’s state lives inside CloudFormation and AWS looks after it. Terraform’s state is a file in a bucket that needs a manual bootstrap, and managing it never stops.

In the end the test account imported only two resources, security group rules I had added by hand, so import barely mattered there. The OpenSearch index mattered more: every way of letting Terraform own it has a cost, covered in its own section below, and CDK would not have handled it better.

The test account’s data layer started empty, so Terraform created almost everything new. Prod is different: every resource there already exists, built in the console and holding real data, so none of them can be deleted and recreated, and each one has to be imported into Terraform. That is when adopting existing resources really counts.

Prod as a read-only spec, test as the proof

Terraform never touches prod. I read prod once, with a script that calls only AWS APIs such as List, Get and Describe, and saved what it returned into a spec/ directory next to the module: the Glue column definitions, the vector index mapping, and the tool schema the AgentCore gateway exposes.

The Terraform module reads those files, so its configuration traces back to a resource that exists in prod.

Prod is read; the test account is built and then comparedThree lanes join a prod account on the left to a test account on the right. In the infrastructure lane, a capture script that only lists, gets and describes writes a spec directory, which a Terraform module reads and applies to the test account. In the data lane, a seed script exports under a prod session to a local cache and imports under a test session, rewriting IDs. In the verification lane, a snapshot diff reads both accounts and reports zero unexplained differences.InfrastructureData (separate script)VerificationProd accountread onlyCapture scriptList / Get / Describecalls onlyspec/Glue columnsindex mappinggateway toolsTerraform modulereads spec/Test accountterraform applyexportprod sessionLocal cachegitignoredimporttest sessionIDs rewrittenSnapshot diffreads both accounts by name0 unexplained differences
Prod is only ever read. The module applies to the test account, a separate script copies the data, and the snapshot diff compares both accounts. The data lane and the snapshot diff are explained further down.

The Terraform module itself holds no account IDs. Every external resource it needs is looked up rather than hardcoded: the landing zone gives the VPC and subnets the same Name tags in every account, so they are found by tag, and the application stack’s security group and task role are found by name.

Because those names are the same in every account, running the module against prod later needs no change to the module, only one more tfvars file.

One Terraform module runs in any account because it finds everything by tag and by nameA Terraform module for the data layer, holding no account IDs, sits between a test account on the left and a prod account on the right, labelled as the next step. Each account holds a landing zone with VPC and subnets under the same Name tags, and an application CloudFormation stack with a security group and task role under the same names. Dashed arrows show the module finding the network by Name tag and the stack resources by name in each account. Each account also has its own tfvars file, test.tfvars and prod.tfvars, feeding per-environment values into the module. A strip says the module and the names are the same in every account and only the tfvars file changes.Test accountLanding zoneVPC and subnetssame Name tagsApp CloudFormation stacksecurity group, task rolesame namestest.tfvarsper-environment valuesProd account (next step)Landing zoneVPC and subnetssame Name tagsApp CloudFormation stacksecurity group, task rolesame namesprod.tfvarsper-environment valuesTerraform moduledata layerno account IDsfinds the networkby Name tagfinds the app stackby nameSame module and same names in every account: only the tfvars file changes
One module, two accounts. The module finds the network by Name tag and the application stack’s resources by name, so only the tfvars file differs.

S3 bucket names must be unique across all AWS accounts. Two of prod’s document buckets have names without an account ID, so the test account cannot create buckets with the same names. The safer pattern is to append the account ID (kb-docs → kb-docs-222222222222) or the environment name (kb-docs → kb-docs-stg).

With the AWS resources in place, the data stored in prod still has to be copied: the files in S3 buckets, including the data behind Athena, and the application’s records in DynamoDB.

I kept that data out of Terraform state and copy it with a seed script, in two steps: export reads prod’s data into a local cache, and import writes the cache to the matching places in the test account.

Knowledge base IDs, the gateway URL, the code interpreter ID and bucket names all have to be replaced: copied unchanged, they would send the test application to prod’s knowledge bases and gateway.

So import replaces every prod value with its test counterpart as it writes. Bucket names follow a naming rule, but values AWS generates, such as the knowledge base ID, exist only once Terraform has created the resource.

scripts/seed_test_env.py
# One pass, longest match first. The test bucket name starts with the prod
# bucket name ("docs" -> "docs-222222222222"), so sequential str.replace
# calls would rewrite the already-rewritten value a second time.
pattern = re.compile("|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True)))
text = pattern.sub(lambda m: mapping[m.group(0)], json.dumps(item))
if leftover.search(text):
raise SystemExit(f"prod value survived in {label}")

What Terraform owns and what it only looks up

Anything another team created, Terraform looks up through a data source, a read-only query that manages nothing. Only what the data layer needs on top of it is written as a resource, which Terraform creates and manages.

The VPC and subnets belong to the landing zone, and the ECS service, its security group and its task role belong to the CloudFormation template the application team manages. Terraform reads all of them and creates none.

Terraform reads the network and the application stack, and owns the restTwo zones side by side. The left zone, looked up through data sources, holds the landing zone VPC and subnets found by Name tag, and the application CloudFormation stack with its ECS service security group and task role found by name. The right zone, owned by Terraform, holds the data layer and a card of rules attached to the stack: port 3000 ingress from the load balancer subnets, a 443 self-reference that gates the VPC endpoint, and inline policies on the task role. An arrow runs from that card to the application stack.Looked up (data sources)Owned by TerraformLanding zoneVPC and subnetsfound by Name tagApplication CloudFormation stackECS service security groupECS task rolefound by nameData layerS3 buckets, Glue tablesOpenSearch Serverless + indexBedrock knowledge basesLambdaAgentCore gateway, code interpreterbedrock-runtime VPC endpointAttached to the stackPort 3000 from LB subnets443 self-reference (gates the endpoint)Task role inline policies
Terraform looks up the landing zone network and the application stack, and owns the data layer plus the rules that used to be added by hand.

Comparing prod with the freshly built application stack in test turned up changes I had made by hand in prod, on resources the template owns:

  1. A port 3000 ingress rule on the service security group. The template’s rule allows traffic from the load balancer’s security group, which only matches packets that arrive straight from the load balancer. Here, traffic between the public and protected subnets is routed through the landing zone’s Gateway Load Balancer (GWLB) endpoint into the provider’s inspection VPC, so on arrival it no longer counts as coming from that group. I had added a rule by IP range instead, allowing the load balancer subnets’ CIDRs, which matches whichever path the packet takes.
Through the inspection VPC, only the CIDR rule still matchesTwo rows. The top row is the path the template’s rule assumes: the load balancer in the public subnet sends traffic straight to the service in the protected subnet, and the rule allowing traffic from the load balancer’s security group matches. The bottom row is the real path: traffic from the load balancer goes through the landing zone’s GWLB endpoint into the landing zone provider’s inspection VPC and back before reaching the service. There, the rule allowing traffic from the load balancer’s security group no longer matches, and the rule allowing the load balancer subnet’s CIDR, added by hand, matches.The path the template’s rule assumesThe real pathLoad balancerpublic subnetService (ECS task)protected subnetLoad balancerpublic subnetGWLB endpointlanding zoneService (ECS task)protected subnetInspection VPClanding zone providerRule: from the LB’ssecurity group: matchesRule: from the LB’ssecurity group: no matchRule: from the LB subnetCIDR (added by hand): matches
Through the landing zone’s inspection VPC, packets no longer count as coming from the load balancer’s security group, so only the CIDR rule matches.
  1. A 443 inbound rule whose source is the security group itself, meaning members of the same security group may connect to each other. The ECS tasks, the Lambda and the bedrock-runtime VPC endpoint all sit in this security group, so this rule is what lets the tasks and the Lambda reach the endpoint.
  2. Two inline policies on the task role, one allowing Athena and Glue access, the other allowing reads of source PDFs.

Terraform now owns all three as separate resources attached to the template’s objects. A stack update that removes one gets it back on the next apply.

The endpoint also waits for the 443 rule. It has private DNS enabled, so once it exists every bedrock-runtime call in the VPC goes through it, including the application’s own; without the rule those calls would all be blocked, so depends_on enforces the order:

terraform/modules/data-layer/network.tf
resource "aws_vpc_endpoint" "bedrock_runtime" {
vpc_id = data.aws_vpc.lz.id
service_name = "com.amazonaws.${local.region}.bedrock-runtime"
vpc_endpoint_type = "Interface"
subnet_ids = local.protected_subnet_ids
security_group_ids = [local.service_sg_id]
private_dns_enabled = true
depends_on = [aws_vpc_security_group_ingress_rule.self_https]
}

Creating the OpenSearch Serverless index with terraform_data

The vector index is created by a script through the native API, only if it is absent, and Terraform never updates or deletes it. Every route to letting Terraform own the index has a cost:

  1. The AWS provider has no index resource. internal/service/opensearchserverless/ contains collections, policies and VPC endpoints, and no index.go.
  2. The opensearch provider can own one, but it plans a replacement when Bedrock adds dynamic fields to the mapping (#175, still open in v2.6.0), and replacing the index deletes every vector in it. lifecycle { ignore_changes = [mappings] } with prevent_destroy closes that risk. It does not fix configuration: the provider’s url is the collection endpoint, which exists only after the collection is created, so a fresh account needs two applies, the first with -target on the collection, and CI’s auto-apply runs one. That limitation is reported in the provider’s issues and was not reproduced here.
  3. AWS::OpenSearchServerless::Index exists in CloudFormation, so aws_cloudcontrolapi_resource looked viable. But although Cloud Control lists the prod indexes with ListResources, GetResource fails on every one of them.
Terminal window
IN
aws cloudcontrol get-resource --type-name AWS::OpenSearchServerless::Index --identifier "docs-index|https://<collection-id>.ap-northeast-1.aoss.amazonaws.com"
OUT
aws: [ERROR]: An error occurred (GeneralServiceException) when calling the GetResource operation: AWS::OpenSearchServerless::Index Handler returned status FAILED: Error occurred during operation 'GetIndex'.

Every plan first refreshes each resource, so as long as GetResource fails, every plan fails with it. The native create-index and get-index calls work, so I switched to a small script that does create-if-absent and nothing more:

terraform/modules/data-layer/scripts/ensure_index.sh
for i in $(seq 1 12); do
if aws opensearchserverless get-index --id "$COLLECTION_ID" --index-name "$INDEX_NAME" >/dev/null 2>&1; then
echo "index ${INDEX_NAME}: exists"; exit 0
fi
# A new data access policy can take a while to become ready, so retry.
if aws opensearchserverless create-index --id "$COLLECTION_ID" --index-name "$INDEX_NAME" \
--index-schema "file://${SCHEMA_FILE}" >/dev/null 2>&1; then
echo "index ${INDEX_NAME}: created"; exit 0
fi
sleep 15
done
exit 1

The script also means one provider fewer: no third-party provider, no second SigV4 setup, no extra version to track. The cost is that the index is in no plan. Terraform cannot see mapping drift, only the snapshot diff later in this article catches it, and mapping changes are applied by hand.

I created a terraform_data resource to run it, with the collection ID, the index name and a hash of the mapping file as triggers.

I also put a 60-second time_sleep between the index and the knowledge base: right after creation the index is still propagating, and creating the knowledge base then can fail because the index is not found (“no such index”).

I put only the three fields the knowledge base names into the mapping file: the vector, text and metadata fields. Bedrock adds the rest dynamically during ingestion.

Proving parity: a normalized config diff and identical retrieval

I wrote a snapshot script that looks up the resources in prod and test by name, compares their settings, and fails on any difference not listed in EXPECTED_DIFFS. Every resource, from the knowledge base to the gateway, is found by name and compared.

Before comparing, the script replaces account-specific values with placeholders such as <ACCT>, <bucket:rag>, <kb:docs> and <subnet:LZPrivateSubnet1a>, so values that are supposed to differ, like account IDs, do not show up as differences. Lists are sorted after the placeholders go in, so the JSON from prod and test comes back in the same order.

scripts/infra_snapshot.py
EXPECTED_DIFFS = {
"lambda.code_sha256": "test runs the repository handler; prod runs older console code",
"lambda.handler": "same reason",
"kb_data_sources.*.name": "prod names were generated by the console quick-create",
"*.tags": "test carries default_tags (ManagedBy, Repository)",
}

The first run printed three differences, all of them settings the Terraform module had wrong:

  1. S3 Bucket Keys were enabled in prod and disabled in test.
  2. The module was missing the marketplace statement the console adds to every knowledge base role in prod.
  3. A gateway role statement was scoped differently from prod.

After I fixed those three, every difference outside EXPECTED_DIFFS was gone. The index mappings matched too, including the fields Bedrock added during ingestion.

Even when configuration matches, behaviour may not, so I ran the same retrieval test in both accounts: five queries against each of the two knowledge bases, top five results. All ten returned the same documents in the same order, and every source link in test pointed at a test bucket.

I also counted rows in five tables with Athena, and both sides matched.

GitHub OIDC: plan on pull requests, apply on merge to main

I set up a GitHub Actions workflow: every pull request that touches terraform/ gets a plan, posted as a comment on that pull request, and once the pull request merges to main the workflow applies to the test account. AWS credentials come from GitHub’s OIDC token, so the repository stores no keys.

Two roles with two subjects

The OIDC token GitHub issues carries a subject (the sub field) recording which repository the token came from and which event triggered it, and an AWS trust policy compares that value to decide who may assume which role. So I set up two roles, each trusting one subject only:

  1. Plan role: the plan job runs code from the pull request, so the plan role trusts only the pull_request subject, and it is read-only.
  2. Apply role: the apply job runs in the GitHub environment test, so the apply role trusts only the environment:test subject, and it has administrator access.
A pull request can only plan; only main, through the test environment, can applyTwo lanes. In the top lane, a pull request presents an OIDC token whose subject ends in pull_request, and the only role that trusts it is the plan role: ReadOnlyAccess plus Bedrock, OpenSearch Serverless and AgentCore reads, with data reads denied and the state bucket still readable. In the bottom lane, a merge to main runs a job in the test environment, which deploys from main only, and its token subject ends in environment:test, which only the apply role trusts. The apply role has AdministratorAccess and runs terraform apply on the test account.Pull requestterraform plansub claimrepo:<owner>@<owner-id>/<repo>@<repo-id>:pull_requestPlan roleReadOnlyAccess+ bedrock, aoss, agentcore readsDeny data reads (S3, DynamoDB, logs)State bucket stays readableMerge to mainterraform applysub claimrepo:<owner>@<owner-id>/<repo>@<repo-id>:environment:testApply roleAdministratorAccessterraform applyon the test accountEnvironment: testdeploys from main only
The plan role trusts pull requests and cannot read data. The apply role trusts only the test environment, and the environment only deploys from main.

I put the two jobs in separate concurrency groups. GitHub keeps one pending run per group, so a shared group would let a newer pull request’s plan replace an apply that was waiting.

Before a plan or an apply, Terraform creates a .tflock lock file next to the state file in S3, so the two jobs never operate on state at the same time.

Fixing the sub prefix

The first pull request failed before Terraform started:

Retry AssumeRole: attempt 1 of 12 failed: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity.

The trust policy expected repo:<owner>/<repo>:pull_request. The repository sends repo:<owner>@<owner-id>/<repo>@<repo-id>:pull_request, which embeds the numeric owner and repository IDs. A trust policy compares the subject exactly, so AWS refused AssumeRoleWithWebIdentity. The prefix GitHub actually uses can be checked with this API:

Terminal window
IN
gh api repos/<owner>/<repo>/actions/oidc/customization/sub
OUT
{"use_default":true,"use_immutable_subject":true,"sub_claim_prefix":"repo:<owner>@<owner-id>/<repo>@<repo-id>"}

I changed both trust policies to match that prefix:

terraform/bootstrap/test/ci.tf
locals {
github_subject = "repo:<owner>/<repo>"
github_subject = "repo:<owner>@<owner-id>/<repo>@<repo-id>"
}
# plan: "${local.github_subject}:pull_request"
# apply: "${local.github_subject}:environment:test"

Adding Bedrock reads to ReadOnlyAccess, and denying data reads

After I fixed the trust policy, the plan got further, but then failed with this error:

Error: listing tags for Bedrock Agents Knowledge Base (arn:aws:bedrock:ap-northeast-1:<account>:knowledge-base/<kb-id>)
AccessDeniedException: User: arn:aws:sts::<account>:assumed-role/github-terraform-plan/GitHubActions is not authorized to perform: bedrock:ListTagsForResource

ReadOnlyAccess does not include bedrock:ListTagsForResource, so I added bedrock:Get* and bedrock:List* to the plan role, beside the bedrock-agentcore and aoss reads it already had. Then I reran the plan with gh run rerun <run-id> --failed, which picks up the updated role without a new commit.

ReadOnlyAccess also reads far more than a plan needs: S3 objects, DynamoDB items and CloudWatch log events. The test account holds a copy of production data and the application’s chat logs, and the plan role runs pull request code, so anyone who can open a pull request could read that data through the plan.

So I added a rule denying every data read outside the state bucket:

terraform/bootstrap/test/ci.tf
statement {
sid = "DenyDataReads"
effect = "Deny"
actions = [
"s3:GetObject", "s3:GetObjectVersion",
"dynamodb:GetItem", "dynamodb:BatchGetItem", "dynamodb:Query", "dynamodb:Scan",
"logs:GetLogEvents", "logs:FilterLogEvents", "logs:StartQuery",
"athena:GetQueryResults", "bedrock:Retrieve", "bedrock:RetrieveAndGenerate",
]
not_resources = ["${aws_s3_bucket.state.arn}/*"]
}

Locking apply to main with a deployment branch policy

The AWS trust policy cannot tell which branch an environment job came from: an environment job’s subject (sub) names only the environment, as in …:environment:test, and never the branch. Without a branch rule on the GitHub side, a workflow_dispatch from any branch, or a pull request that adds environment: test to a job, assumes the administrator role.

If the environment a workflow names does not exist yet, it is created automatically, with no branch protection by default. So I created the test environment before the workflow first ran and limited it to deploying from main:

Terminal window
gh api -X PUT repos/<owner>/<repo>/environments/test --input - <<'JSON'
{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}
JSON
gh api -X POST repos/<owner>/<repo>/environments/test/deployment-branch-policies -f name=main -f type=branch

AgentCore network interfaces hold the security group for up to 8 hours

Finally, I tried rebuilding the test account from zero, but terraform destroy stopped on the code interpreter’s security group after the 45-minute delete timeout:

Error: deleting Security Group (sg-<id>): operation error EC2: DeleteSecurityGroup, https response error StatusCode: 400, api error DependencyViolation: resource sg-<id> has a dependent object

The code interpreter itself was already gone, but two network interfaces of type agentic_ai were still attached to the security group. They were requested by an AgentCore service account, an account AWS manages, not by the test account. AWS documents that these network interfaces may persist for up to 8 hours after deletion, and detaching them is not permitted.

That did not block the rebuild. Everything else had been destroyed and only that security group remained in state, so I ran terraform apply, and it reused the security group and rebuilt the rest of the account.

After I re-ran the seed script, the snapshot diff, the retrieval test and the Athena row counts all came back the same as before.

Summary

Exporting prod’s configuration into a spec/ directory gave the Terraform module something to be checked against.

I chose Terraform over CDK because it writes the configuration itself when it imports existing resources. The vector index went to a script calling the native API: in a fresh account the opensearch provider cannot be configured until the collection exists, and CI’s single apply cannot do that.

The CI side hit three problems that a role created by hand never meets: the immutable subject format, the Bedrock reads ReadOnlyAccess lacks and the data reads it should not have, and the deployment branch policy that the environment trusted by the apply role needs.

References

Share this article