Skip to content

kubeplus-saas-provider ServiceAccount permissions — remove cluster-admin-equivalent grants #1486

Description

@devdattakulkarni

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.

  1. 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
    
  2. 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.)

  3. 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.

  4. 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.

  5. 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:

  1. 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).

  2. Delete ruleGroup15 (CSR create/delete/update/patch) and its ruleList.append(ruleGroup15).

  3. Delete ruleGroup9 (impersonate) and its ruleList.append(ruleGroup9). (Leave _build_consumer_rules()'s own ruleGroup9 untouched — that one is legitimately needed.)

  4. 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"]
    
  5. Delete ruleGroup14 (dead duplicate of ruleGroup1) and its ruleList.append(ruleGroup14).

  6. 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.

  7. Renumber remaining ruleGroup16ruleGroup23 if the project's style wants contiguous numbering (functionally optional — Python doesn't care, it's just readability).

  8. 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

  • Trimmed permission set validated via revoke against a live test cluster through a full create/upgrade/scale/delete/webhook-reinit cycle with no Forbidden errors.
  • _build_provider_rules() updated per Step 2.
  • No CSR-approval, impersonate, or serviceaccounts/token grants remain on kubeplus-saas-provider.
  • mutatingwebhookconfigurations access scoped to platform-as-code.crd-binding where the verb allows resourceNames.
  • Existing test suite updated to assert the above and passing.
  • Note in docs/README (wherever the provider-kubeconfig control-center registration flow is described) that the provider kubeconfig no longer carries these grants, so anyone relying on the old broader role for a custom flow (e.g., real-CSR webhook certs) knows to add it back explicitly via update.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions