Skip to content

Admin-Controlled Deploy Rules

Status: Design / not yet implemented — parked for later Depends on: the deploy preflight work (internal/deploy/preflight.go), which supplies the client-side evaluation point these rules plug into. Preflight has since landed, along with keel auth connect github, which moves require_status_check (§5) closer than the rest — see the detail under it. Related: docs/design/app-environment-separation.md — rules are keyed on the (app, env) pair that design establishes.


1. Motivation

An admin should be able to say "production for this app deploys only from main", and have every keel deploy honour it without each developer remembering to. Rules live in DynamoDB, are fetched at deploy time, and are evaluated before anything is built.

This is adjacent to, not a replacement for, GitHub branch protection. Branch protection guards the repository; these rules guard the deploy. You want both.

2. The enforcement caveat — read this first

As the IAM policies stand today, a rules table alone would be advisory only. Two independent bypasses exist in internal/auth/policies.go:

  1. The developer policy's DeployTracking statement grants dynamodb:PutItem / UpdateItem on arn:aws:dynamodb:*:*:table/keel-*. A keel-deploy-rules table matches that wildcard, so a developer could rewrite the rules that constrain them.
  2. The CodeBuild statement grants codebuild:StartBuild on Resource: "*". A developer can skip keel deploy entirely — aws codebuild start-build --source-version <any-sha> — and never read the rules table at all.

So decide explicitly which is being built:

  • Guardrail — stops honest mistakes ("oops, deployed from my feature branch"). Client-side evaluation is sufficient.
  • Control — survives a motivated insider. Enforcement must live somewhere the developer's own credentials cannot reach.

Recommended: build the guardrail first, but fix the two IAM grants regardless (they are over-broad on their own merits), and shape the schema so the control tier can be layered on without changing the rule format.

IAM fixes worth doing regardless

  • Replace table/keel-* in DeployTracking with an explicit list — keel-deployments, keel-deploy-locks. Rules stay read-only for developers. The wildcard is broader than the Sid name implies.
  • Scope codebuild:StartBuild to the generated project ARNs rather than *.

3. Where enforcement can live

The headline rule — "prod only from main" — is one IAM fundamentally cannot express: IAM has no concept of a git commit. Three tiers, increasing strength:

Tier 1 — client-side, in preflight. The ruleset is fetched and evaluated in deploy.Preflight alongside the local git checks. Cheap, best error messages, fully bypassable.

Tier 2 — in the generated buildspec. The buildspec is emitted by internal/infra/hcl/codebuild.go (codeBuildProject) and changing it requires a tofu apply, which is admin-only. A pre-build gate is therefore beyond a developer's reach:

yaml
pre_build:
  commands:
    - git fetch --depth=50 origin main
    - git merge-base --is-ancestor $CODEBUILD_RESOLVED_SOURCE_VERSION origin/main ||
        { echo "Refusing: commit is not on main"; exit 1; }

This catches a raw start-build too, because it is the one path both keel deploy and the AWS CLI must traverse. Note it requires raising git_clone_depth from 1, or the targeted fetch shown above.

Tier 3 — IAM conditions, for the subset of rules IAM can actually see. require_mfa: true maps to an aws:MultiFactorAuthPresent condition on the assume-role policy — genuine server-side enforcement, and a natural extension of the MFA work already in internal/auth/.

4. Storage and schema

Table keel-deploy-rules, created in internal/auth/bootstrap.go alongside the existing three. Partition key AppName, sort key Environment — mirroring how keel-deployments keys on AppName + Timestamp.

App-level defaults with per-environment overrides; the environment wins.

yaml
allowed_branches:   [main]
require_clean_tree: true
require_pushed:     true          # ties to the preflight remote-reachability check
require_mfa:        true
min_access_level:   developer     # viewer | developer | admin
allow_skip_release: false
allow_force_unlock: false
allowed_deployers:  []            # empty = anyone meeting min_access_level
freeze_windows:     ["Fri 17:00 - Mon 09:00 America/New_York"]
require_approval:   false
require_status_check: false       # GitHub check-runs must pass for the SHA

Two of these close holes that exist today:

  • allow_skip_release--skip-release (internal/cli/deploy.go) skips migrations. In production that is nearly always a mistake, and nothing currently prevents it.
  • allow_force_unlock--force stomps another engineer's deploy lock with no record of who did it or why.

5. CI-like checks

Keel should not reimplement required reviewers or PR checks. It should consume their result:

  • require_status_check — before deploying, query GitHub's check-runs API for the commit and require success. This yields "you cannot deploy a commit whose tests did not pass", which is the highest-value rule in the set.
  • For CodeCommit there is no check-runs equivalent, so the Tier-2 buildspec gate is the mechanism. Another reason to invest there.

require_status_check in more detail

Deferred deliberately, not forgotten. Recording the shape while it is fresh.

Why it is now closer than it was. CodeConnections support landed with keel auth connect github, so an operator already authorizes an AWS Connector GitHub App per organization, and the ARN is already discoverable at /keel/connections/github/<org>. The organization boundary a status check needs is therefore already modelled.

What it still needs, and why that is the hard part. A CodeConnections connection does not give Keel an API credential for GitHub. It authorizes CodeBuild to clone; there is no supported way to borrow it to call the check-runs API. So this needs a separate GitHub credential — a fine-grained PAT or a Keel-owned GitHub App — stored per organization alongside the connection ARN (SSM under /keel/*, encrypted, which the existing policies already cover).

That is the real cost, and it is not small: a second thing to create, store, rotate, and revoke, with a different lifecycle from the connection. It should not be bolted onto keel auth connect github as though it were free.

Where it would run. In deploy.Preflight, as a Connection-style function field: resolve the commit, ask GitHub for its check-runs and commit statuses, and require every required check to have concluded successfully. Preflight already resolves the exact SHA being deployed, which is the input this needs.

Decisions to make before building it:

  • Required vs all checks. GitHub's branch protection knows which checks are required; the check-runs API returns everything that ran. Reading required checks needs a separate call and more token scope. Treating all checks as required will block deploys on advisory or flaky jobs.
  • Pending vs failed. A check still running is not a failed check. Blocking on pending is right for production and infuriating elsewhere, so this probably wants to be require_status_check: strict | passing | off rather than a bool.
  • No checks at all. A commit with zero check-runs must not read as "passing". Fail closed, consistent with §6.
  • Waivability. By the rule established in preflight: an authoritative "this check failed" blocks and cannot be waived; an API call that failed is a warning. A token that has expired is the latter, not the former.
  • Forks and PR merge commits. Checks run against the PR head, which is not the commit on main after a squash merge. The SHA being deployed may legitimately have no checks of its own.

Not a substitute for branch protection. This gates the deploy, not the merge. It is worth having precisely because someone can deploy a commit that never went through a PR; it does not remove the reason to protect the branch.

require_approval has no GitHub-repo analogue but matches GitHub Environments: a second admin approves before promote. The primitives already exist — the deploy-lock table gives the pending-approval row, and Build / RegisterRevision / Promote are already separate in internal/deploy/deployer.go. That split was built so a release command could run between the last two; an approval gate needs exactly the same seam.

6. Two details that will bite

Fail closed. Naive code treats "no rules found" as "no restrictions", so dropping a network connection at the right moment bypasses production. Mark environments as protected in the app registry (internal/project/registry.go); if an environment is protected and its rules cannot be read, refuse the deploy.

Audit. Stamp the evaluated ruleset and the verdict into the keel-deployments record. keel history then answers "who deployed, from what branch, under which rules, with which overrides" — most of the compliance story, and nearly free, since that record is already written on every phase transition.

7. Suggested order

  1. Deploy preflight (separate, already scoped) — no new infra.
  2. The two IAM tightenings in §2. Everything downstream is theatre without them.
  3. keel-deploy-rules table + keel rules get/set behind requireAccess(cmd, auth.Admin), evaluated in preflight. Guardrail tier.
  4. Buildspec gate for allowed_branches. Promotes guardrail to control.
  5. require_approval and require_status_check.

Keel — the AWS CLI you've always wanted.