Skip to main content
Back
DateRead11 min

How to Create and Manage AWS RCPs with Terraform and the AWS CLI

RCPs fail in one specific scenario: you create the policy, attach it to an OU, and nothing changes. The most likely reason is that the RESOURCE_CONTROL_POLICY type isn't enabled on the organization root. The console handles that step automatically when you navigate to the RCP section. Terraform and the CLI don't. Enabling the policy type is a prerequisite you have to handle explicitly in both paths, and skipping it means your attachment will succeed without your policy taking effect. This guide covers the full deployment sequence for both approaches, starting with that step.

For background on how RCPs evaluate alongside SCPs and IAM policies, see the AWS RCP page and the AWS RCP vs. SCP comparison.

Key Takeaways

  • Terraform support for RCPs requires AWS provider version 5.76.0 or later and a separate enabled_policy_types entry on the aws_organizations_organization resource to activate the policy type.
  • RCPs apply only to member accounts and have no effect on resources in the management account, regardless of where in the hierarchy the policy is attached.
  • Each entity allows a maximum of five RCP attachments, and the AWS-managed RCPFullAWSAccess policy counts toward that limit, which leaves four slots for custom policies per OU or account.
  • Terraform is the more maintainable path as the policy set grows, because it keeps policy content, attachments, and organizational structure together in version control and surfaces drift on the next plan.

Prerequisites

Before deploying RCPs via Terraform or the CLI, confirm the following are in place:

AWS Organizations all-features mode. RCPs require all features to be enabled on the organization. They aren't available in consolidated billing mode.

Management account or delegated administrator credentials. You can only create, enable, and attach RCPs from the management account or a member account designated as a delegated administrator. Member account credentials don't have the necessary organization-level permissions.

Minimum provider version for Terraform. The hashicorp/aws provider version 5.76.0 introduced support for type = "RESOURCE_CONTROL_POLICY" on aws_organizations_policy. Earlier versions accept the resource but reject the type value at plan time.

IAM permissions. At minimum, the deploying identity needs organizations:EnablePolicyType, organizations:CreatePolicy, organizations:AttachPolicy, and organizations:DescribeOrganization. Terraform state management also requires organizations:ListPolicies, organizations:DescribePolicy, organizations:ListPoliciesForTarget, and organizations:ListTargetsForPolicy.

Enabling the RCP Policy Type

Both the Terraform and CLI paths require the RESOURCE_CONTROL_POLICY type to be enabled on the organization root before you can create or attach any RCP.

With Terraform

Adding RESOURCE_CONTROL_POLICY to enabled_policy_types on the organization resource handles this. If your organization resource lives in a separate Terraform state file that you don't own, you'll need to enable the policy type in whichever configuration owns aws_organizations_organization. You can also use the CLI to enable it out of band before running the Terraform that manages RCPs.

Terraform
resource "aws_organizations_organization" "org" {
  feature_set = "ALL"

  enabled_policy_types = [
    "SERVICE_CONTROL_POLICY",
    "RESOURCE_CONTROL_POLICY",
  ]
}

With the AWS CLI

First, retrieve the organization root ID:

bash
aws organizations list-roots \
  --query 'Roots[0].Id' \
  --output text

Then enable the policy type against that root ID:

bash
aws organizations enable-policy-type \
  --root-id r-XXXX \
  --policy-type RESOURCE_CONTROL_POLICY

Enabling the policy type automatically creates RCPFullAWSAccess, an AWS-managed policy that sets a permissive baseline and doesn't restrict any actions on any resource. AWS evaluates all custom RCPs against this baseline. Don't detach it unless you intend to block all resource access in the affected accounts, which will immediately break most workloads.

Creating an RCP with Terraform

The full Terraform deployment pattern has three parts: the policy document, the policy resource, and the attachment. Each maps to a separate resource block.

Provider version constraint

Terraform
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.76.0"
    }
  }
}

Policy document

The policy below is the confused-deputy prevention pattern from AWS's data-perimeter examples. It denies cross-service principal access from outside the organization to S3, SQS, KMS, Secrets Manager, and STS. This is a common starting point for organizations building a data perimeter, because those five services cover the most frequent cross-account data access paths.

The three conditions work together: the StringNotEqualsIfExists on aws:SourceOrgID catches requests from service principals acting on behalf of accounts outside the organization, the Null check on aws:SourceAccount ensures the deny only fires when a source account is present (filtering out direct principal calls), and the Bool check on aws:PrincipalIsAWSService scopes the statement to AWS service principals.

Terraform
data "aws_organizations_organization" "current" {}

data "aws_iam_policy_document" "deny_cross_org_access" {
  statement {
    sid    = "DenyExternalServicePrincipalAccess"
    effect = "Deny"

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    actions = [
      "s3:*",
      "sqs:*",
      "kms:*",
      "secretsmanager:*",
      "sts:*",
    ]

    resources = ["*"]

    condition {
      test     = "StringNotEqualsIfExists"
      variable = "aws:SourceOrgID"
      values   = [data.aws_organizations_organization.current.id]
    }

    condition {
      test     = "Null"
      variable = "aws:SourceAccount"
      values   = ["false"]
    }

    condition {
      test     = "Bool"
      variable = "aws:PrincipalIsAWSService"
      values   = ["true"]
    }
  }
}

Use minified_json rather than json in the content argument. The 5,120-character limit on RCP content includes whitespace. The formatted JSON output from aws_iam_policy_document can push a multi-statement policy over that limit, and the API returns a cryptic character-limit error rather than identifying which whitespace caused the failure.

Policy resource

Terraform
resource "aws_organizations_policy" "deny_cross_org_access" {
  name        = "deny-cross-org-access"
  description = "Denies external service principal access to core data plane services"
  type        = "RESOURCE_CONTROL_POLICY"
  content     = data.aws_iam_policy_document.deny_cross_org_access.minified_json
}

Policy attachment

The target_id can be a root ID (r-xxxx), an OU ID (ou-xxxx-xxxx), or a 12-digit AWS account ID. Start with an individual account ID to validate behavior before widening scope.

Terraform
resource "aws_organizations_policy_attachment" "deny_cross_org_access" {
  policy_id = aws_organizations_policy.deny_cross_org_access.id
  target_id = aws_organizations_organizational_unit.production.id
}

If you destroy and recreate an OU that has RCP attachments, Terraform won't automatically re-attach the policies because the attachment resource references the old OU ID. Track OU lifecycle changes in the same configuration that manages RCP attachments, or use depends_on to make the relationship explicit.

Managing RCPs with the AWS CLI

The CLI path follows the same sequence: enable the policy type (covered above), create the policy from a JSON file, and attach it to a target.

Policy file

Save the policy content as a local JSON file. The format is standard IAM policy JSON with a Principal element, which distinguishes it from SCP syntax.

deny-cross-org-access.json:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyExternalServicePrincipalAccess",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "s3:*",
        "sqs:*",
        "kms:*",
        "secretsmanager:*",
        "sts:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:SourceOrgID": "o-XXXXXXXXXXXX"
        },
        "Null": {
          "aws:SourceAccount": "false"
        },
        "Bool": {
          "aws:PrincipalIsAWSService": "true"
        }
      }
    }
  ]
}

The "Principal": "*" wildcard matches what aws_iam_policy_document generates when type = "*" and identifiers = ["*"] are set, so the Terraform and CLI policy documents produce equivalent JSON.

Replace o-XXXXXXXXXXXX with your actual organization ID, which you can retrieve with:

bash
aws organizations describe-organization \
  --query 'Organization.Id' \
  --output text

Create the policy

bash
aws organizations create-policy \
  --content file://deny-cross-org-access.json \
  --name "deny-cross-org-access" \
  --type RESOURCE_CONTROL_POLICY \
  --description "Denies external service principal access to core data plane services"

The response includes the policy ID in Policy.PolicySummary.Id. Capture it for the next step, or retrieve it later with list-policies.

Attach to a target

bash
aws organizations attach-policy \
  --policy-id p-XXXXXXXXXXXX \
  --target-id ou-XXXX-XXXXXXXX

Replace the target-id with a root ID, OU ID, or 12-digit account ID, depending on the intended scope.

Useful read commands

List all RCPs in the organization:

bash
aws organizations list-policies \
  --filter RESOURCE_CONTROL_POLICY

Inspect the content and status of a specific policy:

bash
aws organizations describe-policy \
  --policy-id p-XXXXXXXXXXXX

Check which RCPs are attached to a specific target:

bash
aws organizations list-policies-for-target \
  --target-id ou-XXXX-XXXXXXXX \
  --filter RESOURCE_CONTROL_POLICY

Update and remove policies

To update an RCP's content, use update-policy. The policy ID stays the same; only the content changes.

bash
aws organizations update-policy \
  --policy-id p-XXXXXXXXXXXX \
  --content file://updated-policy.json

To detach an RCP from a target without deleting the policy:

bash
aws organizations detach-policy \
  --policy-id p-XXXXXXXXXXXX \
  --target-id ou-XXXX-XXXXXXXX

To delete a policy entirely, you must remove all attachments first. Attempting to delete an attached policy returns PolicyInUseException.

bash
aws organizations delete-policy \
  --policy-id p-XXXXXXXXXXXX

Deployment Target Comparison

TargetTerraform argumentCLI --target-id valueScope
Rootdata.aws_organizations_organization.current.roots[0].idRoot ID (r-xxxx)All member accounts in the organization
Organizational unitaws_organizations_organizational_unit.name.idOU ID (ou-xxxx-xxxx)All accounts in the OU and its children
Individual accountAccount ID as a string12-digit account IDSingle account only

Viewing RCPs in the Console

The AWS Organizations console gives you a list of attached policies and their targets under Organizations > Policies > Resource control policies. It's useful for verifying what's currently attached and inspecting the RCPFullAWSAccess baseline without writing a query. Creating or modifying policies through the console while Terraform manages the same resources will cause state drift. The next terraform plan will show the console-created attachment as an unmanaged resource, requiring either an import or a destroy-and-recreate. Treat the console as read-only if you're managing RCPs in Terraform.

Validating That the Policy Applied

After attaching an RCP, confirm it's active before assuming it's enforcing.

Verify the attachment

bash
aws organizations list-policies-for-target \
  --target-id ou-XXXX-XXXXXXXX \
  --filter RESOURCE_CONTROL_POLICY

The output should include both RCPFullAWSAccess and your custom policy. If only RCPFullAWSAccess appears, the attachment either didn't complete or targeted the wrong ID.

Test enforcement

For an S3 data-perimeter RCP, attempt an action from a principal outside the organization against a resource in the affected account. A correctly applied RCP produces an explicit AccessDenied with a denial context pointing to the organization policy. If the denial comes back without that context, the request was blocked by a different control.

For CloudTrail-based validation, filter on errorCode = AccessDenied in the affected account's trail. Check the userAgent and sourceIPAddress fields to confirm the denials match the expected external principals and aren't blocking internal traffic unexpectedly.

For Terraform state validation, run terraform plan after applying. If the plan shows no changes and the attachment IDs in the output match what list-policies-for-target returns, the state is consistent.

Common Errors

Policy type not enabled. The create-policy call succeeds, but attach-policy returns PolicyTypeNotEnabledException. You can create RCPs before the policy type is enabled, but you can't attach them. Run aws organizations list-roots and verify that RESOURCE_CONTROL_POLICY appears in the PolicyTypes array with Status: ENABLED. If it's missing, run enable-policy-type before retrying the attachment.

State drift from out-of-band console changes. Attaching or detaching an RCP through the console while Terraform manages the same resource causes drift. Terraform tracks the attachment by the policy ID and target ID combination. A console-created attachment for the same pair shows as an untracked resource on the next plan, and a console detachment shows as a required recreate. Treat manual console changes to Terraform-managed RCPs the same way you'd treat a manual change to a managed security group.

Attachment limit reached. Each OU or account accepts a maximum of five RCPs, and RCPFullAWSAccess counts toward that limit. If you need to attach a sixth policy, either remove an existing attachment or consolidate multiple statements into a single policy. Use minified_json in Terraform or compact the JSON manually for the CLI when combining statements.

Policy content over the character limit. The 5,120-character limit applies to the raw JSON string, including all whitespace. Formatted JSON with standard indentation can push a modest policy over the limit. Switch to minified_json in the Terraform content argument, or minify the file manually before passing it to --content in the CLI.

Policy not affecting expected accounts. RCPs apply to member accounts only. Actions taken by principals in or on the management account aren't affected by RCPs, regardless of where in the hierarchy the policy is attached. This is a hard limit in the AWS authorization model.

Service-linked roles bypassing the policy. RCPs don't apply to service-linked roles. If you're seeing access that your RCP should be blocking, check whether the principal making the request is a service-linked role. This is intentional AWS behavior and an RCP can't override it.

Limits and Known Constraints

ConstraintValueNotes
Max RCPs per entity5RCPFullAWSAccess counts; effective limit is 4 custom policies per OU or account
Max policy content size5,120 charactersIncludes whitespace; use minified_json in Terraform
Supported effectsDeny onlyAllow statements aren't valid in RCPs
Management accountNot affectedRCPs have no effect on resources in the management account, regardless of attachment scope
Service-linked rolesExemptCan't be restricted by RCPs
AWS managed KMS keysExemptkms:RetireGrant also not impacted
Minimum Terraform providerhashicorp/aws >= 5.76.0Earlier versions don't expose RESOURCE_CONTROL_POLICY as a valid type value
Organization mode requiredAll featuresRCPs are unavailable in consolidated billing mode

Why Native

Deploying an RCP through Terraform or the CLI is a one-time operation. Keeping RCPs aligned with your organizational structure as the environment scales is the ongoing work.

OUs reorganize. New accounts get provisioned without inheriting the expected attachment pattern. A policy content change in one OU doesn't propagate to sibling OUs automatically. An exception gets documented, the original justification fades, and the compensating configuration outlives the reason it was created. The result is a gap between the enforcement state shown in the console and what's actually in effect across the account structure.

Native connects the RCPs and other preventive controls already deployed across your AWS organization to the architectural intent behind them. It shows you when policy attachments have drifted from the intended structure, when new accounts are created without the expected RCP coverage, or when policy content references services or conditions that have changed. See how Native works across AWS, Azure, Google Cloud, and OCI at native.security/get-a-demo.

For more on how RCPs fit into a broader enforcement architecture, see The Architecture of Intent: Zones, Boundaries, and Baselines in the Cloud and Cloud Security Controls: What They Are, How They Map to Frameworks, and Where They Break Down.

FAQ

Can I use RCPs with AWS Control Tower?

Yes. Control Tower accounts are member accounts in an AWS organization and are subject to RCPs attached at the root, OU, or account level. Control Tower doesn't create RCPs on its own, so you manage them separately. Test RCPs in a single Control Tower-enrolled account before attaching at the OU level, because Control Tower SCPs and Landing Zone guardrails interact with RCPs during authorization evaluation. The combined deny logic isn't always obvious from inspecting the policies individually.

Can a single RCP resource contain multiple statements?

Yes. A single aws_organizations_policy resource can include a policy document with multiple statement blocks. Reference minified_json in the content argument when combining statements to stay under the 5,120-character limit. Each statement evaluates independently, and a request is denied if any statement's conditions produce a deny.

What happens if RCPFullAWSAccess is detached?

Detaching RCPFullAWSAccess from an entity removes the Allow baseline for resources in that entity. With no Allow baseline, all resource access through RCP evaluation is effectively blocked, regardless of identity-based or resource-based policies. AWS strongly recommends keeping RCPFullAWSAccess attached at all levels and managing permissions exclusively through custom Deny statements.

Can RCP management be delegated to a member account?

Yes. AWS Organizations supports delegated administrators who can create, update, and attach RCPs. A delegated administrator can't enable or disable policy types at the root level, which still requires the management account. Use organizations:RegisterDelegatedAdministrator from the management account to grant this access.

Do RCPs apply to cross-account access within the same organization?

Yes, in one specific direction. When a principal from Account A accesses a resource in Account B, the RCPs attached to Account B apply to that request. The RCPs attached to Account A don't apply to that request. This is the practical difference from SCPs: SCPs restrict what principals in Account A can do, while RCPs restrict what can be done to resources in Account B, regardless of where the calling principal lives.

Do RCPs apply to the root user of a member account?

Yes. RCPs apply to all principals accessing resources in member accounts, including the root user of that account. The only principals that are fully exempt are service-linked roles and principals operating within the management account.

Ready to enforce secure-by-design?