Skip to content

Simulated Organizations

Yulin simulates AWS Organizations service control policies. A test can find out that the organization around an account forbids something before a deployment does.

A service control policy filters what an account’s principals may do and grants nothing. Policies attach to the organization root, to an organizational unit, or to one account, and an account inherits every policy on the path down to it. Sim IAM evaluates them ahead of that account’s identity and resource policies. An SCP therefore applies to a CloudFormation deployment, an intercepted SDK client, and a direct service call alike.

A policy is usually attached to an organizational unit rather than to one account, and every account under that unit inherits it. Units nest, and the root sits above all of them.

/**
* Inheriting a service control policy from an organizational unit.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "123456789012" });
const organizations = simAws.organizations();
const workloads = organizations.createOrganizationalUnit("Workloads");
const production = organizations.createOrganizationalUnit(
"Production",
workloads,
);
organizations.moveAccount("123456789012", production);
organizations.attachServiceControlPolicy(workloads, {
Version: "2012-10-17",
Statement: { Effect: "Deny", Action: "s3:CreateBucket", Resource: "*" },
});
const decision = simAws.account("123456789012").iam().authorize({
action: "s3:CreateBucket",
resource: "arn:aws:s3:::reports-bucket",
});
console.log(decision.value); // "ExplicitDeny"

The policy hangs two levels above the account and still reaches it. createOrganizationalUnit takes a parent unit as its second argument, and leaves the unit under the root without one. organizations.root() is the node above everything, and a policy attached there covers every account in the organization.

An account is filtered by each node on the path from the root down to it, and each one has to allow an action on its own. A root allowing S3 and a unit allowing DynamoDB leave an account beneath them able to do neither.

/**
* Each level of the organization allowing the action separately.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "123456789012" });
const organizations = simAws.organizations();
const workloads = organizations.createOrganizationalUnit("Workloads");
organizations.moveAccount("123456789012", workloads);
organizations.detachFullAwsAccess(organizations.root());
organizations.attachServiceControlPolicy(organizations.root(), {
Version: "2012-10-17",
Statement: { Effect: "Allow", Action: "s3:*", Resource: "*" },
});
organizations.detachFullAwsAccess(workloads);
organizations.attachServiceControlPolicy(workloads, {
Version: "2012-10-17",
Statement: { Effect: "Allow", Action: "dynamodb:*", Resource: "*" },
});
const decision = simAws.account("123456789012").iam().authorize({
action: "s3:GetObject",
resource: "arn:aws:s3:::reports-bucket/summary.csv",
});
console.log(decision.value); // "ImplicitDeny"
console.log(decision.serviceControlPolicy.unallowedLevels); // [ "Workloads" ]

unallowedLevels names the nodes that allowed nothing matching. That is the part of a real SCP denial that takes longest to track down.

A Deny at any level ends the request whatever another level allows.

setManagementAccount names the account AWS exempts from every service control policy. That account is decided by its identity and resource policies alone, whatever is attached above it.

/**
* Exempting the management account.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "111111111111" });
const organizations = simAws.organizations();
organizations.attachServiceControlPolicy(organizations.root(), {
Version: "2012-10-17",
Statement: { Effect: "Deny", Action: "*", Resource: "*" },
});
organizations.setManagementAccount("111111111111");
const decision = simAws.account("111111111111").iam().authorize({
action: "s3:CreateBucket",
resource: "arn:aws:s3:::reports-bucket",
});
console.log(decision.isAllowed); // true
console.log(decision.serviceControlPolicy.isApplied); // false

Attach a service control policy to an Account

Section titled “Attach a service control policy to an Account”

A policy attached straight to an account applies to that account alone. An organization spans accounts, so it belongs to the whole simulated environment and is reached as simAws.organizations(), not from an account scope.

/**
* Denying an action with a simulated service control policy.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "123456789012" });
simAws.organizations().attachServiceControlPolicy("123456789012", {
Version: "2012-10-17",
Statement: {
Sid: "DenyBucketCreation",
Effect: "Deny",
Action: "s3:CreateBucket",
Resource: "*",
},
});
const decision = simAws.account("123456789012").iam().authorize({
action: "s3:CreateBucket",
resource: "arn:aws:s3:::reports-bucket",
});
console.log(decision.value); // "ExplicitDeny"
console.log(decision.serviceControlPolicy.isDenied); // true
console.log(decision.serviceControlPolicy.denyStatements[0]?.Sid); // "DenyBucketCreation"

The account also gets AWS’s own FullAWSAccess policy, as it would in a real organization. One Deny statement therefore denies that one action and leaves the rest of the account working.

An account with no policy attached to it stays outside the organization’s reach, and its identity and resource policies decide its requests as they did before.

Sim CloudFormation creates each resource through the owning service’s command handler, and that handler authorizes. A deployment that names no principal is decided as the account root. An SCP applies to a member account’s root the same way AWS does.

/**
* A CloudFormation Resource a service control policy denies.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "123456789012" });
simAws.organizations().attachServiceControlPolicy("123456789012", {
Version: "2012-10-17",
Statement: { Effect: "Deny", Action: "s3:CreateBucket", Resource: "*" },
});
try {
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "reports-stack",
template: {
Resources: {
ReportsBucket: {
Type: "AWS::S3::Bucket",
Properties: { BucketName: "reports-bucket" },
},
},
},
});
await stack.waitForDeployComplete();
} catch (error) {
// "... is not authorized to perform: s3:CreateBucket on resource:
// arn:aws:s3:::reports-bucket with an explicit deny in a service control policy"
console.log((error as Error).message);
}
const failed = simAws
.cloudFormation()
.getStackByName("reports-stack")
?.getResource("ReportsBucket");
console.log(failed?.status); // "CREATE_FAILED"

The resource is left CREATE_FAILED and the deployment rejects. A test asserting that a stack deploys then fails on the policy, with the policy named in the message.

An organization that denies its accounts’ root principals is ordinary, and a deployment decided as the root fails under one. caller says which principal the resources are created as, and a statement conditioned on aws:PrincipalArn then has a deploy role to match against.

/**
* A policy denying the account root, and a deployment that names a Role.
*/
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "123456789012" });
const simIam = simAws.iam();
const roleCreation = await simIam.createRole(
new CreateRoleCommand({
RoleName: "cdk-deploy-role",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { Service: "cloudformation.amazonaws.com" },
Action: "sts:AssumeRole",
},
}),
}),
);
await simIam.putRolePolicy(
new PutRolePolicyCommand({
RoleName: "cdk-deploy-role",
PolicyName: "Deploy",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: { Effect: "Allow", Action: "s3:*", Resource: "*" },
}),
}),
);
simAws.organizations().attachServiceControlPolicy("123456789012", {
Version: "2012-10-17",
Statement: {
Sid: "DenyRootPrincipal",
Effect: "Deny",
Action: "*",
Resource: "*",
Condition: { ArnLike: { "aws:PrincipalArn": "arn:aws:iam::*:root" } },
},
});
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "reports-stack",
template: {
Resources: {
ReportsBucket: {
Type: "AWS::S3::Bucket",
Properties: { BucketName: "reports-bucket" },
},
},
},
caller: { kind: "arn", arn: roleCreation.Role.Arn },
});
console.log(stack.getResource("ReportsBucket")?.status); // "CREATE_COMPLETE"

The Role is created before the policy is attached. Creating it afterwards is a call the policy denies the root, and the account root is who a bare createRole runs as.

Name the caller the rest of a test reads as

Section titled “Name the caller the rest of a test reads as”

A deployment names its own principal. Every other call in the test still names none, and each one is the account root. Under a policy denying that root, a test reading back what a stack made is denied on every read.

defaultCaller on SimAws says who those calls are. A test then reads the account as the person or role that would really be looking at it, and an explicit caller still wins wherever one is given.

/**
* Reading an account whose organization denies its root principal.
*/
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { SimAws } from "@kensio/yulin";
const administratorArn = "arn:aws:iam::123456789012:role/Administrator";
const simAws = new SimAws({
defaultAccountId: "123456789012",
defaultCaller: { kind: "arn", arn: administratorArn },
});
// The Role is created as the account root, because a simulation with a default
// caller attributes these two commands to a Role that has no policy yet.
const root = simAws.account().rootPrincipal;
const simIam = simAws.iam();
await simIam.createRole(
new CreateRoleCommand({
RoleName: "Administrator",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { AWS: "arn:aws:iam::123456789012:root" },
Action: "sts:AssumeRole",
},
}),
}),
{ caller: root },
);
await simIam.putRolePolicy(
new PutRolePolicyCommand({
RoleName: "Administrator",
PolicyName: "Administer",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: { Effect: "Allow", Action: "*", Resource: "*" },
}),
}),
{ caller: root },
);
simAws.organizations().attachServiceControlPolicy("123456789012", {
Version: "2012-10-17",
Statement: {
Sid: "DenyRootPrincipal",
Effect: "Deny",
Action: "*",
Resource: "*",
Condition: { ArnLike: { "aws:PrincipalArn": "arn:aws:iam::*:root" } },
},
});
await simAws.ssm().putParameter({
input: { Name: "/reports/bucket", Type: "String", Value: "reports-bucket" },
});
const read = await simAws
.ssm()
.getParameter({ input: { Name: "/reports/bucket" } });
const identity = await simAws.sts().getCallerIdentity({});
console.log(read.Parameter?.Value); // "reports-bucket"
console.log(identity.Arn); // "arn:aws:iam::123456789012:role/Administrator"

Setup runs before the policy is attached, for the same reason the deploy Role above does. The Role a simulation reads as has to exist and hold a policy, and creating it is itself a call.

Naming a default caller says who an unattributed call comes from, and leaves the root’s own identity access where it was. A test about root behaviour names simAws.account().rootPrincipal and gets the root. Under the policy above that call is denied, which is what the statement is written to do.

Write an allow list instead of a deny list

Section titled “Write an allow list instead of a deny list”

detachFullAwsAccess takes AWS’s own policy off an account. What remains has to allow an action for the account to be allowed it. That is an organization run as an allow list.

/**
* Allowing only what the attached policies name.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "123456789012" });
const organizations = simAws.organizations();
organizations.detachFullAwsAccess("123456789012");
organizations.attachServiceControlPolicy("123456789012", {
Version: "2012-10-17",
Statement: { Effect: "Allow", Action: "dynamodb:*", Resource: "*" },
});
const decision = simAws.account("123456789012").iam().authorize({
action: "s3:GetObject",
resource: "arn:aws:s3:::reports-bucket/summary.csv",
});
console.log(decision.value); // "ImplicitDeny"
console.log(decision.denialReason);
// "because no service control policy allows the s3:GetObject action"

An account root holds unrestricted access in sim IAM, and this denies it anyway. That is what an SCP does in AWS, and it is why an allow list is worth writing in a test at all.

Detaching FullAWSAccess on its own leaves the account holding no policy, and every action is then denied. AWS behaves the same way, and warns about it.

Deploy an organization from CloudFormation

Section titled “Deploy an organization from CloudFormation”

A template’s AWS::Organizations::* resources build the organization they describe, so a stack already managing the org chart is the same one a test deploys.

/**
* Building an organization from a CloudFormation template.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws({ defaultAccountId: "123456789012" });
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "org-stack",
template: {
Resources: {
Organization: { Type: "AWS::Organizations::Organization" },
Workloads: {
Type: "AWS::Organizations::OrganizationalUnit",
Properties: {
Name: "Workloads",
ParentId: { "Fn::GetAtt": ["Organization", "RootId"] },
},
},
DenyBucketCreation: {
Type: "AWS::Organizations::Policy",
Properties: {
Name: "DenyBucketCreation",
Type: "SERVICE_CONTROL_POLICY",
TargetIds: [{ Ref: "Workloads" }],
Content: {
Version: "2012-10-17",
Statement: [
{ Effect: "Deny", Action: "s3:CreateBucket", Resource: "*" },
],
},
},
},
},
Outputs: { WorkloadsId: { Value: { Ref: "Workloads" } } },
},
});
await stack.waitForDeployComplete();
simAws.organizations().moveAccount("123456789012", stack.output("WorkloadsId"));
const decision = simAws.account("123456789012").iam().authorize({
action: "s3:CreateBucket",
resource: "arn:aws:s3:::reports-bucket",
});
console.log(decision.value); // "ExplicitDeny"

Content takes the policy document inline or as JSON text. TargetIds takes a list or a single value, and each entry names the root, a unit, or an Account. A policy reaches every target it names or none of them. A target this organization has never heard of fails the resource before anything is attached.

A simulated environment has one organization from the start, so AWS::Organizations::Organization records the one already there rather than making another. It is worth declaring for RootId, which is what a unit hangs off.

AWS::Organizations::Account creates an Account with an id nobody chose, as AWS does. Read that id back with Ref or Fn::GetAtt AccountId.

These are the properties read from each resource:

Resource Read Recorded and skipped
AWS::Organizations::Organization FeatureSet FeatureSet
AWS::Organizations::OrganizationalUnit Name, ParentId Tags
AWS::Organizations::Account AccountName, Email, ParentIds Tags, RoleName
AWS::Organizations::Policy Name, Type, Content, TargetIds Tags, Description

Tearing the stack down takes its own policies off the nodes they were attached to, removes the units, and takes any Account the stack created back out of the organization. A node holding policies from more than one stack keeps the others. A unit that still holds something when it goes hands what it holds to its parent, so every Account keeps a path to the root.

An authorization decision reports the organization’s verdict apart from the identity and resource sides, through decision.serviceControlPolicy:

Property Meaning
isApplied Whether any service control policy applied to the request.
isDenied Whether the attached policies denied it, either way.
isExplicitDeny Whether a matching Deny statement denied it.
isImplicitDeny Whether the attached policies produced no matching Allow.
denyStatements The matching Deny statements.
allowStatements The matching Allow statements.

decision.denialReason carries the wording AWS puts on the AccessDenied message, and every simulated service passes it through to the error it throws.

simAws.organizations().serviceControlPoliciesFor(accountId) returns the policies in force for an account, in the order they were evaluated, including FullAWSAccess where it is still attached.

serviceControlPolicySetFor(accountId).levels keeps the policies grouped by the node they hang on, root first. That grouping is what sim IAM evaluates.

The flattened list is empty in three cases that behave differently. An account that was never named sits outside the organization and stays unrestricted. The management account is exempt and equally unrestricted. An account left holding no policy is denied everything. applies separates the last of those from the other two. serviceControlPolicySetFor(accountId).applies tells the two apart, and so does decision.serviceControlPolicy.isApplied.

Simulated Organizations supports:

  • createOrganizationalUnit, creating a unit under the root or under another unit
  • moveAccount, putting an Account under a unit or under the root
  • root, the node above every Account in the organization
  • setManagementAccount, exempting the Account AWS exempts
  • attachServiceControlPolicy, attaching a policy document to the root, a unit, or an Account, and answering with the id that takes it off again
  • detachServiceControlPolicy, taking one policy off a node and leaving the rest
  • accountIds, reading which Accounts the organization holds
  • detachFullAwsAccess, turning a node’s policies into an allow list
  • detachServiceControlPolicies, taking every policy back off one node and leaving the rest alone
  • removeAccount, taking an Account out of the organization
  • serviceControlPoliciesFor, reading the policies in force for an Account
  • serviceControlPolicySetFor, reading those policies along with whether any apply
  • The AWS-managed FullAWSAccess policy, attached by default as it is in AWS
  • Evaluation ahead of identity and resource policies, for every principal in the Account including its root
  • Inheritance down the root-to-Account path, with every level having to allow the action
  • AWS-shaped r- and ou- node ids
  • Action, NotAction, Resource, NotResource and Condition in an SCP statement
  • The ArnEquals, ArnLike, NumericLessThanEquals, StringEquals and StringLike condition operators, and their ForAnyValue: and ForAllValues: set forms
  • The negated ArnNotEquals, ArnNotLike, StringNotEquals and StringNotLike operators and their set forms, which a Deny statement exempting named roles hangs on aws:PrincipalArn
  • AccessDenied messages naming the service control policy, as AWS words them
  • Denial reporting through decision.serviceControlPolicy, naming the levels that allowed nothing
  • The AWS::Organizations::Organization, ::OrganizationalUnit, ::Account and ::Policy CloudFormation resources, with Ref and Fn::GetAtt, and teardown
Limitation Detail
Management account Named with setManagementAccount. An organization with none named exempts no Account.
Moving an Account moveAccount places an Account and can move it again. Nothing records where it was before.
Service-linked roles Evaluated like any other principal. AWS exempts a service-linked role from SCPs.
Other policy types Resource control policies, declarative policies, tag policies, backup policies and AI services opt-out policies are not simulated.
The Organizations SDK CreatePolicy, AttachPolicy, ListAccounts and the rest of the API are not handled. Policies are attached through the accessor.
CloudFormation AWS::Organizations::Organization, ::OrganizationalUnit, ::Account and ::Policy are not created from a template.
Organization condition keys aws:PrincipalOrgID and aws:PrincipalOrgPaths are not populated. A condition naming either fails to match.
Service principals A request whose caller is a service principal or anonymous belongs to no Account and is subject to no policy.
Condition operator coverage The operators above are evaluated. Anything else fails closed and the statement holding it matches nothing.
HTTP API Organizations is not served as an HTTP API by serveSimAws.

Documenting Yulin v1.20.16