Skip to content

Preserve serviceaccounts/token granularity in CheckAccess DataAction mapping - #27

Merged
Artem Kolomeetc (arxhive) merged 1 commit into
masterfrom
fix/msrc-136086-serviceaccounts-token
Aug 24, 2026
Merged

Preserve serviceaccounts/token granularity in CheckAccess DataAction mapping#27
Artem Kolomeetc (arxhive) merged 1 commit into
masterfrom
fix/msrc-136086-serviceaccounts-token

Conversation

@arxhive

@arxhive Artem Kolomeetc (arxhive) commented Aug 20, 2026

Copy link
Copy Markdown

What

serviceaccounts/token (the Kubernetes TokenRequest API) now maps to its own
CheckAccess DataAction, <clusterType>/serviceaccounts/token/action, instead of
collapsing onto <clusterType>/serviceaccounts/write.

Why

Upstream Kubernetes treats serviceaccounts/token as a resource distinct from
serviceaccounts. In bootstrappolicy/policy.go,
the system:aggregate-to-edit ClusterRole grants create on
serviceaccounts/token in a rule of its own, separate from the write rule that
covers the serviceaccounts object:

rbacv1helpers.NewRule(Write...).Groups(legacyGroup).Resources(..., "serviceaccounts", ...).RuleOrDie(),
rbacv1helpers.NewRule("create").Groups(legacyGroup).Resources("serviceaccounts/token").RuleOrDie(),

Issuing a bearer token for a ServiceAccount is a credential-minting operation,
not an update of the ServiceAccount object, so native Kubernetes RBAC requires
serviceaccounts/token to be listed explicitly. getResourceAndAction
collapsed the subresource, so token issuance was implied by any grant of
ServiceAccount write access.

This is the same treatment already applied to pods/exec, pods/attach,
pods/portforward, pods/proxy, services/proxy and nodes/proxy, and it
mirrors the existing serviceaccounts/impersonate/action DataAction, which
already gates the sibling sub-capability separately.

Behaviour change

Request Before After
create serviceaccounts/token serviceaccounts/write serviceaccounts/token/action
create/update serviceaccounts serviceaccounts/write unchanged
impersonate serviceaccounts serviceaccounts/impersonate/action unchanged

Impact on role definitions, verified with az role definition list:

  • Roles that cover ServiceAccounts with a wildcard still match the new action.
    Azure RBAC wildcards span /, so Microsoft.ContainerService/managedClusters/serviceaccounts/*
    (RBAC Writer) and Microsoft.ContainerService/managedClusters/* (RBAC Admin,
    RBAC Cluster Admin) continue to authorize token issuance. I confirmed this
    against the live Azure RBAC engine rather than inferring it.
  • Roles that enumerate serviceaccounts/write as a leaf action no longer imply
    token issuance. This applies to custom roles, and is the intended narrowing.
  • RBAC Reader is unaffected: it holds only serviceaccounts/read, so
    create serviceaccounts/token was already denied.

Because the new action is not yet registered in the Microsoft.ContainerService
provider manifest, a follow-up on the RP/CCP side is needed to register
serviceaccounts/token/action and to decide, per built-in role, whether token
issuance stays in the wildcard or is carved out. Guard cannot express that
distinction on its own — this PR is the prerequisite that makes the carve-out
possible, since a notDataAction on serviceaccounts/write would break all
ServiceAccount writes.

Rollout sequencing: custom roles that enumerate leaf DataActions lose token
issuance as soon as this ships, until the new action is registered RP-side.
Register the action before or together with the Guard rollout, and note the
behaviour change in the release note at the AKS Guard version bump.

Testing

Static gates:

  • go build ./..., go vet ./..., gofmt -l clean
  • golangci-lint run via the canonical ghcr.io/appscode/golang-dev:1.25 image: 0 issues
  • Full CI-scope unit suite passes, in the CI image and on host
  • Four new Test_getDataActions cases: token-create on AKS and Fleet produce the
    distinct action; ServiceAccount object writes and the impersonate verb are
    asserted unchanged (53/53 Test_getDataActions subtests pass)

End-to-end, in addition to the unit tests: this build and master were run side
by side in Docker against a mock CheckAccess endpoint that evaluates real Azure
role definitions (including *-spans-/ matching) and records every DataAction
on the wire. Real SubjectAccessReviews were POSTed to /subjectaccessreviews
over mTLS.

  • Differential: across 35 request shapes covering the DataAction surface,
    exactly 2 rows differ from master, both serviceaccounts + token. Every
    other emitted DataAction is byte-identical, so the blast radius is limited to
    the intended case.
  • Result cache isolation: verified in both orders (ServiceAccount write then
    token create, and the reverse) that the two now occupy distinct cache entries
    and each reaches the PDP. On master the second request in that sequence was
    answered from the first one's cache entry and never reached the PDP at all.
    This matters because serviceaccounts/token is not in
    subresourceAttributeAllowlist, so cache separation comes entirely from the
    action string. Also verified on the CheckAccess v2 path and at the unit level
    under both values of allowSubresourceTypeCheck.
  • Forward compatibility: confirmed that once the action is registered, a
    notDataActions carve-out on serviceaccounts/token/action denies token
    create while leaving ServiceAccount writes allowed.

test/e2e was not run: it requires a live AKS staging cluster whose DNS no
longer resolves. It fails identically on master and is excluded from CI by
SRC_PKGS.

One pre-existing issue was noticed and is not addressed here (it reproduces
identically on master and is unrelated to this change): at -v>=7,
writeAuthzResponse in server/utils.go dereferences spec.Extra["oid"] when
spec is nil, panicking on the missing-client-cert and malformed-body paths.
The panic is recovered by chi and the client still receives HTTP 400, but with
an empty body. Worth a separate PR.

…mapping

Upstream Kubernetes treats serviceaccounts/token, the TokenRequest API, as a
resource distinct from serviceaccounts. The aggregate-to-edit ClusterRole in
plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go grants "create" on
serviceaccounts/token in its own rule, separate from the write rule that covers
the serviceaccounts object, because issuing a bearer token for a ServiceAccount
is a credential-minting operation rather than an update of the object.

getResourceAndAction collapsed the subresource, so a create on
serviceaccounts/token resolved to the serviceaccounts/write DataAction and token
issuance was covered by any grant of ServiceAccount write access. Add the pair
to securitySensitiveSubresources so it maps to serviceaccounts/token/action,
keeping the same granularity already applied to pods exec/attach/portforward/
proxy, services/proxy and nodes/proxy.

Writing the ServiceAccount object itself still maps to serviceaccounts/write,
and the impersonate verb still resolves through the verb mapping, so ordinary
ServiceAccount CRUD and impersonation are unchanged. Roles that cover
serviceaccounts with a wildcard continue to match the new action; roles that
enumerate serviceaccounts/write as a leaf action no longer imply token issuance.

Signed-off-by: Artem Kolomeetc <akolomeetc@microsoft.com>
@arxhive
Artem Kolomeetc (arxhive) merged commit 59928de into master Aug 24, 2026
2 checks passed
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.

2 participants