Restrict kubeplus-saas-provider ServiceAccount permissions — remove cluster-admin-equivalent grants
Problem
The kubeplus-saas-provider ServiceAccount (the identity behind the provider kubeconfig generated by provider-kubeconfig.py create) currently receives a ClusterRole that, while not literally cluster-admin (apiGroups:["*"], resources:["*"], verbs:["*"]), is functionally equivalent to it. Several rules in _build_provider_rules() combine into privilege-escalation paths:
certificatesigningrequests create/approve + signers.certificates.k8s.io approve on kube-apiserver-client/kubelet-serving — lets the holder mint and approve arbitrary client certs, including identities in system:masters if the CA permits it.
impersonate on users/groups/serviceaccounts — bypasses RBAC checks entirely by acting as any identity.
clusterroles/clusterrolebindings/roles/rolebindings full CRUD — can grant itself (or anything) any permission set.
serviceaccounts/token — token-minting path for any ServiceAccount in the cluster.
Any one of these is generally treated as cluster-admin-equivalent for security review purposes. Having several together, on a ServiceAccount tied to an externally-distributed kubeconfig (per the provider-kubeconfig-first control-center registration flow), is a meaningful blast-radius concern if that kubeconfig is ever leaked or over-shared.
This SA's design intentionally deploys arbitrary Helm charts (that's the whole point of KubePlus), so broad rights over workload/networking/storage kinds (deployments, statefulsets, daemonsets, jobs, cronjobs, horizontalpodautoscalers, poddisruptionbudgets, ingresses, networkpolicies, services, configmaps, secrets, pvcs, resourcequotas, and the read-only get/watch/list wildcard) are legitimate and out of scope for this issue. The goal here is narrowly to remove the grants that provide cluster-admin-equivalent escalation paths without any corresponding use in the current code.
Findings — cross-referenced against actual usage
| Rule (in _build_provider_rules()) |
Used by |
Verdict |
| ruleGroup13: signers.certificates.k8s.io approve (kube-apiserver-client, kubelet-serving, legacy-unknown, cloudark.io/kubeplus) |
Only deploy/webhook-create-signed-cert.sh / -new.sh, which are not the init container wired into the Helm chart (that's webhook-create-self-signed-ca-cert.sh, which signs entirely with local openssl and never touches the K8s CSR API) |
Unused — remove |
| ruleGroup15: certificatesigningrequests / certificatesigningrequests/approval create/delete/update/patch |
Same as above |
Unused — remove |
| ruleGroup9: impersonate on users/groups/serviceaccounts |
Needed for consumer kubeconfigs (_build_consumer_rules, same ruleGroup9); appears copy-pasted into the provider's rule set. Provider's actual job is creating the RoleBinding that grants this to the consumer SA (already covered by ruleGroup3), not holding impersonate itself |
Unused on provider — remove |
| serviceaccounts/token (part of ruleGroup6's resource list) |
_create_secret() uses the legacy kubernetes.io/service-account-token Secret-annotation pattern, not the TokenRequest subresource |
Unused — remove from resource list |
| ruleGroup14: apiGroups:[""], resources:[""], verbs ["get"] |
Strict subset of ruleGroup1 (get/watch/list on same scope) |
Dead duplicate — remove (no permission change) |
| ruleGroup11: mutatingwebhookconfigurations get/create/delete/update |
webhook-create-self-signed-ca-cert.sh, but only ever touches the single object platform-as-code.crd-binding |
Used — tighten with resourceNames where the verb allows it |
Step 1 — Validate via revoke before touching code
provider-kubeconfig.py already supports a revoke action that can trim a live ClusterRole without a code change, so we can test for breakage first.
-
Build a permission file describing exactly the rules to revoke (matches the -p/--permissionfile schema: {"perms":{"<apigroup>":[{"<resourcetype>":["<verb>",...]}]}}):
# provider-trim-test.yaml
perms:
certificates.k8s.io:
- signers:
- get
- create
- delete
- update
- patch
- approve
- certificatesigningrequests:
- create
- delete
- update
- patch
- certificatesigningrequests/approval:
- create
- delete
- update
- patch
'':
- users:
- impersonate
- groups:
- impersonate
- serviceaccounts:
- impersonate
- serviceaccounts/token:
- get
- watch
- list
- create
- delete
- update
- patch
- deletecollection
-
Run the revoke against a test cluster's existing provider ClusterRole:
python3 provider-kubeconfig.py \
-k <path-to-kubeconfig> \
-p provider-trim-test.yaml \
revoke default
(default here is the namespace positional arg — use whatever namespace kubeplus-saas-provider was created in.)
-
Exercise the normal instance lifecycle against this trimmed role: create a tenant/SaaS instance, upgrade it, scale it, delete it, and re-run the webhook init flow (redeploy the KubePlus pod) to confirm webhook-create-self-signed-ca-cert.sh still succeeds without CSR permissions.
-
Watch platform-operator and kubeconfiggenerator container logs for any Forbidden/is forbidden errors during this cycle. A clean run across create/upgrade/scale/delete/webhook-reinit is the signal to proceed to Step 2.
-
If anything breaks, capture the exact Forbidden error (it names the missing verb/resource) and add it back explicitly — that tells us if any of the "unused" findings above were wrong or if there's an untested code path (e.g., a different webhook cert script variant someone still relies on).
Step 2 — Code changes to _build_provider_rules() in provider-kubeconfig.py
Once Step 1 confirms nothing breaks, make the trim permanent:
-
Delete ruleGroup13 and its append, including the dangling resourceNames13 reference:
# Certificates
ruleGroup13 = {}
apiGroup13 = ["certificates.k8s.io"]
resourceGroup13 = ["signers"]
verbsGroup13 = ["get","create","delete","update","patch","approve"]
ruleGroup13["apiGroups"] = apiGroup13
ruleGroup13["resources"] = resourceGroup13
ruleGroup13["resourceNames"] = resourceNames13
ruleGroup13["verbs"] = verbsGroup13
and remove ruleList.append(ruleGroup13).
-
Delete ruleGroup15 (CSR create/delete/update/patch) and its ruleList.append(ruleGroup15).
-
Delete ruleGroup9 (impersonate) and its ruleList.append(ruleGroup9). (Leave _build_consumer_rules()'s own ruleGroup9 untouched — that one is legitimately needed.)
-
Trim serviceaccounts/token out of ruleGroup6:
# before
resourceGroup6 = ["secrets","serviceaccounts","configmaps","events","persistentvolumeclaims","serviceaccounts/token","services","services/proxy","endpoints"]
# after
resourceGroup6 = ["secrets","serviceaccounts","configmaps","events","persistentvolumeclaims","services","services/proxy","endpoints"]
-
Delete ruleGroup14 (dead duplicate of ruleGroup1) and its ruleList.append(ruleGroup14).
-
Tighten ruleGroup11 (mutatingwebhookconfigurations) to the single object name actually used. Since create can't be resourceName-scoped (object doesn't exist yet), split into two rules:
# AdmissionRegistration — create (cluster-scoped, name not yet known)
ruleGroup11a = {}
ruleGroup11a["apiGroups"] = ["admissionregistration.k8s.io"]
ruleGroup11a["resources"] = ["mutatingwebhookconfigurations"]
ruleGroup11a["verbs"] = ["create"]
AdmissionRegistration — get/update/delete, scoped to the one object this SA manages
ruleGroup11b = {}
ruleGroup11b["apiGroups"] = ["admissionregistration.k8s.io"]
ruleGroup11b["resources"] = ["mutatingwebhookconfigurations"]
ruleGroup11b["resourceNames"] = ["platform-as-code.crd-binding"]
ruleGroup11b["verbs"] = ["get","update","delete"]
Replace the single ruleList.append(ruleGroup11) with both ruleGroup11a and ruleGroup11b.
-
Renumber remaining ruleGroup16–ruleGroup23 if the project's style wants contiguous numbering (functionally optional — Python doesn't care, it's just readability).
-
Update/add a unit test alongside the existing ones in tests/permission_files/ and tests/test_provider_kubeconfig.py asserting the generated provider ClusterRole no longer contains: certificatesigningrequests*, signers, impersonate, or serviceaccounts/token.
Acceptance criteria
Restrict
kubeplus-saas-providerServiceAccount permissions — remove cluster-admin-equivalent grantsProblem
The
kubeplus-saas-providerServiceAccount (the identity behind the provider kubeconfig generated byprovider-kubeconfig.py create) currently receives a ClusterRole that, while not literallycluster-admin(apiGroups:["*"], resources:["*"], verbs:["*"]), is functionally equivalent to it. Several rules in_build_provider_rules()combine into privilege-escalation paths:certificatesigningrequestscreate/approve +signers.certificates.k8s.ioapprove onkube-apiserver-client/kubelet-serving— lets the holder mint and approve arbitrary client certs, including identities insystem:mastersif the CA permits it.impersonateonusers/groups/serviceaccounts— bypasses RBAC checks entirely by acting as any identity.clusterroles/clusterrolebindings/roles/rolebindingsfull CRUD — can grant itself (or anything) any permission set.serviceaccounts/token— token-minting path for any ServiceAccount in the cluster.Any one of these is generally treated as cluster-admin-equivalent for security review purposes. Having several together, on a ServiceAccount tied to an externally-distributed kubeconfig (per the provider-kubeconfig-first control-center registration flow), is a meaningful blast-radius concern if that kubeconfig is ever leaked or over-shared.
This SA's design intentionally deploys arbitrary Helm charts (that's the whole point of KubePlus), so broad rights over workload/networking/storage kinds (
deployments,statefulsets,daemonsets,jobs,cronjobs,horizontalpodautoscalers,poddisruptionbudgets,ingresses,networkpolicies,services,configmaps,secrets,pvcs,resourcequotas, and the read-onlyget/watch/listwildcard) are legitimate and out of scope for this issue. The goal here is narrowly to remove the grants that provide cluster-admin-equivalent escalation paths without any corresponding use in the current code.Findings — cross-referenced against actual usage
Step 1 — Validate via
revokebefore touching codeprovider-kubeconfig.pyalready supports arevokeaction that can trim a live ClusterRole without a code change, so we can test for breakage first.Build a permission file describing exactly the rules to revoke (matches the
-p/--permissionfileschema:{"perms":{"<apigroup>":[{"<resourcetype>":["<verb>",...]}]}}):Run the revoke against a test cluster's existing provider ClusterRole:
(
defaulthere is thenamespacepositional arg — use whatever namespacekubeplus-saas-providerwas created in.)Exercise the normal instance lifecycle against this trimmed role: create a tenant/SaaS instance, upgrade it, scale it, delete it, and re-run the webhook init flow (redeploy the KubePlus pod) to confirm
webhook-create-self-signed-ca-cert.shstill succeeds without CSR permissions.Watch
platform-operatorandkubeconfiggeneratorcontainer logs for anyForbidden/is forbiddenerrors during this cycle. A clean run across create/upgrade/scale/delete/webhook-reinit is the signal to proceed to Step 2.If anything breaks, capture the exact
Forbiddenerror (it names the missing verb/resource) and add it back explicitly — that tells us if any of the "unused" findings above were wrong or if there's an untested code path (e.g., a different webhook cert script variant someone still relies on).Step 2 — Code changes to
_build_provider_rules()inprovider-kubeconfig.pyOnce Step 1 confirms nothing breaks, make the trim permanent:
Delete
ruleGroup13and its append, including the danglingresourceNames13reference:and remove
ruleList.append(ruleGroup13).Delete
ruleGroup15(CSR create/delete/update/patch) and itsruleList.append(ruleGroup15).Delete
ruleGroup9(impersonate) and itsruleList.append(ruleGroup9). (Leave_build_consumer_rules()'s ownruleGroup9untouched — that one is legitimately needed.)Trim
serviceaccounts/tokenout ofruleGroup6:Delete
ruleGroup14(dead duplicate ofruleGroup1) and itsruleList.append(ruleGroup14).Tighten
ruleGroup11(mutatingwebhookconfigurations) to the single object name actually used. Sincecreatecan't be resourceName-scoped (object doesn't exist yet), split into two rules:Replace the single
ruleList.append(ruleGroup11)with bothruleGroup11aandruleGroup11b.Renumber remaining
ruleGroup16–ruleGroup23if the project's style wants contiguous numbering (functionally optional — Python doesn't care, it's just readability).Update/add a unit test alongside the existing ones in
tests/permission_files/andtests/test_provider_kubeconfig.pyasserting the generated provider ClusterRole no longer contains:certificatesigningrequests*,signers,impersonate, orserviceaccounts/token.Acceptance criteria
revokeagainst a live test cluster through a full create/upgrade/scale/delete/webhook-reinit cycle with noForbiddenerrors._build_provider_rules()updated per Step 2.impersonate, orserviceaccounts/tokengrants remain onkubeplus-saas-provider.mutatingwebhookconfigurationsaccess scoped toplatform-as-code.crd-bindingwhere the verb allowsresourceNames.update.