Skip to content

fix: authorize the runner ServiceAccount override at admission - #69

Open
amaydixit11 wants to merge 2 commits into
nirmata:mainfrom
amaydixit11:fix/authorize-runner-serviceaccount
Open

fix: authorize the runner ServiceAccount override at admission#69
amaydixit11 wants to merge 2 commits into
nirmata:mainfrom
amaydixit11:fix/authorize-runner-serviceaccount

Conversation

@amaydixit11

@amaydixit11 amaydixit11 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Closes #26.

A WorkflowRun can set spec.execution.job.serviceAccountName to any account in its namespace, buildWorkflowRunnerJob honours it, and ensureRunnerAccess skips runs that name one. The Job then runs with that account's token — so anyone who can create a WorkflowRun can pick a privileged ServiceAccount and get its credentials, which is the escalation scoping the runner to its own role set out to close.

The account is chosen in two places, and each is now authorized where the choice is made:

  • Workflow.spec.execution.job.serviceAccountName — against the Workflow author, at Workflow admission.
  • WorkflowRun.spec.execution.job.serviceAccountName — against whoever submitted the run.

Either way the check is a SubjectAccessReview for use on that ServiceAccount in that namespace, and the object is rejected if the answer is no:

WorkflowRun "nightly": "system:serviceaccount:team-a:submitter" may not use
serviceaccount "privileged-sa" in namespace "team-a"

Operators grant it by naming the accounts a subject may borrow:

rules:
  - apiGroups: [""]
    resources: ["serviceaccounts"]
    resourceNames: ["build-runner"]
    verbs: ["use"]

Smaller than the issue expected

The issue says the validator would have to become a raw admission handler, since it does not receive UserInfo. It does not — controller-runtime puts the AdmissionRequest into the context before calling the validator (admission/validator_custom.go:104), so admission.RequestFromContext reaches the user without changing the handler shape. The change is one new file plus a field on the validator.

A run whose account matches the one its Workflow declares is inheriting rather than choosing, so it is not reviewed again — this is what keeps cron runs working, since the scheduler copies spec.execution into every run it creates and creates it as the controller. Same namespace only: the account is used in the run's namespace, and a Workflow elsewhere was authorized against its own. A run naming an account its Workflow does not declare is still reviewed.

Two other things worth noting:

  • Checked on update as well as create. The field is mutable, so authorizing only creates would let a run be submitted clean and then patched.
  • Everything that stops the check reaching a verdict rejects: a review that errors, a request carrying no user, a validator with no authorizer wired. A check that admits when it cannot decide is not a check.

subjectaccessreviews create moves out of the MCP fragment — which only renders when the MCP server is enabled — into the core controller role, since the webhook needs it whenever it runs.

Breaking

Anyone already setting serviceAccountName has their runs rejected until someone grants use on that account. That is the point of the fix, but it is a real migration step and worth a release note. Say if you would rather have a flag for a transition period; I left one out deliberately, since a switch that turns off an authorization check tends to stay on.

Verified

Against a real API server, that the grant behaves as designed:

granted SA:      yes
un-granted SA:   no
unrelated user:  no

Unit tests cover the review contents (user, UID, groups, extra, and the resource attributes), the allow and deny paths on both objects, update, no-review-when-unset, the three fail-closed cases, and inheritance: admitted with no review issued, still reviewed when the account differs, and not honoured across namespaces.

make build, make verify-codegen, go test, helm lint, chart RBAC tests pass.

Closes nirmata#26.

Scoping the runner Job to a least-privilege ClusterRole left one way around it.
A WorkflowRun can set spec.execution.job.serviceAccountName to any account in
its namespace, buildWorkflowRunnerJob honours it, and ensureRunnerAccess skips
runs that name one. The Job is then launched with that account's token, so
anyone who can create a WorkflowRun can pick a privileged ServiceAccount and
get its credentials.

The WorkflowRun webhook now issues a SubjectAccessReview for the submitter,
asking for use on the named ServiceAccount in the run's namespace, and rejects
the run when the answer is no. Operators grant it by naming the accounts a
subject may borrow:

  rules:
  - apiGroups: [""]
    resources: ["serviceaccounts"]
    resourceNames: ["build-runner"]
    verbs: ["use"]

The issue expected this to need a raw admission handler, since the validator
does not receive UserInfo. It does not: controller-runtime puts the
AdmissionRequest in the context before calling the validator
(admission/validator_custom.go), so admission.RequestFromContext reaches the
user without changing the handler shape.

Checked on update as well as create, because the field is mutable and
authorizing only creates would let a run be submitted clean and then patched.
Everything that stops the check reaching a verdict rejects: a review that
errors, a request with no user, a validator with no authorizer.

subjectaccessreviews create moves from the MCP fragment, which is enabled only
with the MCP server, into the core controller role, since the webhook needs it
whenever it runs.

This is a breaking change for anyone already setting serviceAccountName: those
runs are rejected until someone grants use on that account.

Verified against a real API server that the grant works as intended: with a
Role granting use on serviceaccounts/build-runner, the bound subject gets yes
for that account, no for another, and an unbound subject gets no.

Signed-off-by: Amay Dixit <amaydixit11@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 197c09135c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/webhook/runner_serviceaccount.go
…e it is used

Review caught that this rejected every cron run. The scheduler copies
spec.execution from the Workflow into each run it creates and creates it as the
controller, so reviewing the submitter asked whether the controller may use the
account. The chart grants no use verb, so a default install would reject them
all.

Authorizing the submitter of a run is the wrong place on its own. The account
is chosen in two places, and each is now authorized where the choice is made:

- Workflow.spec.execution.job.serviceAccountName, checked when the Workflow is
  admitted, against its author.
- WorkflowRun.spec.execution.job.serviceAccountName, checked when the run is
  admitted, against whoever submitted it.

A run whose account matches the one its Workflow declares is inheriting rather
than choosing, so it is not reviewed again: the declaration was authorized at
Workflow admission and every run of that Workflow uses that account anyway.
Same namespace only, since the account is used in the run's namespace and a
Workflow elsewhere was authorized against its own.

A run naming an account its Workflow does not declare is still reviewed.

Signed-off-by: Amay Dixit <amaydixit11@gmail.com>
Sign up for free to 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.

security: per-run serviceAccountName override bypasses runner least-privilege (follow-up to earlier RBAC work)

1 participant