Skip to content

fix(ci): declare permissions on every caller template and own workflow - #73

Merged
andrew2net merged 3 commits into
mainfrom
fix/workflow-permissions
Aug 20, 2026
Merged

fix(ci): declare permissions on every caller template and own workflow#73
andrew2net merged 3 commits into
mainfrom
fix/workflow-permissions

Conversation

@andrew2net

Copy link
Copy Markdown
Contributor

Closes the fleet-wide CodeQL actions/missing-workflow-permissions alert (medium). Found via the security-bot review on relaton-data-jcgm#1.

Why it has to be fixed here

The alert is open on deploy.yml, crawler.yml and check_data.yml in at least seven relaton-data-* repos (jcgm, iana, iso, ieee, iec, ids, iho), plus this repo's own ci-lint, ci-spec and ci-repo-watcher.

It cannot be fixed where it is flagged:

  • Not in the data repos — every flagged file is Cimas-generated, so a hand-edit is reverted by the next cimas sync.
  • Not in the reusable workflows — a called workflow's permissions: can only downgrade what the caller granted. CodeQL knows this and deliberately skips workflow_call-only workflows, which is why relaton/support's own crawler.yml and check-data.yml are not flagged despite declaring nothing.

So the grant goes on the caller templates, which live here.

The trap: each grant is a union, not a minimum

A caller must grant the union of every scope every job of the called workflow requests — not the narrowest set describing what the caller itself does.

This matters most for deploy.yml. data-deploy.yml declares contents: read at workflow level and adds pages: write + id-token: write on its deploy job. A reflexive "minimal permissions" edit to contents: read does not fail at parse time — it fails minutes into the build, at the deploy step, in all 29 data repos at once:

The workflow is requesting 'pages: write', but is only allowed 'pages: none'.

Every grant below was verified by reading the called workflow, including the upstream metanorma/ci ones.

CallerGrantBecause the called workflow…
data/deploy.ymlcontents: read, pages: write, id-token: writedata-deploy.yml declares contents: read (workflow) + pages/id-token: write (deploy job)
data/crawler.ymlcontents: writecrawler.yml's "Push data" step runs git commit + git push with checkout's persisted GITHUB_TOKEN
data/check_data.ymlcontents: readcheckout + bundle exec ruby check_data.rb
master/release.ymlid-token/contents/packages: writematches what support/release.yml declares (see caveat)
model/make.ymlcontents: readcheckout + bundle exec make clean all
master/rake.ymlunchangedalready had workflow-level contents: write
ci-lint.ymlcontents: readmetanorma/cici-lint.yml declares contents: read
ci-spec.ymlcontents: readcheckout + rspec
ci-repo-watcher.ymlcontents: read, issues: writemetanorma/cici-repo-watcher.yml declares both and opens/updates an issue via comment-or-create

ci-repo-watcher was the one worth reading rather than guessing: too narrow means a silent daily-cron failure nobody watches.

Also: the keep-alive template had drifted

master/keep-alive.yml was three regressions behind what every data repo carries by hand — and none of them were visible in CI:

  1. Missing permissions: actions: write. A sync would have reopened a CodeQL alert that currently reads fixed in six repos.
  2. Missing workflow_dispatch:, leaving a monthly cron as the only trigger — precisely the workflow GitHub disables for inactivity, with no way back in.
  3. uses: still pointing at metanorma/ci, which no longer has a keep-alive.yml at all. The target was already a 404.

It now diffs functionally clean against the deployed relaton-data-iana copy, so that sync is a no-op.

Known over-grant, deliberately left

packages: write on the release caller. Nothing downstream requests it — metanorma/ci's rubygems-release.yml grants its jobs only contents + id-token, and publishing goes to RubyGems, not GitHub Packages. It is here only because support/release.yml declares it, and removing it from the caller alone would cap that file below its own declaration. Removing it means editing both files in one change, verified against a live release. Documented in place; flagged for follow-up rather than smuggled into this PR.

Tests

spec/workflow_permissions_spec.rb (new) encodes the CodeQL rule itself so the class stays closed rather than these instances being fixed once:

  • every Cimas-synced caller template declares a non-empty grant (list derived from cimas.yml, not globbed);
  • every workflow this repo runs itself does too, skipping exactly the workflow_call-only set CodeQL skips;
  • exact-value pins for each template's grant, so a future narrowing fails here rather than in production;
  • the three keep-alive regressions, pinned individually.

spec/data_deploy_caller_template_spec.rb gains the deploy/crawler union assertions, sitting next to the existing block that asserts what that job must not carry.

333 examples, 0 failures.

Verification still outstanding

The only thing that truly proves the union was right needs credentials:

  1. cimas sync -g data and confirm the diff in one data repo is only the added permissions: blocks.
  2. On the first repo synced, trigger Deploy via workflow_dispatch and confirm the deploy job publishes rather than failing the pages: none check.

Related, not touched

  • feat/data-check-index-caller adds a new check-index.yml template with no permissions: block — the new sweep will fail on it when those branches meet.
  • data/check_data.yml's push branches are [ master, main ], but all three repos it syncs into (bipm, iho, iso) are on v2, so its push trigger never fires. Pre-existing, out of scope.

CodeQL's actions/missing-workflow-permissions was open fleet-wide: deploy.yml,
crawler.yml and check_data.yml across at least seven relaton-data-* repos, plus
this repo's own ci-lint, ci-spec and ci-repo-watcher.
It could not be fixed where it was flagged. Every flagged data-repo file is
Cimas-generated, so a hand-edit there is reverted by the next sync; and it
cannot be fixed in the reusable workflows either, because a called workflow's
permissions can only downgrade what the caller granted. CodeQL knows this and
skips workflow_call-only workflows. The grant belongs on the caller.
Each grant is the UNION of every scope every job of the called workflow
requests, not the narrowest set describing what the caller itself does. That
matters most for deploy.yml: a naive `contents: read` there does not fail at
parse time, it fails minutes into the build at the deploy step, in 29 repos at
once, with "requesting 'pages: write', but is only allowed 'pages: none'".
Also resyncs the keep-alive template, which had silently drifted away from what
every data repo carries by hand. It was missing `permissions: actions: write`
(so a sync would have reopened an alert that currently reads `fixed`), missing
`workflow_dispatch:`, and still pointing at metanorma/ci — which no longer has
a keep-alive.yml at all, so the target was already a 404.
`packages: write` on the release caller is a known inherited over-grant:
nothing downstream requests it, but it cannot be dropped from the caller alone
without capping support's own release.yml below what that file declares.
Documented in place for a coordinated follow-up.
spec/workflow_permissions_spec.rb encodes the CodeQL rule itself, so the whole
class stays closed rather than these instances being fixed once.
check-index.yml landed in main (support#68) after this branch was cut, with no
`permissions:` block — so the new sweep in spec/workflow_permissions_spec.rb
caught it on the PR merge commit. That is the guard working, not a defect: a
caller template can no longer be added to cimas.yml without a grant.
Deliberately `contents: read`, narrower than deploy.yml's block, though both
call the same reusable workflow. deploy.yml needs the union of every scope
data-deploy.yml's jobs request, including `pages: write` + `id-token: write` for
its `deploy` job. Here that job cannot run: it is gated on `github.ref` being the
repo's default branch, and a pull_request run's ref is `refs/pull/N/merge`. A
skipped job is never dispatched, so its request is never evaluated — the union
failure surfaces at the deploy step of a real build, not at graph-parse time.
Granting the two publishing scopes on `pull_request`, the most reachable trigger
in the repo, would buy nothing and widen the token in all 30 data repos. The
template records what to change if that reading turns out to be wrong.
Also realigns two "29 repos" comments with main's renumbering to 30.
@andrew2net
andrew2net merged commit 9921a36 into mainAug 20, 2026
6 checks passed
@andrew2net
andrew2net deleted the fix/workflow-permissions branch August 20, 2026 16:53
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.

1 participant

@andrew2net