, ,

Identity, RBAC and Multi-Tenancy, TKGI Plans to OpenShift Projects (TKGI to OpenShift Series, Part 10)

TKGI put tenancy at the cluster boundary and OpenShift puts it at the Project. This part maps UAA scopes, LDAP groups and Plans onto OpenShift RBAC, project templates and ClusterResourceQuota, including the group sync step that silently breaks every binding if you skip it.

TKGI to OpenShift Series · Part 10 of 26
Key takeaways: OpenShift authenticates users against LDAP but never creates Group objects on its own, so every RoleBinding you write against a group matches nobody until a separate sync job runs. A TKGI Plan is a cluster template and has no single OpenShift equivalent; it decomposes into a Project, a ResourceQuota, an SCC binding and a labelled MachineSet. Bind roles to groups, never to named users, and almost never cluster wide. Our three cluster estate carried 214 RoleBindings and shipped on OpenShift with 31, because most of them had been copy pasted and never revoked. Removing self-provisioner is the right end state and the wrong day one move during migration waves.

Our LDAP identity provider had been live for about twenty minutes. People were signing in with corporate credentials, the console rendered, and oc whoami returned the right username every time. Then I checked group membership, because all the RoleBindings I had applied that morning pointed at groups.

$ oc get groups No resources found

Nine groups existed in the directory. Every one of them was already named as a subject in a RoleBinding on the new cluster. OpenShift knew about none of them, and nothing in the console or the API said so. Authentication was working perfectly and authorization was silently empty, which is a much worse failure mode than a login error, because a login error tells you something is broken.

Who this is for: You run a TKGI (Tanzu Kubernetes Grid Integrated Edition, formerly Enterprise PKS) estate authenticated through UAA against a corporate LDAP directory, you have inventoried the estate and sorted applications into waves, and you now have to decide who gets what on the OpenShift side before anyone installs anything. This part is design work plus light discovery. No production cutover happens here.

Authorization Models Compared, UAA Scopes Against OpenShift RBAC

TKGI splits authorization across two systems that barely talk to each other. UAA (User Account and Authentication, the OAuth2 identity service that ships inside Tanzu Operations Manager) decides who may create, resize, delete or fetch credentials for a cluster. Kubernetes RBAC (role based access control, the native permission model) inside each provisioned cluster decides what a person may do once they hold a kubeconfig. A user with the pks.clusters.manage scope can stand up a cluster and, by way of the credentials that command hands back, becomes an administrator inside it. A user with pks.clusters.admin can do that to every cluster in the estate. Those two scopes are mapped to LDAP groups with uaac group map, and that mapping lives in UAA, not in any cluster.

OpenShift collapses both layers into one. An OAuth server runs inside the cluster, identity providers are declared in a single cluster scoped OAuth custom resource, and everything downstream is ordinary Kubernetes RBAC plus a handful of OpenShift specific cluster roles. There is no outer control plane that decides who gets a cluster, because in the model established back in Part 2 you are not handing out clusters any more. Admission adds a third gate that TKGI mostly did not enforce, Security Context Constraints, and that gate is covered on its own in Part 7.

Before mapping anything, count what you actually have. This is the same discovery pass as Part 5, narrowed to identity. Note that these commands use kubectl, not oc, because the source side is upstream Kubernetes with no OpenShift client available. From Part 12 onward everything on the target uses oc, which is the same binary plus the OpenShift API groups, project switching and the oc adm subcommands you will need for group sync and policy.

# Versions this part was checked against # TKGI 1.18, tkgi CLI 1.18, UAA against an RFC 2307 LDAP directory # OpenShift Container Platform 4.20, oc client 4.20 $ tkgi clusters Name Plan Name UUID Status Action dev-01 small 6f0d1c9a-4b2e-41f7-9a55-2c1e0b7d3a44 succeeded UPDATE staging-01 medium b3a7e214-88cd-4f0a-9d61-77b5e2c9f118 succeeded UPDATE prod-01 large e91c4d76-1a3f-4c58-b2d0-5f8a6e40cb27 succeeded UPDATE # Which LDAP groups hold cluster lifecycle rights today $ uaac group map –name pks.clusters.manage cn=k8s-developers,ou=groups,dc=example,dc=com # How many RoleBindings each cluster is actually carrying $ for ns in $(kubectl get ns -o name | cut -d/ -f2); do printf ‘%-28s %sn’ "$ns" "$(kubectl get rolebinding -n $ns –no-headers 2>/dev/null | wc -l)" done | sort -k2 -nr | head -5 payments-prod 19 orders-prod 17 platform-tools 14 reporting-prod 11 identity-prod 9

Nineteen RoleBindings on a single namespace is not a permission model. It is sediment. Someone needed access in 2022, a binding was written, the person changed teams, and nobody revoked it because revoking access on a cluster that already works is a task with no deadline. Across the three clusters that number came to 214. When we rebuilt the same access on OpenShift from the actual team rosters rather than from the inherited objects, it came to 31.

RoleBinding sprawl, inherited against rebuilt Counted across one TKGI estate of three clusters, then rebuilt on OpenShift from team rosters 0 20 40 60 80 RoleBindings 78 9 61 9 75 13 dev staging production Inherited on TKGI, total 214 Rebuilt on OpenShift, total 31
Inherited access almost never survives an honest rebuild, and migration is the only window you will get to do one

Below is the mapping table this part exists to produce. Print it, argue about the middle column with your security team, and use it as the checklist when you configure the target. Every row is a construct that exists on TKGI today and has to land somewhere on OpenShift, including the two rows where the honest answer is that it lands nowhere.

On TKGI OpenShift 4 equivalent Migration action Where it goes wrong
UAA user backed by an LDAP bindUser plus Identity object, created on first successful loginDeclare the LDAP identity provider in the OAuth CR with mappingMethod claimNo User object exists before someone logs in, so you cannot pre bind permissions by username
LDAP group mapped to a UAA scope with uaac group mapGroup object populated by oc adm groups syncWrite an LDAPSyncConfig and schedule the sync as a CronJobThe identity provider does not create groups; this is the failure that opened this part
Scope pks.clusters.admincluster-admin ClusterRoleBinding, plus Machine API rightsGrant to one small platform group, audited monthlyCluster lifecycle is now MachineSets and Operators, a different skill set and often a different team
Scope pks.clusters.manageself-provisioner cluster role plus admin on the resulting ProjectDecide deliberately per tenant, then constrain with a project request templateself-provisioner is bound to system:authenticated by default, which is far broader than the scope ever was
Plan definition, small, medium or largeNo single object; a Project plus ResourceQuota plus LimitRange plus a MachineSet labelAuthor one quota template per former Plan tierTeams hunt for a cluster template on OpenShift and waste a sprint; there is not one
Privileged posture allowed by a permissive PodSecurityPolicySCC bound to a named ServiceAccount, restricted-v2 everywhere elseEnumerate per workload during wave planning, not at cutoverBinding anyuid to a group instead of a ServiceAccount hands the whole tenant root
Shared admin kubeconfig from tkgi get-credentialsPer user token issued by the OAuth route on oc loginRetire the shared file, do not recreate it as a ServiceAccount tokenPipelines quietly depend on that shared file more often than anyone admits
Namespace RoleBinding on a TKGI clusterRoleBinding on the equivalent OpenShift ProjectRebuild from the team roster, never restore the objectA Velero restore that includes rbac.authorization.k8s.io reintroduces access you spent a sprint removing

Mapping TKGI Plans onto OpenShift Projects

A TKGI Plan is a template for a whole cluster. It fixes control plane and worker counts, VM sizing, whether privileged containers are permitted, which add-ons get installed, and which availability zones the nodes land in. Tenants pick a Plan, run tkgi create-cluster, and receive an entire Kubernetes control plane of their own. That is the unit of tenancy, and it is why a mid sized estate ends up with a dozen clusters nobody can patch on schedule.

OpenShift has no object that plays this role, and looking for one is the most common way teams lose a fortnight. What a Plan encodes decomposes cleanly into four separate OpenShift concerns. Compute shape becomes a MachineSet, optionally tainted so only one tenant schedules there. Capacity becomes a ResourceQuota and a LimitRange attached to the Project. Security posture becomes an SCC binding. Network isolation becomes NetworkPolicy, which Part 8 covered when it traded the NSX-T distributed firewall for OVN-Kubernetes. Write those four down per former Plan and you have your target tenancy design.

Against the obvious approach: Almost every migration plan I review proposes one OpenShift cluster per TKGI cluster and one MachineSet per Plan tier. Both halves are wrong. Cluster count should follow audit and failure domain boundaries, which is usually two or three for an estate of a dozen TKGI clusters, and Plan tiers are a billing and capacity concept that belongs in quota, not in node pools. Copying the Plan list into MachineSets gives you idle capacity in every tier at once, which is the one cost line a finance team will notice in month two.

Deciding how much isolation each tenant genuinely needs is the hard part, because tenants will always ask for more than they can justify and the request costs them nothing. Push the question toward evidence: is there a regulator, an auditor or a contractual boundary that requires separation, or is this a preference. Three tiers cover almost every case I have met.

flowchart TD A[Tenant needs capacity on OpenShift] –> B{Regulator or separate audit boundary} B — Yes –> C[Dedicated cluster with its own OAuth config] B — No –> D{Needs privileged or host level access} D — Yes –> E[Project on a tainted MachineSet with a scoped SCC] D — No –> F{More than one namespace for the same team} F — Yes –> G[Several Projects under one ClusterResourceQuota] F — No –> H[Single Project with ResourceQuota and LimitRange]
Decision path for placing a former TKGI tenant on the OpenShift target
Tenancy tier What the tenant gets Use it when What it costs you
Shared, soft isolationOne or more Projects, ResourceQuota, LimitRange, default NetworkPolicy, restricted-v2Internal applications with no regulatory separation, which was most of our estateNoisy neighbour risk on the control plane if quota is not enforced properly
Shared cluster, dedicated nodesEverything above plus a tainted MachineSet and a node selector on the ProjectLicence bound workloads, latency sensitive tiers, workloads needing a non default SCCIdle capacity that cannot be reclaimed by anyone else, plus extra worker subscriptions
Dedicated clusterOwn control plane, own OAuth config, managed as a spoke from an RHACM hubA named regulator, a separate legal entity, or an isolated network zoneThree control plane nodes, a separate upgrade calendar, and the operational load Part 2 warned about

LDAP Wiring, OAuth Identity Provider and Group Sync

Wiring authentication is a short job. OpenShift reads identity providers from a single cluster scoped OAuth resource named cluster, and the bind password and certificate authority live as a Secret and a ConfigMap in the openshift-config namespace. Read the bind password from an environment variable so it never reaches your shell history or a Git commit.

$ oc create secret generic ldap-bind-secret –from-literal=bindPassword="$LDAP_BIND_PASSWORD" -n openshift-config secret/ldap-bind-secret created $ oc create configmap ldap-ca –from-file=ca.crt=/etc/pki/corp-root.pem -n openshift-config configmap/ldap-ca created $ cat oauth-ldap.yaml apiVersion: config.openshift.io/v1 kind: OAuth metadata: name: cluster spec: identityProviders: – name: corp-ldap mappingMethod: claim type: LDAP ldap: attributes: id: – dn email: – mail name: – cn preferredUsername: – uid bindDN: cn=svc-openshift,ou=service,dc=example,dc=com bindPassword: name: ldap-bind-secret ca: name: ldap-ca insecure: false url: ldaps://ldap.example.com/ou=people,dc=example,dc=com?uid $ oc apply -f oauth-ldap.yaml oauth.config.openshift.io/cluster configured

Two details in that manifest decide how painful the next year is. Setting preferredUsername to uid rather than mail fixes what usernames look like forever, because mappingMethod: claim creates the User object on first login and that name is then baked into every RoleBinding, audit record and Project annotation. Changing it later means rewriting every binding. Pick the same attribute your UAA configuration already used, so that inherited documentation and runbooks still read correctly. Setting insecure: false with a real certificate authority is not optional in production, and it is the one line people copy from a lab and forget.

Now the part that catches almost everyone. An identity provider authenticates. It does not enumerate groups, and it never creates Group objects. Group membership is a separate synchronisation job that you run yourself with oc adm groups sync against an LDAPSyncConfig file, and until it runs, every group subject in every RoleBinding matches nobody. Red Hat documents this clearly, but it sits in a different chapter from identity provider configuration, and nothing in the login path warns you.

$ cat ldap-sync.yaml kind: LDAPSyncConfig apiVersion: v1 url: ldaps://ldap.example.com:636 insecure: false bindDN: cn=svc-openshift,ou=service,dc=example,dc=com bindPassword: file: /etc/ldap-bind/bindPassword ca: /etc/pki/corp-root.pem rfc2307: groupsQuery: baseDN: ou=groups,dc=example,dc=com scope: sub derefAliases: never pageSize: 0 groupUIDAttribute: dn groupNameAttributes: [ cn ] groupMembershipAttributes: [ member ] usersQuery: baseDN: ou=people,dc=example,dc=com scope: sub derefAliases: never pageSize: 0 userUIDAttribute: dn userNameAttributes: [ uid ] tolerateMemberNotFoundErrors: false tolerateMemberOutOfScopeErrors: false # Sync is dry run unless you pass –confirm. Run it dry first, every time. $ oc adm groups sync –sync-config=ldap-sync.yaml –whitelist=tenant-groups.txt error: group cn=payments-dev,ou=groups,dc=example,dc=com: error determining LDAP group membership: search for user with DN uid=jrivera,ou=contractors,dc=example,dc=com returned no entries # A leaver was still listed as a member. Fix the directory, or tolerate it explicitly. $ oc adm groups sync –sync-config=ldap-sync.yaml –whitelist=tenant-groups.txt –confirm group/platform-sre group/payments-dev group/payments-sre group/orders-dev group/reporting-analysts $ oc get groups NAME USERS platform-sre asharma, dokafor, jlindqvist payments-dev bmoreau, jrivera, kpatel, tnakamura payments-sre dokafor, kpatel orders-dev lferreira, mabadi, swilkes reporting-analysts hcosta, nvarga

That first error is worth pausing on, because it is the normal state of a corporate directory rather than an exception. A contractor left, the account was removed from the people tree, and the group entry still carries a member attribute pointing at a distinguished name that no longer resolves. Setting tolerateMemberNotFoundErrors to true makes the sync succeed and is what most teams do. It also means directory rot is now invisible to you permanently. My preference is to leave it false, let the sync fail loudly the first time, and use that failure to get the directory cleaned, then decide.

Whichever you choose, this sync has to be scheduled, and a CronJob in a dedicated namespace running the oc image every thirty minutes is enough. Give its ServiceAccount permission to manage Group objects and nothing else, mount the sync config and bind password from a Secret, and pair it with oc adm prune groups so that groups deleted in the directory eventually disappear from the cluster. Read the pruning caveat in the field note below before you enable that second job.

Role Bindings per Tenant, and What to Take Away from Developers

OpenShift ships default cluster roles that already match how most teams actually work, so resist writing custom roles until you have proved the built in ones do not fit. Bind admin to the group that owns a Project and they can manage everything inside it, including RoleBindings for their own people. Bind edit to people who deploy but should not hand out access. Bind view to auditors, support and anyone who only reads. Those three cover the overwhelming majority of what those 214 inherited bindings were trying to express.

Bind to groups, always. A RoleBinding whose subject is a named user is a permission that will outlive that person’s time on the team, and it is exactly how a TKGI estate accumulates two hundred bindings nobody can explain. If the group does not exist in the directory, the correct response is to create it in the directory rather than to bind the individual as a stopgap, because a stopgap in access control has a half life measured in years.

Then there is self-provisioner. By default every authenticated user holds it through a cluster role binding on system:authenticated:oauth, meaning anyone who can log in can create Projects. Standard hardening advice is to remove it on day one with oc adm policy remove-cluster-role-from-group, and there is a real trap in doing so: unless you also set the rbac.authorization.kubernetes.io/autoupdate annotation on the binding to false, the cluster resets it to default on the next restart or upgrade and your change quietly evaporates.

Recommendation on self-provisioner: Leave it enabled through the migration waves and turn it off at production cutover, not before. During waves your migration engineers create and destroy scratch Projects constantly, and routing every one of those through a ticket queue adds days per wave for no security benefit on a cluster that carries no production traffic yet. Set a custom projectRequestMessage the same day you remove it, otherwise developers meet a bare forbidden error with no idea who to ask.

Before you disable self provisioning, shape what a new Project looks like. Generate the default template with oc adm create-bootstrap-project-template, add a ResourceQuota, a LimitRange, a default deny NetworkPolicy and the tenant label the next section depends on, load it into openshift-config, and point the cluster Project configuration at it. Every Project created from that moment arrives governed instead of empty, which is the single most valuable hour of configuration in this whole part.

Quota and Isolation Across a Tenant Estate

A TKGI tenant with a large Plan had a hard ceiling: the worker node count in that Plan. Nothing they deployed could exceed it, because the cluster physically was the quota. On OpenShift, a tenant that holds four Projects has four independent ResourceQuotas, and four times the ceiling you thought you granted. This is the arithmetic error that turns a carefully sized cluster into an oversubscribed one within a quarter.

OpenShift solves it with an object Kubernetes does not have. ClusterResourceQuota, in the quota.openshift.io/v1 API group, selects Projects by label or by the annotation recording who requested them, aggregates consumption across all of them, and enforces one ceiling over the set. It is the closest thing OpenShift offers to a Plan sized allocation, and it is the object I would reach for first when translating Plan tiers.

$ oc adm policy add-role-to-group admin payments-sre -n payments-prod clusterrole.rbac.authorization.k8s.io/admin added: "payments-sre" $ cat crq-payments.yaml apiVersion: quota.openshift.io/v1 kind: ClusterResourceQuota metadata: name: tenant-payments spec: selector: labels: matchLabels: tenant: payments quota: hard: pods: "150" requests.cpu: "40" requests.memory: 160Gi requests.storage: 2Ti persistentvolumeclaims: "30" $ oc apply -f crq-payments.yaml clusterresourcequota.quota.openshift.io/tenant-payments created $ oc describe clusterresourcequota tenant-payments Name: tenant-payments Created: 92 seconds ago Labels: <none> Label Selector: tenant=payments AnnotationSelector: map[] Resource Used Hard ——– —- —- persistentvolumeclaims 11 30 pods 92 150 requests.cpu 21 40 requests.memory 84Gi 160Gi requests.storage 740Gi 2Ti

Notice what that selector implies. A Project without the tenant: payments label is not covered by this quota, and it does not error, warn or appear anywhere in the describe output. It simply consumes cluster capacity outside the ceiling you believe you set. We found one such Project six weeks in, holding a forgotten load test at 14 CPU. Putting the tenant label into the project request template is the fix, and pairing it with a scheduled report that lists Projects carrying no tenant label is the belt and braces.

Add a LimitRange alongside every quota. A ResourceQuota on requests.cpu rejects any pod that does not declare a CPU request at all, which produces a confusing admission error on workloads that ran happily on TKGI without resource declarations. LimitRange supplies defaults so those pods are admitted with sensible values instead of bounced. That combination is what keeps a wave one migration from generating fifty support tickets on its first afternoon.

Field Note from a Three Cluster Identity Cutover

Forty minutes is what the empty group table cost me, and it was avoidable. I had budgeted a two hour window for identity on the target cluster, wired the OAuth resource in about fifteen minutes, applied the RoleBindings I had prepared the week before, and then spent the rest of that window convincing myself the LDAP filter was wrong. It was not. Login worked. Group sync had simply never been run, because I had read the identity provider chapter and not the group sync chapter, and those two chapters are far apart in the documentation. Once I found oc adm groups sync, the fix took four minutes.

Worse came a fortnight later, from the prune job I had been so pleased to automate. Our directory team renamed an Active Directory group during a routine tidy up, from reporting-analysts to bi-analysts, and told nobody outside their own change record. To the sync job, a rename looks exactly like a deletion followed by a creation. Our scheduled prune removed the old Group object at 03:00. A RoleBinding on the reporting Project still named reporting-analysts, still existed, still validated, and now matched an empty set. At 09:00 on Monday five analysts had a console that rendered perfectly and showed them zero Projects. Diagnosing that took most of an hour, largely because everything looked healthy.

Two changes came out of that morning and both have earned their keep. Group names used in RoleBindings are now covered by the same change control as a DNS record, agreed in writing with the directory team, and a rename requires a ticket that names the affected bindings. And the prune job writes a diff of removed groups to a channel the platform team actually reads, rather than silently succeeding. Neither is clever. Both would have turned a one hour outage into a message nobody had to act on.

Verdict on the rebuild question, since it is the one people put off. Rebuild access from team rosters. Do not migrate RoleBindings, and do not let a Velero backup that includes rbac.authorization.k8s.io restore them for you on the target. Going from 214 bindings to 31 took two afternoons with the four team leads in a room and a shared screen. Doing that same review on a running TKGI cluster had been on our backlog for three years and had never once reached the top.

Sync Groups Before Binding Anything

If you take one sequencing rule from this part, take that one. Configure the identity provider, run the group sync, confirm oc get groups returns real membership, and only then write a single RoleBinding. Doing it in that order removes an entire category of confusing failure, because from that point on a permission problem is a permission problem rather than a phantom subject.

Everything else in this part follows from treating the Project, not the cluster, as your unit of tenancy. Plans become quota plus node placement plus an SCC decision. Cluster lifecycle rights stop being something you hand to application teams. Multi Project tenants get one ceiling through ClusterResourceQuota instead of one per namespace. If a tenant genuinely needs a whole cluster, that is a legitimate answer, and the tier table above is how you make them prove it rather than simply asserting it. Readers weighing VMware vSphere Kubernetes Service instead of OpenShift as the landing place will find the same tenancy questions answered differently in the TKGI to VKS guide.

Your action for Monday: run the RoleBinding count loop from the first code block against every cluster in your estate and write the total on a whiteboard. Then book two hours with your team leads and rebuild that list from who actually needs access today. Whatever the gap turns out to be, it is the clearest argument you will ever have for why this migration is worth doing properly. Part 11 turns all of this into a target reference architecture for OpenShift 4 on vSphere.

TKGI to OpenShift Series · Part 10 of 26
« Previous: Part 9  |  Guide  |  Next: Part 11 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading