Company rules¶
The built-in checks say whether a template is valid. A company also
has rules that are not about validity: buckets are encrypted, queues have a dead-letter queue, nobody gets * on *.
They are one more layer on the same checks, and run in the same call.
def as_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else [] if value is None else [value]
@each("AWS::IAM::Role")
def no_admin_policies(logical_id: str, role: Mapping[str, Any]) -> list[str]:
"""No inline policy allows every action on every resource. Several problems per resource are fine."""
return [
f"Policies.{i} allows * on *"
for i, policy in enumerate(prop(role, "Policies") or [])
for st in as_list(policy["PolicyDocument"]["Statement"])
if st["Effect"] == "Allow" and "*" in as_list(st.get("Action")) and "*" in as_list(st.get("Resource"))
]
@each("AWS::SQS::Queue")
def queue_names_say_what_they_are(logical_id: str, queue: Mapping[str, Any]) -> str | None:
"""Per-resource logic that `require` cannot express: the logical ID ends in Queue or DLQ."""
return None if logical_id.endswith(("Queue", "DLQ")) else "name ends in neither Queue nor DLQ"
def queues_have_a_dead_letter_queue(template: Mapping[str, Any]) -> list[str]:
"""Logic across resources: every queue that is not itself a dead-letter queue redrives to one."""
queues = resources_of(template, "AWS::SQS::Queue")
targets = {
t["Fn::GetAtt"][0]
for q in queues.values()
if isinstance(t := prop(q, "RedrivePolicy.deadLetterTargetArn"), dict) and "Fn::GetAtt" in t
}
return [f"{k}: no RedrivePolicy" for k, q in queues.items() if k not in targets and prop(q, "RedrivePolicy") is None]
COMPANY_RULES: list[Rule] = [
require("AWS::S3::Bucket", "BucketEncryption"),
*(require("AWS::S3::Bucket", f"PublicAccessBlockConfiguration.{s}", equals=True)
for s in ("BlockPublicAcls", "BlockPublicPolicy", "IgnorePublicAcls", "RestrictPublicBuckets")),
forbid("AWS::S3::Bucket", "AccessControl", one_of=["PublicRead", "PublicReadWrite"]),
require("AWS::Logs::LogGroup", "RetentionInDays", at_least=30),
no_admin_policies,
queue_names_say_what_they_are,
queues_have_a_dead_letter_queue,
]
A rule is a function from the finished template to a list of problems, one readable line each. That is the shape
of every find_*_problems in the library, so a rule can always be written by hand. Most are one of three forms, so
cfn_typed.rules has a constructor for each:
| Form | Reads as |
|---|---|
require(type, path, equals= / one_of= / at_least= / at_most= / matches=) |
every resource of that type sets path, and the value meets the conditions |
forbid(type, path, equals= / one_of=) |
no resource of that type sets path, or sets it to that value |
@each(type) on a function of one resource |
anything else about a single resource; return a problem, a list, or None |
type is one type or a sequence of them, and unless=lambda logical_id, resource: ... exempts a resource. A rule made by
require or forbid is named after what it demands, so a failure says which rule it was. A rule that has to look at
several resources at once (every queue needs a dead-letter queue somewhere) is a plain function, as queues_have_a_dead_letter_queue is.
AssertionError: the template has problems:
[references] Resources.Worker.Properties.Role: Fn::GetAtt refers to 'ExecutionRol', ...
[AWS::S3::Bucket.BucketEncryption required] Bucket: BucketEncryption is not set
[AWS::Logs::LogGroup.RetentionInDays at_least=30 required] Logs: RetentionInDays is 7, expected at least 30
Each problem is listed under the rule's function name, next to the built-in ones. find_all_problems(template, rules=...)
returns the same as a dict.
Helpers for writing rules¶
For a plain function, cfn_typed.query has two. Neither follows intrinsics:
| Helper | Does |
|---|---|
resources_of(template, *types) |
the resources of those types, by logical ID |
prop(resource, "A.B.0.C") |
the value at a dotted path below Properties; None when a step is missing or is Ref: AWS::NoValue |
Things worth noticing¶
- Rules read the finished template, not your code. They also catch resources from a pattern someone else wrote or
a fragment copied from another team. Use
taggedto build the right thing and a rule to prove it. - A value that is an intrinsic is set, but cannot be compared.
require(..., at_least=30)accepts aReffor the value: it is decided at deploy time, and a rule is silent about what it cannot read. In your own functionspropgives you theRefdict, so decide there whether to skip it or to fail. - Make your own constructor the same way the library does: a function that returns a rule, with
__name__set so a failure says which one it was. - Test the rules themselves on a small bad template, as
tests/test_patterns.pydoes: each rule should fail on something and pass on the example patterns. - Share the list, not a framework. Put
COMPANY_RULESin the package that holds the company's patterns (see distributing), and every repository gets the same layer by passing it in.