awspolicy

package
v0.9.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 5, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package awspolicy assembles the least-privilege IAM policy an ec2 billet node (or the AMI builder) needs, from the action constants each owning package declares.

ONE GENERATOR, THREE CONSUMERS. `billet init iam` prints the policy for a deployment, a drift test pins the generator's output to a committed rendering, and the Terraform module consumes that same committed rendering. If the actions billet's code performs and the permissions its policy grants ever disagreed, a node would fail at runtime with a permission error that no config check could have caught — so the actions live beside the code that performs them (internal/provider/ec2, internal/store/ebss3) and this package is the only place that unions them into statements.

THE OWNERSHIP BOUNDARY IS PER DEPLOYMENT WHEN THE OWNER IS KNOWN. Every destructive action is conditioned on billet's owner tag: on its exact VALUE (the deployment identity) when Inputs.Owner is set, or on mere PRESENCE when it is not. `billet init iam` reads the deployment id from the state directory and always sets it, so the policy it generates isolates ONE deployment from any other billet deployment sharing the AWS account — deployment A's role cannot terminate or delete deployment B's resources, because B's carry a different value. Presence remains the fallback for a caller that has no id yet, and is a sound boundary only when a single billet deployment runs in the account.

The static value is safe because the id is stable and knowable at generation time; it needs no principal-tag on the role and so has no forgotten-tag footgun. Value-conditioning holds because a runtime role's CreateTags is restricted to create-time tagging (ec2:CreateAction) and a node never calls CreateTags on its own, so it can stamp an owner tag only on resources it is creating — and RunInstances also requires the request to carry the owner value where it can. The BUILDER's extra standalone CreateTags does not weaken that: it is scoped to image resources that already carry the per-build owner tag, so it can add a tag only to an AMI this builder made. Creates require the owner (by value) in the request; deletes, attach/detach and terminate require it on the resource; CreateSnapshot additionally requires the SOURCE volume to carry it, so a foreign volume cannot be copied into a billet-tagged snapshot. RunInstances and the describes cannot be scoped this way (RunInstances makes several resource types at once; describes are not resource-scopable) and stay on "*".

THE TAG BOUNDARY IS DESTRUCTIVE INTEGRITY; CONFIDENTIALITY NEEDS THE KEY. Tag conditions stop one deployment terminating, deleting or wedging another's resources. ec2:CreateVolume from a snapshot authorizes the PARENT SNAPSHOT as well as the volume it creates — this file used to say the opposite, and the first real clone under the policy was refused for it (see BilletCacheCloneSource) — so the source is scoped by its owner tag in a statement of its own, which stops a role cloning a snapshot that carries no billet owner tag. It does not stop a role cloning ANOTHER billet deployment's snapshot in account-wide mode, where the condition is tag presence rather than value, and a tag is not a secret in any mode. A PER-DEPLOYMENT KMS KEY is what closes that read boundary: with Cache.KMSKeyARN set, every volume and snapshot is encrypted under the deployment's own key, and the policy's KMS statements are scoped to exactly that key — creating a volume from a FOREIGN deployment's snapshot then fails at the KMS grant EBS needs, before any data moves. Measured with iam:SimulateCustomPolicy against the generated policy: every KMS action on the deployment's own key through EBS (kms:ViaService) is allowed; every KMS action on another deployment's key is implicitly denied; a DIRECT KMS call (no ViaService) is denied even on the deployment's own key; and a grant not destined for an AWS service is denied. The boundary therefore holds exactly when each deployment has its OWN key — the terraform module's enable_kms mints one per module instance — and silently reopens if two deployments share a key, which nothing at policy level can detect. The protection is also STRICTLY OPT-IN PER DEPLOYMENT: a deployment that sets no key encrypts under the ACCOUNT's default EBS key — the AWS-managed aws/ebs unless the account configured another — and aws/ebs authorizes ANY principal in the account through EC2 without a kms: statement, so its snapshots stay readable no matter what keys the other deployments use (a shared customer default key is no better: shared is not per-deployment). Setting a key protects the deployment that sets it, not its neighbours — and only the snapshots created AFTER it was set: earlier ones remain under the old key and stay readable until re-snapshotted or evicted. The simulation proves the IDENTITY-policy decisions; that is decisive for keys whose own key policy delegates to IAM (the terraform module's default-policy keys do), while a key whose policy or grants admit foreign roles reopens the boundary on the key side, which no identity policy can see.

A LAUNCH MAY NOT ATTACH A SNAPSHOT BILLET DOES NOT OWN — this deployment's, in per-deployment mode; any billet-owned one in account-wide mode, which is the same weaker boundary every other condition here falls back to without an id. That is the one boundary RunInstances-on-"*" cannot draw by being scoped: RunInstances authorizes every snapshot a block-device mapping names, so an unconditioned grant lets a node role launch an instance with ANY snapshot in the account attached and read it — in a shared account that is the control plane's own ledger snapshots, a volume holding the deployment identity and the node-wire CA key. The other two routes to a foreign snapshot are already closed by tag conditions (BilletCacheCloneSource on CreateVolume, BilletCacheAttach); this one is closed by an explicit DENY, because the Allow it bounds cannot be narrowed without denying the launch itself.

MEASURED WITH iam:SimulateCustomPolicy, 2026-09-04, both modes and both directions each. Per-deployment (StringNotEquals on the owner value): a snapshot with no owner tag is an explicitDeny, one carrying ANOTHER deployment's owner is an explicitDeny, one carrying this deployment's own is allowed, and a plain launch (instance + image) is allowed. Account-wide (Null true, the tag absent): an untagged snapshot is an explicitDeny and a tagged one is allowed. The per-deployment case needs no second statement for the untagged snapshot, which is a property of IAM rather than of this document: a NEGATED operator evaluates true when the key is absent from the request context, so one StringNotEquals catches both the foreign owner and the missing tag.

THE ACCOUNT FIELD IS A WILDCARD, NOT EMPTY, AND THE DIFFERENCE IS THE WHOLE GUARD. An EBS snapshot ARN is documented account-less and BilletCacheCloneSource matches exactly that shape — but a Deny must match every spelling the resource could take, and measured on the same day, `arn:aws:ec2:*::snapshot/*` answers ALLOWED for `arn:aws:ec2:us-west-2:<account>:snapshot/snap-…` while `arn:aws:ec2:*:*:snapshot/*` denies both. So this statement takes the wildcard, the same shape BilletCacheSnapshotCreate and BilletAMIBuilderImage already use.

NOTHING BILLET SENDS IS DENIED BY IT: billet's own RunInstances names no snapshot (setBlockDevices writes DeleteOnTermination and VolumeSize, never a SnapshotId) and a cache clone goes through CreateVolume, which is a different action.

ASKED OF EC2 ITSELF, 2026-09-04, three RunInstances --dry-run calls under a throwaway role holding exactly the rendering below, against the account's real control-plane ledger snapshot (untagged) and the AMI billet boots:

this policy, billet's own launch shape        DryRunOperation
this policy, that snapshot in a mapping       UnauthorizedOperation, explicit deny
the PREVIOUS policy, the same mapping         DryRunOperation

The third line is what the guard is for: before this statement EC2 AUTHORIZED that request, and what it attaches is a volume holding the deployment identity and the CA key, to a machine that runs somebody's workflow. (A dry run answers the authorization question and no other, which is the question here; it says nothing about capacity.) The first line says billet's own launch is untouched, and it answers the AMI question a simulation cannot reach: that launch was allowed although the AMI's own backing snapshot is untagged, so the AMI's backing snapshot was not evaluated while a snapshot the mapping NAMES was. That is measured for the image the acceptance lane boots and the request billet sends, which is the case that matters — and if AWS ever changed it, the failure is a refused launch rather than a widened grant, which is the direction this package chooses everywhere. EC2's own refusal also names the resource it evaluated, `arn:aws:ec2:us-west-2::snapshot/snap-…` — account-less, as the section above says, which the wildcard account matches and would keep matching if that spelling ever changed.

WHAT IT DOES NOT CLOSE, in account-wide mode, is another billet deployment's TAGGED snapshot — the same limitation BilletCacheCloneSource states one section up, for the same reason (presence is all a policy with no deployment id can ask), and closed by the same thing: a per-deployment KMS key.

THE BUILDER IS SCOPED SEPARATELY. `billet ami build` tags its builder instance with a per-build owner (ec2.BuilderOwner), which carries the deployment id in value mode and not in account-wide mode, so the --builder statements match ec2.BuilderOwnerPattern by StringLike and carry their own Terminate — the runtime Terminate is scoped to the deployment's exact owner value and would not reach a builder. The snapshot deny above gets NO builder exemption, which is deliberate in both directions: a build launches from a base image and from the image it just made, and neither names a snapshot, so an exemption would authorize nothing it needs — and with the CreateImage request billet sends today it could not help anyway, because that request tags only the image, so a builder's own backing snapshots carry no owner tag for an exemption to match.

VALIDATED AGAINST A LIVE AWS ACCOUNT with iam:SimulateCustomPolicy in both modes: every action billet performs is allowed for its own tagged resources with the right context; every boundary denies the foreign or wrong-context case; and with a value condition a DIFFERENT deployment's owner-tagged instance is denied, where under presence it was allowed. Access Analyzer reports the document clean. Re-run the simulation after changing a condition; see the drift goldens.

THE THREE GRANTS THE VERIFICATION ADDED ARE SIMULATED TOO, on 2026-08-29, both directions each. Tagging on CreateImage: allowed with the grant, implicitDeny without it, and RunInstances tagging still allowed either way, so the difference is that one CreateAction rather than a broken policy. ec2:GetConsoleOutput on an instance carrying the builder prefix: allowed, and implicitDeny for a foreign owner. The standalone ec2:CreateTags on an image: the same pair. Re-run all of them after changing a condition.

The policy is CAPABILITY-SCOPED: a compute-only node gets the runtime statements alone, and each of cache, spot, a builder, an instance profile and a KMS key adds exactly the statements it needs, scoped to the resource it names.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func PartitionForRegion

func PartitionForRegion(region string) string

PartitionForRegion returns the AWS partition an ARN in region belongs to. The commercial partition is "aws"; China and GovCloud are separate partitions whose ARNs would be malformed under "aws".

Types

type Backup

type Backup struct {
	// Bucket holds this deployment's archives; Prefix isolates it inside one.
	// Both land in an IAM Resource ARN, so both must be literal — a `*` widens
	// the grant to every sibling prefix, and every sibling prefix is another
	// deployment's App key.
	Bucket string
	Prefix string
	// KMSKeyARN, when set, is the customer-managed key the bucket encrypts with.
	// A full key ARN, not an alias or a bare id: IAM resource scoping needs one,
	// and a bare `*` would grant every key in the account.
	KMSKeyARN string
}

Backup describes the archive store a control plane's policy must permit.

THERE IS NO DELETE IN IT, AND THAT IS THE DESIGN RATHER THAN AN OVERSIGHT. billet never issues one — internal/archivestore has no delete at all — so granting the permission would leave the one host that holds the GitHub App private key and the node-wire CA able to destroy the off-site copies whose whole purpose is surviving the loss of that host. Retention belongs to the bucket: versioning and a lifecycle rule, which the Terraform module sets.

type Cache

type Cache struct {
	// Bucket holds the S3 pointer/lease/fencing state; Prefix isolates one
	// deployment inside it. Both name the S3 resources the statement scopes to,
	// and the prefix must be a literal — a `*` or `?` in it would widen the IAM
	// grant to sibling prefixes.
	Bucket string
	Prefix string
	// KMSKeyARN, when set, is the customer-managed key the cache's EBS volumes are
	// encrypted with. It must be a full key ARN, not an alias or bare id: IAM
	// resource scoping needs an ARN, and a bare `*` would grant every key.
	KMSKeyARN string
}

Cache describes the cache storage a node's policy must permit.

type CodeBuild

type CodeBuild struct {
	// ProjectARN is the one project this node may start and stop builds in. A full
	// ARN and a literal — a `*` in it would widen the grant to every project in the
	// account, which is the boundary this is.
	ProjectARN string
	// FleetARN, when set, is the reserved-capacity fleet the node describes for
	// `billet check`. Read-only; nothing here creates or changes a fleet.
	FleetARN string
	// ParameterPath is the Parameter Store path prefix the single-use runner
	// registration is written under, without a trailing slash. A literal, for the
	// reason a cache prefix is: it lands in an IAM Resource ARN, and on a shared
	// account the sibling paths a wildcard admits are other deployments'
	// registrations.
	ParameterPath string
	// KMSKeyARN, when set, is the customer-managed key those SecureString
	// parameters are encrypted with. A full key ARN, not an alias or bare id.
	KMSKeyARN string
}

CodeBuild describes what a codebuild NODE's role must permit.

THE PROJECT IS THE OWNERSHIP BOUNDARY HERE, and that is forced rather than chosen: a CodeBuild build cannot be tagged — tags exist on projects and report groups, and StartBuild has no field that becomes one — so the `aws:ResourceTag/sh.billet.owner` condition every ec2 statement carries has no equivalent. What replaces it is scoping the build actions to exactly one project ARN, which is why that project must be billet's alone: StartBuild and StopBuild on a shared project is a way for billet to stop somebody else's build.

type CodeBuildRole

type CodeBuildRole struct {
	// ParameterPath is the same prefix the node writes under, so the build can read
	// the registration staged for it and nothing else.
	ParameterPath string
	// KMSKeyARN, when set, is the key it needs kms:Decrypt on.
	KMSKeyARN string
	// LogGroupARN, when set, scopes the log grant to one group. Empty leaves logs
	// on "*", which is what CodeBuild's own default log group requires.
	LogGroupARN string
}

CodeBuildRole describes what the BUILD's own service role must permit.

IT STARTS NOTHING AND DELETES NOTHING. This role runs inside the compute that executes a workflow, so every permission it holds is a permission that workflow holds: it reads the one parameter carrying its own registration, and writes its own logs. `ssm:DeleteParameter` is deliberately absent — cleanup is the node's job — and so is every codebuild action, because a role that could start a build, from inside a build, is how a job launches runners billet never escrowed capacity for.

type CodeBuildSweep

type CodeBuildSweep struct {
	// ParameterPath is the prefix the node stages registrations under, without a
	// trailing slash. A literal, because it lands in an IAM Resource ARN.
	ParameterPath string
	// Account is the AWS account the parameters live in. Required because a
	// parameter ARN names one and this rendering has no other ARN to take it from.
	Account string
}

CodeBuildSweep describes what the CONTROL PLANE must permit in order to remove the staged runner registrations a dead codebuild node never reaped.

A THIRD PRINCIPAL BESIDE THE NODE AND THE BUILD, and the narrowest. It lists the names under one path and deletes the ones the LEDGER has proved dead: no GetParameter (it never reads a registration), no PutParameter (it stages none), no KMS (a listing that asks for no decryption calls no key), and nothing from codebuild at all. It is rendered separately so that reads at a glance.

THE MISSING KMS ACTION DOES NOT, ON ITS OWN, KEEP A REGISTRATION OUT OF THIS PRINCIPAL. Measured under a real role holding exactly this statement: a listing that asked for decryption received plaintext, because the account's aws/ssm key authorises any principal reaching it through Parameter Store. What keeps the value out is billet's request never asking (codebuild.RegistrationSweeper), and a customer-managed key is what makes this grant decisive as well — measured: under one, the same role's decrypting listing was refused kms:Decrypt while its delete still succeeded.

type Identity

type Identity struct {
	// Prefix is the Parameter Store path this deployment's identity lives under.
	// It lands in an IAM Resource ARN, so it must be literal.
	Prefix string
	// KMSKeyARN, when set, is the customer-managed key the SecureStrings are
	// encrypted with. A full key ARN, not an alias or a bare id.
	KMSKeyARN string
}

Identity describes what a CONTROL PLANE needs to reach this deployment's identity material in Parameter Store.

THE MOST SENSITIVE GRANT BILLET GENERATES, and the prefix is the whole of the boundary. What lives under it is the GitHub App private key — which can mint tokens for an entire organization and which GitHub issues exactly once — and the node-wire certificate authority, which decides who may connect to this control plane. A `*` in the prefix reaches every sibling, and every sibling is another deployment's.

THERE IS NO DELETE, deliberately, and for the reason `backup` states one level over: the credential on a host that also holds these must not be able to destroy them. Removing a parameter is a console or CLI action an operator takes knowing what it is.

type Inputs

type Inputs struct {
	// Owner is the deployment identity (`billet init iam` reads it from the state
	// directory). When set, every ownership condition matches this exact value, so
	// the policy isolates ONE deployment from any other billet deployment sharing
	// the account. When empty the conditions fall back to tag PRESENCE, a boundary
	// sound only for a single billet deployment per account.
	Owner string
	// Partition is the AWS partition ("aws", "aws-cn", "aws-us-gov") the ARNs are
	// built in. Empty defaults to "aws".
	Partition string
	// DNSSuffix overrides the endpoint DNS suffix used in the kms:ViaService and
	// iam:PassRole service conditions. Empty derives it from Partition (amazonaws.com,
	// or amazonaws.com.cn for aws-cn). It exists so the Terraform module's committed
	// rendering can carry a substitutable sentinel independent of the region sentinel
	// — a real deployment never sets it and takes the partition's own suffix.
	DNSSuffix string
	// Region scopes the KMS kms:ViaService condition to this region's EC2 service.
	// Required when Cache.KMSKeyARN is set.
	Region string
	// Cache, when non-nil, adds the sticky-disk cache statements.
	Cache *Cache
	// CodeBuild, when non-nil, adds the statements a codebuild NODE needs.
	//
	// A DIFFERENT PRINCIPAL FROM THE BUILD ITSELF, and that split is the whole
	// reason there are two structs. This one starts and stops builds and stages the
	// single-use runner registration; CodeBuildRole below is what runs INSIDE the
	// compute that executes somebody's job, and it may only read one parameter and
	// write logs. One role carrying both is the NoCompute mistake wearing different
	// clothes — a role that could start builds, running inside a build, is a way for
	// a job to launch runners billet never escrowed capacity for.
	CodeBuild *CodeBuild
	// CodeBuildRole, when non-nil, renders the BUILD's own service role. It
	// describes a different principal from everything else here.
	CodeBuildRole *CodeBuildRole
	// CodeBuildSweep, when non-nil, adds the statement the CONTROL PLANE needs to
	// sweep staged registrations under one path. A third principal again: it is
	// what `billet server` runs as, never the node and never the build.
	CodeBuildSweep *CodeBuildSweep
	// Backup, when non-nil, adds the archive-store statements the CONTROL PLANE
	// needs. On the recommended AWS topology the root module passes fleet-ec2's
	// instance profile to the co-located controller, so one role carries both;
	// a standalone controller gets a policy with this and nothing else.
	Backup *Backup
	// Identity, when non-nil, adds the statements a CONTROL PLANE needs to read
	// and publish this deployment's identity material. Only a controller ever gets
	// these: a node holds no App key and no certificate authority.
	Identity *Identity
	// SpotQueueARN, when set, adds the interruption-queue statement scoped to it.
	SpotQueueARN string
	// InstanceProfileRoleARN, when set, adds an iam:PassRole scoped to that role.
	// It must be the ROLE ARN, not an instance-profile name.
	InstanceProfileRoleARN string
	// Builder adds the ec2:CreateImage the AMI builder needs.
	Builder bool
	// Payload, when non-nil, adds the S3 statements `billet ami build
	// --payload-bucket` needs to stage the shared installers. It is meaningful
	// only for a BUILDER and is refused without one: nothing else billet does
	// touches that bucket, so granting it to a plain node role would widen the
	// role every job's instance is launched by for a command it never runs.
	Payload *Payload
	// NoCompute omits the runtime statements entirely, for a principal that
	// launches nothing.
	//
	// A CONTROL PLANE IS SUCH A PRINCIPAL, and this exists because the first
	// version of the backup policy did not have it: a standalone controller that
	// only puts archives in a bucket came out holding ec2:RunInstances and
	// ec2:TerminateInstances, on the one host in the deployment that also holds
	// the GitHub App private key. The zero value keeps meaning "the runtime-only
	// node policy", so nothing that does not ask for this changes.
	NoCompute bool
}

Inputs is the deployment a policy is built for. The zero value yields the runtime-only policy a compute-only node needs.

func (Inputs) Build

func (in Inputs) Build() (Policy, error)

Build assembles the policy for these inputs, or reports why it cannot.

type Payload added in v0.7.0

type Payload struct {
	// Bucket is where the archive is staged. It lands in an IAM Resource ARN, so
	// it must be literal: a `*` here would reach every bucket whose name shares
	// the prefix.
	Bucket string
}

Payload describes the bucket `billet ami build` stages its shared installers in, when they are too large for EC2's user-data limit to carry.

THE GRANT IS SCOPED BY OBJECT NAME, NOT BY PREFIX, and that is stricter than a prefix would be. billet writes `billet-payload-<digest>-<nonce>.tar.gz` at the bucket root and refuses a key containing a slash, so the resource can name exactly the objects billet creates — an operator may keep anything else in the same bucket and this role cannot read, replace or delete it.

type Policy

type Policy struct {
	Version   string      `json:"Version"`
	Statement []Statement `json:"Statement"`
}

Policy is an AWS IAM policy document.

func (Policy) JSON

func (p Policy) JSON() ([]byte, error)

JSON renders the policy as indented, deterministic JSON. Struct fields marshal in declaration order and Go sorts map keys, so the same inputs always produce the same bytes — which is what the drift test and the Terraform rendering rely on.

type Statement

type Statement struct {
	Sid       string         `json:"Sid"`
	Effect    string         `json:"Effect"`
	Action    []string       `json:"Action"`
	Resource  []string       `json:"Resource"`
	Condition map[string]any `json:"Condition,omitempty"`
}

Statement is one rule. Every statement here is an Allow but one — the snapshot boundary the package comment describes, which has to be a Deny because the Allow it bounds cannot be narrowed without denying billet's own launches. Condition is omitted when empty so an unconditioned statement does not render an empty object.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL