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.
Attach a policy to an organizational unit
Section titled “Attach a policy to an organizational unit”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.
Every level has to allow the action
Section titled “Every level has to allow the action”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.
The management account
Section titled “The management account”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); // trueconsole.log(decision.serviceControlPolicy.isApplied); // falseAttach 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); // trueconsole.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.
Catch a deployment the policy denies
Section titled “Catch a deployment the policy denies”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.
Name the principal a deployment runs as
Section titled “Name the principal a deployment runs as”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.
Reading a denial
Section titled “Reading a denial”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.
Available functionality
Section titled “Available functionality”Simulated Organizations supports:
createOrganizationalUnit, creating a unit under the root or under another unitmoveAccount, putting an Account under a unit or under the rootroot, the node above every Account in the organizationsetManagementAccount, exempting the Account AWS exemptsattachServiceControlPolicy, attaching a policy document to the root, a unit, or an Account, and answering with the id that takes it off againdetachServiceControlPolicy, taking one policy off a node and leaving the restaccountIds, reading which Accounts the organization holdsdetachFullAwsAccess, turning a node’s policies into an allow listdetachServiceControlPolicies, taking every policy back off one node and leaving the rest aloneremoveAccount, taking an Account out of the organizationserviceControlPoliciesFor, reading the policies in force for an AccountserviceControlPolicySetFor, reading those policies along with whether any apply- The AWS-managed
FullAWSAccesspolicy, 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-andou-node ids Action,NotAction,Resource,NotResourceandConditionin an SCP statement- The
ArnEquals,ArnLike,NumericLessThanEquals,StringEqualsandStringLikecondition operators, and theirForAnyValue:andForAllValues:set forms - The negated
ArnNotEquals,ArnNotLike,StringNotEqualsandStringNotLikeoperators and their set forms, which aDenystatement exempting named roles hangs onaws:PrincipalArn AccessDeniedmessages 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,::Accountand::PolicyCloudFormation resources, withRefandFn::GetAtt, and teardown
Limitations
Section titled “Limitations”| 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. |
Software Engineering by Kensio Software
This page as plain text: llms.txt
Documenting Yulin v1.20.16
