terraform plan, for your app's authentication surface.
autumn routes audit proves what your security
surface is: every mounted route, classified from macro-expanded
#[secured] / #[authorize] / #[public] code. This page is about the next
question — what a pull request did to it, and whether a human agreed.
### 🛡️ Security posture diff
**1 widening change — acknowledgment required.** This pull request makes part
of the app reachable by more callers than before.
| Change | Method | Path | Before | After |
|---|---|---|---|---|
| guard removed: gated → public | `GET` | `/admin/users` | `gated (roles: admin)` | `public` |
To acknowledge these exact changes, comment on this pull request with:
/ack-posture 4f8a1c0d9e2b7a35 ceiling=GET|/admin/users|public|0:|0:|0|absent|0:|0
That comment is the only way past the gate, and it is recorded on the pull request, by a named human, at the moment the decision was made. "Who approved making this endpoint public?" now has an answer.
What blocks, and what doesn't
Only widening blocks. Everything else annotates, and a pull request that changes no posture posts nothing at all.
| Change | Verdict |
|---|---|
New route, public or unclassified | blocks |
gated / framework → public | blocks |
Role requirement dropped (#[secured("admin")] → #[secured]) | blocks |
| Role added — roles are OR-ed, so another role admits more callers | blocks |
| Scope removed — scopes are AND-ed, so one fewer lets more tokens in | blocks |
#[authorize] binding removed, or a policy check dropped | blocks |
| CSRF enforcement lost on a route (disabled, or newly exempt) | blocks |
A prefix added to security.csrf.exempt_paths | blocks |
| A security header stops being emitted | blocks |
| Route removed, and a weaker route still answers its URLs | blocks |
| Last route at a path removed, and a public route inherits the URL | blocks |
New gated route that takes a stricter route's URLs over | blocks |
New gated or framework-owned route | annotates |
| Route removed, gate strengthened, scope added, policy added | annotates |
A prefix withdrawn from security.csrf.exempt_paths | annotates |
| A security header's value changed (e.g. a new CSP) | annotates |
| Handler renamed, moved to another file, re-moduled | nothing at all |
Two of those rows are worth dwelling on, because they run against intuition:
- Adding a role widens.
#[secured("admin", "editor")]admits either role. Addingeditorlets a strictly larger set of people through. - Removing a scope widens.
__check_secured_scopesrequires all listed scopes. Dropping one lets a strictly larger set of tokens through.
Deleting a route can widen; adding one can too
The two conditional rows are the gate's least obvious behaviour, and they exist
because a route is not a URL. The router matches a static segment before a
dynamic one and mounts both happily, so /users/me and /users/{id} coexist
with the static one winning.
- Delete
/users/me— gated — while a public/users/{id}remains, and that URL does not go away. It falls through to the public route, and the guard is gone. Reported asroute_shadow_exposed. - Add
/users/me— restricted toeditor— beside a/users/{id}restricted toadmin, and the new route takes that URL over. Editors reach what neededadmina moment ago, while the/users/{id}entry sits unchanged in both manifests. Reported asroute_added_shadowing.
A path is a node, not a set of routes, so there is a third case. Every method
at one path shares a single handler table, and while any method is mounted the
path answers 405 for the rest. Delete the last route at /users/me and
that node goes with it: POST /users/me, previously a 405, now reaches
whatever less specific route covers it. Reported as route_path_exposed, and
blocking only when that route is public — newly reachable but guarded
annotates, exactly as a new guarded route does. While any method stays
mounted at the path, though, the node keeps answering (with a 405) and nothing
is inherited at all.
All of them are decided by precedence, not by overlap: a survivor that was
already answering the URL gains nothing, so deleting a gated /users/{id}
beside a public /users/me is an ordinary removal. Where several routes could
take a deleted route's URLs, only the one that actually wins them counts —
/records/me/{id} beats /records/{user}/private, so the latter's posture is
irrelevant to that deletion. The same analysis covers
#[authorize] bindings — a check that disappears from a URL that is still
answered is authorization_binding_displaced — and it follows the methods a
route really answers, so a WS route mounted as a GET, or a GET that also
serves HEAD, is compared as the router will compare it.
And one row is deliberately weak: a security header's value changing is reported, never blocked. Whether one Content-Security-Policy is weaker than another is not decidable from the strings, and a gate that blocks on "the CSP changed" is a gate a team mutes by the end of the week.
Turning it on
The CLI has to be new enough. The scaffolded workflow installs the
autumnrelease your app'sautumn-webversion tracks. Ifroutes posturepostdates that release, it probes forward through the next few releases and installs the first one that has it — for that run only — instead of staying stuck. That's a specific, bounded release, not a moving "latest": it doesn't drift further from yourautumn-webversion with every later, unrelated release. But it also doesn't resolve itself — the workflow still installs your pinnedautumn-webversion first, on every run, so publishing a newer compatible release upstream changes nothing here on its own. The fallback keeps firing until you raise your app'sautumn-webversion and rerunautumn upgrade --applyto pick up a pin that already has the command. Until then, the gate still runs (rather than staying red until you raiseautumn-webyourself), just under a CLI that may not exactly match yourautumn-webversion. If nothing in range has the command, the first run fails, naming it. It fails rather than skipping on purpose: a gate that waves a pull request through because its own tooling is too old is worse than a red one.
New apps get it by default: autumn new scaffolds
.github/workflows/posture-gate.yml alongside ci.yml.
Existing apps adopt it with the same one command that reconciles every other framework-owned file:
autumn upgrade # preview — shows the workflow it would add
autumn upgrade --apply # write it
Then commit a baseline, which is the manifest the gate diffs against. Build it
with the flags in the workflow's POSTURE_AUDIT_FLAGS (see
Which build the gate audits):
autumn routes audit --release --manifest security-posture.json
git add security-posture.json
Enabling the gate on a repository whose posture has not changed never breaks it. The first run finds no baseline on the base branch, blocks nothing, and says so.
Two settings finish the job:
- Make Security posture diff a required check in Settings → Branches. Until it is required, the gate reports; it does not gate.
- Put
.github/workflows/under CODEOWNERS review. Like everypull_requestworkflow on GitHub, this one runs from the pull request's own copy of itself, so a change to the gate is only as trusted as the review of that change. The workflow refuses to run a pull request that edits it — land workflow changes on their own, reviewed by someone who owns that path.
Deleting the committed manifest does not quietly turn the gate off either: a pull request that removes it fails the check, because a merged deletion would disarm the gate for every pull request after it. And a build that fails does not turn it off by omission — the verdict job runs even when the manifest job does not, and fails closed rather than being skipped (GitHub counts a skipped required check as satisfied).
If your app is a Cargo workspace member
When the git root is also the workspace root, autumn upgrade --apply writes
the workflow at the git root and names your member:
env:
POSTURE_MANIFEST: apps/my-app/security-posture.json
POSTURE_AUDIT_FLAGS: --release -p my-app
It writes ci.yml at the git root too, and runs it in the crate directory.
Everything else in the gate works unchanged, because it only reads the manifest
path.
One repository holds one posture-gate.yml. If another member already wrote
it, autumn upgrade reports a conflict and does not overwrite it. A second
member needs its own workflow. Copy the file and give it a new name. Then set
POSTURE_MANIFEST and POSTURE_AUDIT_FLAGS for that member.
If the workspace root is below the git root, the gate cannot build the member from the git root. Adopt the gate by hand in that layout.
A nested git repository or submodule is its own git root. Its workflow stays in the member.
The deploy workflows that autumn release init writes also read the manifest
path from one variable, POSTURE_MANIFEST. Move a deploy workflow to the git
root. Then change the value of POSTURE_MANIFEST to the path above. If the file
is missing, the deploy prints a ::warning:: and does not attest. The deploy
does not fail, so apps that never adopted the gate still ship.
How a run works
The workflow is two jobs, and the split is the point:
manifestcompiles the pull request and emits its posture manifest. Compiling runs the pull request's own build scripts and procedural macros, so this job is treated as untrusted: it holds no write permission and reaches no verdict.posturenever compiles anything. It downloads that manifest, diffs it with a CLI it installs itself, resolves acknowledgments, posts the comment, and decides.
So the machinery that decides — the diff, the acknowledgment check, the exit code — cannot be replaced by code the pull request ships. What that does not buy: the manifest itself is derived from building that code, and a build script can make an app dump whatever route list it likes. A build-derived manifest is only ever as trustworthy as your review of the build scripts and macros in the diff; no gate can assert that away.
Step by step:
autumn routes audit $POSTURE_AUDIT_FLAGS --manifest security-posture.jsonbuilds this commit's manifest, with the build your image ships. That is the only build the gate pays for.- The workflow fails if the committed
security-posture.jsondescribes a different posture than the one just built — a stale committed manifest would make the diff lie. The comparison is by posture digest, not by bytes, so a moved line number or a renamed handler never turns into a regenerate-and-commit chore; a real posture change does. - The base branch's copy is read straight out of git
(
git show origin/<base>:security-posture.json). No second build. autumn routes posture diffcompares the two and exits0,1, or2.- One comment is posted, or updated in place if it already exists.
So the manifest is your app's posture state file: it lives in the repository, it changes in the same pull request as the code, and it is the artifact a release signs.
Which build the gate audits
The manifest describes one binary. Two builds can mount different routes. A
route behind #[cfg(not(debug_assertions))] is only in a release build. A
route behind a feature is only in a build with that feature. Thus the gate
must audit the build you deploy.
The workflow sets the build in one place:
env:
POSTURE_AUDIT_FLAGS: --release
The scaffolded Dockerfile builds --release. If you deploy with
autumn build --embed, add --features embed-assets. POSTURE_AUDIT_FLAGS
takes cargo build flags: --release, --profile, --features,
--all-features, --no-default-features. When you change it, regenerate the
baseline with the same flags. This change edits the gate, so make it in a
separate pull request.
Two checks find a wrong build:
- The manifest records its build in
build. A non-default build is part of the posture digest. Thus a baseline made with other flags fails the staleness check, and the attested digest names the build. - If the base and head manifests come from different builds,
routes posture diffreportsbuild_changedas a widening. A diff across builds can hide a widening, so a person must acknowledge it. Expect this once, on the pull request that changes the baseline.
Acknowledging a widening
Comment on the pull request with the line the report prints:
/ack-posture 4f8a1c0d9e2b7a35 ceiling=GET|/status|public|0:|0:|0|absent|0:|0 intentional: public status page for launch week
Rules worth knowing:
-
Who. The workflow asks GitHub for each commenter's repository permission (
repos/{owner}/{repo}/collaborators/{login}/permission) and keeps onlyadmin,writeormaintain. It deliberately does not trustauthor_association:MEMBERmeans "member of the owning organization", which in an org with per-repository permissions includes people with no access to this repository at all.autumnitself has no GitHub identity and enforces no authorization — the workflow step is where that decision lives. A team that wants something narrower (excluding the pull request's own author, say — nothing stops a maintainer acknowledging their own widening today) edits that step. Note that editing it makes the file diverge from the scaffold, soautumn upgradewill report it as a conflict from then on rather than silently overwriting your version. -
Re-running. Posting the comment does not re-run the check:
pull_requestdoes not fire on comments. After commenting, re-run the Security posture check from the Checks tab, or push a commit. -
What it binds to. The report prints a marker with a
ceiling=token. Paste it as is. The token holds the approved posture of each widened route: classification, roles, scopes, policy, CSRF,#[authorize]bindings and mTLS. The gate asks one question: is the route, now, no wider than that?- Narrowing keeps the acknowledgment valid. Add a policy check after you acknowledged a public route, and the gate does not ask again.
- Removing a constraint blocks again. Acknowledge "public, behind a new
mfascope", dropmfaon the next push, and the route is wider than the ceiling. - Unrelated pushes keep it valid. A new widening needs a new acknowledgment.
- The marker digest covers the ceiling text. A truncated or edited paste does not match. This catches mistakes. It is not a secret: anyone can compute the digest for a wider ceiling. Paste only the line the report printed. Do not paste a line from another author without a check.
The gate uses the same rules as the diff: roles are OR-ed, scopes are AND-ed, an open classification is widest. A finding that names no single route at head (a build change, a header, a CSRF exemption prefix) has no posture to order, so it stays bound to the exact set.
For an exact match, paste the second line of the report instead. It has the exact digest and no
ceiling=. A different digest from the first line is normal. Any later change to the route, narrowing included, asks again. -
One file per comment. The workflow writes each comment to its own file and passes the directory as
--ack-dir. The diff parses each file alone, so an unclosed code fence in one comment cannot change another, and no comment text can fake a comment boundary. -
Where it doesn't apply. Quoted lines (
> /ack-posture …) and lines inside fenced code blocks never acknowledge anything, so quoting a colleague — which GitHub's reply button does for you — cannot approve a widening by accident. -
Text after the digest and the
ceiling=token is the reason. It is echoed back into the comment the gate posts, so the record reads as a decision, not a checksum.
When the tool is wrong
Acknowledgment is the escape hatch, and it is the only one this gate provides. There is no flag that disables it, hides the diff, or excludes a route: a false positive is unblocked exactly the way a true positive is — by a named person saying so on the pull request, in public, with a reason. A wrongly blocked pull request is therefore always unblockable by the team alone, without waiting on a framework release.
(What no in-repo gate can defend against is the repository's own settings: an
administrator can always dismiss a required check, and a pull request that
rewrites the workflow could rewrite this one. The workflow refuses to run when
it is itself edited, and CODEOWNERS on .github/workflows/ is what turns that
refusal into a review.)
If the tool got it wrong, please also open an issue with the two manifests; the diff rules are meant to be exact, and a false positive is a bug, not a fact of life.
Recipe: watch it block on reddit-clone
examples/reddit-clone is in autumn's own posture fleet. Its manifest,
examples/reddit-clone/security-posture.json, holds every route in the app.
Each route has a classification. Post edit and delete, voting, subreddit
creation and avatar upload are gated by #[secured]. The pages, feeds and
auth forms are #[public]. The comment router is mounted with nest, and
commentable::route_infos declares its two routes.
Now change one line in delete_post:
-#[secured]
+#[public]
#[delete("/r/{sub_slug}/posts/{post_slug}")]
pub async fn delete_post(
The code compiles. routes audit passes too: every route still carries a
classification, and the audit cannot know that this one used to be stricter.
Only a diff against the accepted posture can see that. The gate posts this on
the pull request:
### 🛡️ Security posture diff
**1 widening change — acknowledgment required.** This pull request makes part of the app reachable by more callers than before.
| Change | Method | Path | Before | After |
|---|---|---|---|---|
| guard removed: gated → public | `DELETE` | `/r/{sub_slug}/posts/{post_slug}` | `gated` | `public` |
To acknowledge these changes, comment on this pull request with:
```
/ack-posture 60c40354ed47391c ceiling=DELETE|/r/%01/posts/%01|public|0:|0:|0|on|0:|0
```
This comment approves these findings. It also approves any narrower posture on these routes. Unrelated pushes keep it valid. A new widening, or a wider route, needs a new comment.
For an exact match, use this line instead. Any later change asks again:
```
/ack-posture ead67427ebc94624
```
<sub>posture digest `4f8df19590b3afb15479ab90cb349a19ae3ff676eb4bcb1ff615f5ffb917b9af` • generated by `autumn routes posture diff`</sub>
<!-- autumn-posture-gate -->
The pull request stays red until someone with write access comments that
/ack-posture line.
Do it yourself with two commands:
autumn routes audit --release --manifest head.json
autumn routes posture diff --base security-posture.json --head head.json
autumn's CI runs this demo on every publish-gate run.
scripts/check-posture-demo.sh makes the widened manifest. It checks that
the diff blocks it, and that the printed marker unblocks it. Then it compares
both reports with the golden files in examples/reddit-clone/posture-demo/,
and the block above with the blocked report. A change to the diff's wording
or rules changes those files in the same pull request.
Verifying at deploy time
The posture manifest a release ships is signed by the same keyless Sigstore
pipeline as everything else autumn ships — see
Verify What You're Running. The scaffolded deploy
workflows attest security-posture.json with
actions/attest-build-provenance, binding those exact bytes to the repository,
commit and CI run that produced them.
One command proves both halves at deploy time:
autumn routes posture verify \
--manifest security-posture.json \
--expect-digest 9b1c… \
--repo your-org/your-app
--expect-digest— the posture digest recorded when the change was acknowledged (the gate's comment prints it, andautumn routes posture digest --manifest security-posture.jsonre-derives it). This answers is this the posture a human approved? It is computed over the manifest's security-relevant content only, so regenerating the manifest does not invalidate it while the posture holds.--repo— runsgh attestation verifyunder the hood. This answers did this file come out of our CI, unmodified?
It exits 0 only if every check that ran passed and at least one actually
ran. Tamper with one byte of a route's classification and the digest check
fails; substitute a manifest from somewhere else and the signature check fails;
omit both --expect-digest and --repo and the run fails rather than
reporting a pass it did not earn. --expect-digest takes the full 64-character
digest — the 16-character short form exists for the comment marker a human
types, not for a value a deploy script passes.
--skip-signature exists for genuinely offline hosts. It is reported as
waived, never as passed, and a run that waives both halves fails: a
verification that verified nothing is not a pass.
Prove the check is real, the same way the supply-chain guide does:
# Flip a route to public in a copy of the manifest…
sed 's/"gated"/"public"/' security-posture.json > tampered.json
autumn routes posture verify --manifest tampered.json \
--expect-digest 9b1c… --skip-signature
# ✗ acknowledged-posture does NOT match the acknowledged digest 9b1c… …
# exit status 1
Command reference
| Command | What it does |
|---|---|
autumn routes posture diff --base B.json --head H.json | The gate. Markdown by default; --format json / text. |
… --ack-dir acks/ | One file per pull-request comment, each scanned alone for /ack-posture markers. |
… --ack-file acks.txt | Comments in one file, divided by <!-- autumn:ack-source --> lines. Prefer --ack-dir. |
… --ack <digest> | The same acknowledgment, inline — for local runs. |
… --allow-missing-base | No baseline yet: report, block nothing. |
… --output posture-diff.md | Also write the report to a file. |
autumn routes posture digest --manifest M.json | The posture digest a release records. |
autumn routes posture verify --manifest M.json --expect-digest D --repo o/r | Deploy-time proof. |
Exit codes: 0 clean or acknowledged · 1 blocked (unacknowledged widening, or failed verification) · 2 usage or I/O error. CI can tell "this pull request widens the surface" from "the tool could not run".
What it cannot see
The diff is exact about what the manifest proves, and silent about what it doesn't. It inherits every boundary of the manifest's provenance classes:
- Runtime enforcement. This is a review-time and deploy-time control. It
never blocks a request, and it does not change what
#[secured]or#[authorize]do. - Which policy answers an
#[authorize]check. The manifest proves the(action, resource)binding; whichimpl Policy<Resource>is registered at boot is a runtime fact, disclosed as the dimension'sruntime_caveat. - Plugin-contributed routes. They are not in the manifest yet; that composes with the plugin manifest work in #1601.
- Anything the app doesn't mount through the route registry. A hand-rolled
axum::Routermerged in at the edges is invisible toroutes audit, and so invisible here. - A renamed
#[authorize]resource. RenamingPosttoArticlereads as one binding removed and another added, and no manifest can tell that from a genuine loss. It blocks, and the report names the pairing so you can acknowledge it in one step. - Middleware-imposed guards. A route guarded by a layer, such as
RequireApiToken, isunclassifiedtoroutes audit. The audit then fails before this gate runs. Mount the group withscoped_guardedto declare the guard. The route is thengatedwithprovenance: "declared". The audit cannot check that claim, and neither can this gate. Review the layer. - A build that lies. The manifest comes from compiling the pull request, so a build script or procedural macro in the diff can influence what the app reports about itself. The gate isolates the verdict from that code (see "How a run works"), not the measurement. Review build scripts as the privileged code they are.
- Which URLs a CSRF exemption prefix actually covers.
routes auditasks whether a route template matches a configured prefix, while the middleware asks it of the request path — so exempting/users/meleavesPOST /users/{id}recorded as enforced. The gate therefore treats the prefix list itself as posture: adding a prefix blocks, whatever the per-route rows say. Expect the report to name the prefix rather than the routes it touches. The acknowledgment binds only to routes that serve a URL under the prefix. A route is left out when a more specific route wins every URL it would serve. The CSRF middleware cleans the request path first. A capture or a catch-all can hold..,.or%2e. For example, a request to/other/../apireachesPOST /other/{id}/api. The middleware cleans that path to/api. The acknowledgment binds to such a route too. Three limits remain:- A
%2finside a capture is not modeled. A%2fcan also hide... - The gate does not rank these routes by router precedence. It can bind a route that no request reaches.
- A route with many captures can have too many cleaned shapes to track. The gate then binds the route.
- A
- A build you do not audit. The gate audits the build in
POSTURE_AUDIT_FLAGS. A route that exists only in another build is not in the manifest. Keep the flags the same as your image build. Thebuildobject records only the Cargo flags. It does not recordRUSTFLAGS,CARGO_PROFILE_*variables or.cargo/config.tomlsettings, which can also change the build.
See also
- Route auth coverage — the default-deny audit this consumes.
- Security posture manifest — what each dimension proves, and what it merely declares.
- Verify What You're Running — the signing and attestation pipeline this reuses.
- Authorization —
#[authorize]and record-level policy.