Policy statements: principals and conditions¶
An IAM policy statement says who may do what to which resource, optionally when. cfn_typed.policy
builds them as plain dicts, in the order IAM documents, so the rendered template reads like a policy. Nothing here is
opinion: it builds what you describe. Which actions belong together is the job of the
grants, which use this module.
from cfn_typed import allow, deny
from cfn_typed.policy import ANY, GlobalKeys, aws, bool_
allow(["sqs:ReceiveMessage"], queue_arn) # identity policy: no principal
allow("kms:*", "*", sid="EnableIam", principal=aws(root_arn)) # a key policy
deny("s3:*", [bucket_arn], principal=ANY, condition=bool_(GlobalKeys.SecureTransport, False))
Statements are plain dicts, and checked¶
You can write a statement three ways, and all of them give the same plain dict:
allow(["sqs:ReceiveMessage"], queue_arn) # the builder
Statement(Effect="Allow", Action=["sqs:ReceiveMessage"], Resource=queue_arn) # the constructor, awacs-style
{"Effect": "Allow", "Action": ["sqs:ReceiveMessage"], "Resource": queue_arn} # a literal
The type checker checks the last two as well as the builder. A misspelled key (Efect, with a "did you mean"
hint for the constructor), a bad Effect and a missing Action are errors before anything runs. A statement is
one of two closed shapes, because IAM allows Action or NotAction, never both:
| Type | Has | Made by |
|---|---|---|
Statement |
Action |
allow(actions), deny(actions) |
NotActionStatement |
NotAction |
allow(not_actions=...), deny(not_actions=...) |
AnyStatement is either one, and is what policy_document takes. A literal with neither Action nor NotAction, or with
both, is an error. Resource and NotResource, and Principal and NotPrincipal, are also either/or in IAM; the
builders refuse both at once, but the types leave them as optional keys.
The Not forms¶
deny(not_actions=[...]) denies everything except the listed actions, which is how to say "nothing but these". The
other fields have the same shape: not_resource and not_principal.
deny(not_actions=["iam:*", "sts:*"], resource="*", condition=bool_("aws:MultiFactorAuthPresent", False, if_exists=True))
Principals¶
| Helper | Gives |
|---|---|
ANY |
"*": every principal |
aws(arn, ...) |
{"AWS": ...}: accounts, roles, users |
service("lambda.amazonaws.com") |
{"Service": ...} |
One value is written as a scalar and several as a list, as the IAM documentation does. Leave principal out and the
statement is an identity-policy statement, where the principal is whoever the policy is attached to.
Conditions¶
The conditions chapter covers building them in full: recipes, qualifiers, and what the template check catches. In short:
condition(operator, key, values) builds one condition, and the common operators have shorthands:
string_equals, string_not_equals, string_like, string_not_like, arn_equals, arn_like, bool_ and
ip_address. The rest are there too: string_equals_ignore_case, string_not_equals_ignore_case, arn_not_equals,
arn_not_like, not_ip_address, binary_equals, null, and the numeric_* and date_* comparisons
(numeric_less_than, date_greater_than_equals, ...). A date is a timezone-aware datetime, written as UTC
ISO 8601, or a string such as an epoch.
from cfn_typed.policy import GlobalKeys, arn_like, string_not_like
arn_like(GlobalKeys.SourceArn, topic_arn)
string_not_like(GlobalKeys.PrincipalArn, [deploy_role, "arn:aws:iam::123456789012:role/team-*"])
- The operator is a
Literal. A misspelled operator is an error before deploy. - Booleans and numbers become the strings IAM compares:
bool_("aws:SecureTransport", False)is"false". - Values may be intrinsics, such as a
Refor aSub. - Qualifiers:
for_any_value=True,for_all_values=Trueandif_exists=TruegiveForAnyValue:...,ForAllValues:...and...IfExists. Asking for both multi-value qualifiers, orIfExistswithNull, is an error. merge_conditions(a, b)combines conditions (all of them must hold), and refuses the same operator and key twice instead of silently overwriting one.GlobalKeysis a short, hand-picked list of the global keys the examples and the AWS CDK use (SourceArn,SourceAccount,PrincipalArn,PrincipalOrgID,SecureTransport). It is not complete: for any other key, write the string.
Resource policies from grants¶
A grant becomes a resource-policy statement when you give it a principal:
from cfn_typed import grants
from cfn_typed.policy import aws, service
grants.S3.Bucket.read.on(bucket_arn, principal=aws(reader_role_arn))
grants.Sqs.Queue.send_messages.on(queue_arn, principal=service("sns.amazonaws.com"),
condition=arn_like(GlobalKeys.SourceArn, topic_arn))
The same actions and resource shape come out, with a Principal added, so it goes in a bucket policy or a queue policy
instead of a role. Use condition= to narrow it, as the second example narrows a service principal to one topic.
The three resource policies in the storage patterns are three different shapes, and target
covers them:
| Policy | Resource |
How |
|---|---|---|
| bucket, queue, topic policy (their own resource) | the resource | the default, target="arn" |
| key policy (inside the key) | "*", the key itself |
target="any", with no ARN |
| ECR repository policy | none | target="omit", with no ARN |
A key policy cannot name its own ARN, which would be a circular reference, and a repository policy does not allow a
Resource field at all. target exists so a grant can produce those correctly.
Two rules keep resource policies honest:
- No key statement.
key_arnis for identity policies. A resource policy cannot carry a statement about another resource, so for a key policy you build the key statement yourself, from the grant'skey_actions. conditiongoes on the main statement, andkey_conditionon the key statement of an identity grant, for conditions such askms:ViaServicethat belong to the key.
Trust policies¶
A role's trust policy says who may assume it. assume_role_policy builds it:
from cfn_typed import assume_role_policy
from cfn_typed.policy import aws, service, string_equals
assume_role_policy(service("lambda.amazonaws.com"))
assume_role_policy(aws(partner_account_root), condition=string_equals("sts:ExternalId", external_id))
It is a one-statement policy document with sts:AssumeRole, ready for AssumeRolePolicyDocument. The service principal
is a plain string that you write; there is no list of service names to go stale.
ARNs¶
arn(service, resource) builds the ARN of a resource in this partition, region and account, with Fn::Sub pseudo
parameters, so the same code works in every partition:
arn("dynamodb", "table/orders") # arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/orders
arn("s3", "my-bucket/*") # arn:${AWS::Partition}:s3:::my-bucket/*
arn("iam", "role/deployer") # arn:${AWS::Partition}:iam::${AWS::AccountId}:role/deployer
For the resource kinds whose format is easy to get wrong, arn has a method each. Names may contain ${...}
substitutions, as in any Fn::Sub:
| Method | Resource |
|---|---|
arn.bucket(name), arn.object(bucket, key="*") |
S3 bucket, and objects in it |
arn.function(name, qualifier=None) |
Lambda function, version or alias |
arn.table(name), arn.table_index(table, index) |
DynamoDB table and index |
arn.queue(name), arn.topic(name) |
SQS queue, SNS topic |
arn.role(name, path="/"), arn.policy(name, path="/") |
IAM role and customer managed policy |
arn.secret(name) |
Secrets Manager secret; end name with -*, as real ARNs carry a random suffix |
arn.parameter(name) |
SSM parameter; the leading slash is dropped |
arn.log_group(name, streams=False) |
CloudWatch Logs log group, or the streams in it |
arn.key(key_id) |
KMS key |
Anything else goes through arn(service, resource).
Not every ARN has a region and an account. The defaults know the two common exceptions, the way
awacs does: IAM ARNs have no region, and S3 bucket and object ARNs have neither
(S3 access points, jobs and Storage Lens ARNs have both). For any other global service pass region=False or
account=False. To name another region or account, pass a string: arn("sns", "t", account="111111111111").
Guardrails¶
A deny with a condition is a guardrail, not a grant: it takes access away wherever an allow comes from, so it works
as a backstop behind the grants. The bucket policy in the storage patterns is two of them.
Because an explicit deny beats every allow, a deny that is too broad can lock you out of your own resource, so name
your own deploy role in the exceptions.