Skip to content

sec(2157): scope the two board crons to what they actually do - #294

Merged
LukasWodka merged 1 commit into
developfrom
sec/2157-scope-the-board-crons
Aug 21, 2026
Merged

sec(2157): scope the two board crons to what they actually do#294
LukasWodka merged 1 commit into
developfrom
sec/2157-scope-the-board-crons

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

First tier of backend#2157, taken on the two workflows whose requirement is
derivable from what they run and whose result is verifiable by dispatch — not
on the ones whose scopes are still unmeasured.

What they actually needed

workflowmeasured surfacescope now
kanban-columns.ymlruns kanban-columns-check.py, which reads the project's field vocabulary and writes nothingorganization-projects: **read**
kanban-archive.ymlone mutation, archiveProjectV2Itemorganization-projects: **write**

Both previously minted the App's full install — contents + PR write across all 19
repos — for a read of one field list and one archive call.

repositories: narrows the repo-level half. It does not touch the board:
organization_projects is an org permission, measured unaffected by repo scoping in
backend#2181's run 32255581084. That was the load-bearing unknown and it is already
settled, so this PR does not re-litigate it.

Verified by dispatch on this branch, not inferred

Both dispatched from sec/2157-scope-the-board-cronsbefore opening this PR:

  • run 32404102201kanban-columns, success. The job that matters is Written Status names exist on the board, which is the live board read: it passed with organization-projects: read alone.

  • run 32404104720kanban-archive with dry-run=true, success, and the log is the evidence:

    DRY RUN - would archive 65 terminal item(s).
    

    The narrowed token resolved the project id and enumerated all 65 items. Dry-run
    exercises mint + resolve + enumerate; the archive mutation itself is the same
    organization_projects grant, and the first cron run is its proof.

The guard moves with the change

Both rows come out of mint-scope.py's EXEMPT in the same commit, so it now reports
10 unscoped / 10 exempted instead of 12/12. Leaving an exemption for a workflow
that no longer needs one is a licence nobody is using — which that guard reports as
its own finding (stale_exemptions).

Deliberately not in this PR

The six caller-triggered board writers (advance-deploy-env, set-pr-status,
kanban-closure-router, fr-pass-comment, customer-priority-bump,
kanban-reconcile) and the three protection readers (bricked-prs,
merge-settings-drift, standards-sync) plus fr-gate.

The protection readers are the reason for splitting: a narrower token on a branch-protection
read returns less data rather than erroring
, so a wrong guess there does not fail —
it quietly reports a cleaner fleet than exists. That needs its own measurement window,
not a plausible-looking guess shipped alongside two easy ones.

customer-priority-bump is interesting and nearly free — its whole surface is one
gh issue edit --add-label, i.e. issues: write and no board access at all — but
it is workflow_call-triggered, so verifying it means labelling a real issue. Next
tier, with a verification plan.

Test plan

  • actionlint on both workflows
  • python3 scripts/mint-scope.py → 0 findings, no stale exemptions
  • python3 scripts/tests/mint-scope-selftest.py → 13/13
  • make check → 41/41
  • both workflows dispatched on this branch and green, with the board actually reached

🤖 Generated with Claude Code


Note

Medium Risk
Touches GitHub App token minting for live org ProjectV2 jobs. Wrong scopes would break board archive/conformance, but this is a privilege reduction, not a grant expansion.

Overview
Scopes the GitHub App installation tokens used by kanban-columns.yml and kanban-archive.yml instead of minting the App’s full install grant.

kanban-columns now requests organization-projects: read (read-only field check). kanban-archive requests organization-projects: write (the archiveProjectV2Item mutation). Both also set repositories: to this repo so leftover repo-level grants are not fleet-wide.

Removes those two workflows from mint-scope.pyEXEMPT so the guard treats them as scoped and will flag a stale exemption if they regress. Other board writers and protection readers stay exempt.

Reviewed by Cursor Bugbot for commit 2e97a08. Bugbot is set up for automated code reviews on this repo. Configure here.

First tier of backend#2157, taken on the two workflows whose requirement is
DERIVABLE from what they run and whose result is VERIFIABLE by dispatch --
not on the ones whose scopes are still unmeasured.
kanban-columns runs `kanban-columns-check.py`, which READS the project field
vocabulary and writes nothing: `organization-projects: read` is the whole
requirement. kanban-archive mutation surface is exactly
`archiveProjectV2Item`: `organization-projects: write`, no issues, no
pull-requests, no contents.
Both previously minted the App FULL install -- contents+PR write across all
19 repos -- for a read of one project field list and one archive call.
`repositories:` narrows the repo-level half. It does not touch the board:
`organization_projects` is an ORG permission, measured unaffected by repo
scoping in backend#2181 run 32255581084. That was the load-bearing unknown
and it is already settled, so this does not re-litigate it.
Both exemption rows come out of mint-scope.py in the same commit, so the
guard now reports 10 unscoped rather than 12 and has no exemption for a
workflow that no longer needs one -- a stale exemption is a licence nobody
is using, which the guard itself reports.
Deliberately NOT in this PR: the six caller-triggered board writers and the
three protection readers. A narrower token on a protection read returns LESS
DATA rather than erroring, so those need their own measurement window, not a
plausible-looking guess.
backend#2157
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 20, 2026

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All fourteen checks green including Bugbot, zero open threads, MERGEABLE.

I verified the derivation rather than the description.kanban-columns-check.py is 175 lines making exactly one GraphQL call — query{node(id:"…"){… on ProjectV2{field(name:"Status")… — with no mutation, no updateProjectV2*, and no POST/PATCH/PUT/DELETE anywhere. So organization-projects: read is both sufficient and exact, and "every other permission the App holds was surplus" is literally true: it was minting contents + PR write across 19 repos to read one field's option list.

Removing both entries from mint-scope.py in the same PR is the part I'd have asked about if it weren't there — an exemption that outlives its reason is a standing licence, and dropping them means the checker now enforces on these two rather than waving them through.

Verifying by dispatch before opening, and citing the run plus the specific job that constitutes the evidence (Written Status names exist on the board — the live board read passing on read alone), is the right standard. So is declining to re-litigate the repositories:-vs-org-permission question on the strength of backend#2181's measurement instead of re-deriving it.

One gap in that evidence, and it's small because of how the inputs compose. Both workflows' real trigger is schedule0 5 * * * and 15 6 * * * — and both dispatch runs were workflow_dispatch, which is a different event payload. repositories: ${{ github.event.repository.name }} is new here; kanban-reconcile.yml is the repo's other cron and doesn't use the idiom, so there's no in-repo precedent for it resolving on a scheduled run.

I don't think this is a defect, and here's why the downside is bounded: repositories: and permission-organization-projects: are independent inputs to the minting step. If that expression ever came back empty, the token would cover all installation repos while holding onlyorganization-projects — which has no repo-level effect at all. So the change that matters (dropping contents and PR write) lands unconditionally, and only the belt-and-braces repo narrowing would be lost. Worth a glance at the first scheduled run's minted scope to close it out, given this PR's own standard is measured-not-inferred and the trigger it will actually run on is the one not yet exercised.

The tiering is the right call too — taking only the two workflows whose requirement is derivable from what they run and verifiable by dispatch, and explicitly leaving the ones whose scopes are still unmeasured. That's what stops a least-privilege sweep from becoming a guessing exercise across nineteen repos.

@LukasWodka
LukasWodka merged commit 98fe48e into developAug 21, 2026
17 checks passed
@LukasWodka
LukasWodka deleted the sec/2157-scope-the-board-crons branch August 21, 2026 07:03
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

/fr-pass

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal