Multi-Tenancy
Guide for deploying EvalHub with namespace-based multi-tenancy on OpenShift.
Overview
Section titled “Overview”EvalHub uses Kubernetes namespaces as tenant boundaries. Each tenant operates in its own namespace, and access is enforced through Kubernetes RBAC via SubjectAccessReview (SAR) checks.
The core principle is: namespace = tenant = MLFlow workspace.
How tenant isolation works
Section titled “How tenant isolation works”- Authentication — Bearer tokens are validated via the Kubernetes TokenReview API. The token can belong to a ServiceAccount, an OpenShift User, or a member of an OpenShift Group — EvalHub treats all identically at this stage.
- Authorisation — Every API request includes an
X-Tenantheader specifying the target namespace. EvalHub runs a SAR check: “can this principal perform this action in that namespace?” - Data isolation — All database queries are filtered by
tenant_id - Job isolation — Evaluation jobs run in the tenant namespace with a scoped ServiceAccount
Prerequisites
Section titled “Prerequisites”Before setting up multi-tenancy, ensure you have:
- A working EvalHub deployment (see OpenShift Setup)
- Cluster-admin access (for creating namespaces and ClusterRoleBindings)
- The TrustyAI Operator installed and reconciling
Set up
Section titled “Set up”-
Deploy EvalHub
Create an EvalHub instance in the control-plane namespace. For multi-tenancy, a persistent database is recommended:
apiVersion: trustyai.opendatahub.io/v1alpha1kind: EvalHubmetadata:name: evalhubnamespace: opendatahubspec:replicas: 1database:type: postgresqlsecret: evalhub-db-credentialsproviders:- lm-evaluation-harness- garak- garak-kfp- guidellm- lighteval- ibm-clearcollections:- leaderboard-v2- safety-and-fairness-v1- toxicity-and-ethical-principlesThe operator automatically creates:
Resource Namespace Purpose evalhub-serviceSAopendatahubAPI server identity evalhub-opendatahub-jobSAopendatahubJobs in the control-plane namespace ClusterRoleBinding for auth-reviewer cluster-wide Allows SAR and TokenReview checks RoleBindings for jobs-writer, job-config opendatahubAllows creating Jobs and ConfigMaps -
Register tenant namespaces with the operator
Create a namespace for each tenant and label it so the TrustyAI Operator’s namespace watcher provisions job resources and RBAC automatically:
Terminal window oc create namespace team-aoc create namespace team-b# Label each namespace as an EvalHub tenant (value can be empty or e.g. "true")oc label namespace team-a evalhub.trustyai.opendatahub.io/tenant=oc label namespace team-b evalhub.trustyai.opendatahub.io/tenant=The operator watches for namespaces with the label
evalhub.trustyai.opendatahub.io/tenant. When it sees a labelled namespace (other than the EvalHub instance namespace), it automatically creates in that namespace:Resource Purpose Job ServiceAccount ( {evalhub-name}-{evalhub-namespace}-job)Identity for evaluation job pods Job access Role + RoleBinding Allows job pods to create status-eventsjobs-writer RoleBinding Binds EvalHub API SA to evalhub-jobs-writerClusterRole (create/delete Jobs)job-config RoleBinding Binds EvalHub API SA to evalhub-job-configClusterRole (ConfigMaps)MLFlow job RoleBinding Binds job SA to evalhub-mlflow-jobs-accessClusterRoleService CA ConfigMap For TLS callbacks from job pods to EvalHub (OpenShift service CA injection) If you remove the tenant label from a namespace, the operator will clean up the job-related resources from that namespace.
-
Create tenant principals (users or service accounts)
The operator provisions the job ServiceAccount and RBAC in each labelled namespace. You still need to create a tenant user identity and bind it to evaluation permissions. This is the principal that API consumers use to authenticate when calling the EvalHub API.
EvalHub supports three principal types:
Principal Token source RoleBinding kindHas home namespace? ServiceAccount oc create tokenServiceAccountYes (where SA lives) OpenShift User oc whoami -t(OAuth)UserNo OpenShift Group oc whoami -t(OAuth, via member)GroupNo In all cases the tenant namespace is supplied by the caller via the
X-Tenantheader, not inferred from the identity. A RoleBinding granting evaluation permissions must exist in that namespace.Create a ServiceAccount in the tenant namespace and grant it evaluation permissions there.
Create the ServiceAccount:
Terminal window oc apply -f - <<EOFapiVersion: v1kind: ServiceAccountmetadata:name: team-a-usernamespace: team-aEOFGrant evaluation permissions:
Terminal window oc apply -f - <<EOFapiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: evalhub-evaluatornamespace: team-arules:- apiGroups: [trustyai.opendatahub.io]resources: [evaluations, collections, providers]verbs: [get, list, create, update, delete]- apiGroups: [mlflow.kubeflow.org]resources: [experiments]verbs: [create, get]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: evalhub-evaluator-bindingnamespace: team-aroleRef:apiGroup: rbac.authorization.k8s.iokind: Rolename: evalhub-evaluatorsubjects:- kind: ServiceAccountname: team-a-usernamespace: team-a # required for ServiceAccount subjectsEOFBind an existing OpenShift User (a human identity from the OAuth server) to evaluation permissions in the tenant namespace. No ServiceAccount needs to be created.
Grant evaluation permissions:
Terminal window oc apply -f - <<EOFapiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: evalhub-evaluatornamespace: team-arules:- apiGroups: [trustyai.opendatahub.io]resources: [evaluations, collections, providers]verbs: [get, list, create, update, delete]- apiGroups: [mlflow.kubeflow.org]resources: [experiments]verbs: [create, get]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: evalhub-evaluator-bindingnamespace: team-aroleRef:apiGroup: rbac.authorization.k8s.iokind: Rolename: evalhub-evaluatorsubjects:- kind: Username: alice # OpenShift username (oc whoami)apiGroup: rbac.authorization.k8s.io# no namespace field — Users are cluster-scopedEOFBind an OpenShift Group to evaluation permissions in the tenant namespace. All members of the group inherit access without needing individual RoleBindings. This is the recommended approach for teams.
Create the Group and add members:
Terminal window # Create the group (cluster-admin required)oc adm groups new team-a-evaluators# Add users to the groupoc adm groups add-users team-a-evaluators aliceoc adm groups add-users team-a-evaluators bobGrant evaluation permissions to the group:
Terminal window oc apply -f - <<EOFapiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: evalhub-evaluatornamespace: team-arules:- apiGroups: [trustyai.opendatahub.io]resources: [evaluations, collections, providers]verbs: [get, list, create, update, delete]- apiGroups: [mlflow.kubeflow.org]resources: [experiments]verbs: [create, get]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: evalhub-evaluator-group-bindingnamespace: team-aroleRef:apiGroup: rbac.authorization.k8s.iokind: Rolename: evalhub-evaluatorsubjects:- kind: Groupname: team-a-evaluatorsapiGroup: rbac.authorization.k8s.io# no namespace field — Groups are cluster-scopedEOF -
Access the API with tenant scoping
All evaluation API requests must include the
X-Tenantheader set to the target namespace. The header is the sole source of the tenant ID — it is not derived from the token.Get the EvalHub route:
Terminal window EVALHUB_URL=$(oc get route evalhub -n opendatahub -o jsonpath='{.spec.host}')Get a token:
Terminal window TOKEN=$(oc create token team-a-user -n team-a --duration=1h)The TokenReview will resolve this to
system:serviceaccount:team-a:team-a-user.Terminal window # Log in as the user first (if not already)oc login --username=alice --password=<password>TOKEN=$(oc whoami -t)The TokenReview will resolve this to
alice(the plain OpenShift username).Terminal window # Log in as a user who is a member of the groupoc login --username=alice --password=<password>TOKEN=$(oc whoami -t)The TokenReview resolves this to
alice, and the SAR check matches thekind: GroupRoleBinding because the token includes group membership claims. The flow is identical to the User case — the only difference is which RoleBinding subject kind is matched.List providers (scoped to team-a):
Terminal window evalhub providers list --token $TOKEN --tenant team-ateam_a = SyncEvalHubClient(base_url=os.environ["EVALHUB_URL"],auth_token=TOKEN,tenant="team-a")providers = team_a.providers.list()Terminal window curl -sS -k \-H "Authorization: Bearer $TOKEN" \-H "X-Tenant: team-a" \"https://$EVALHUB_URL/api/v1/evaluations/providers" | jq .Submit an evaluation job:
Terminal window evalhub eval run \--token $TOKEN \--tenant team-a \--name mmlu-eval \--model-url http://vllm-server.team-a.svc.cluster.local:8000/v1 \--model-name meta-llama/Llama-3.2-1B-Instruct \--provider lm_evaluation_harness \--benchmark mmlujob = team_a.jobs.submit(JobSubmissionRequest(name="mmlu-eval",model=ModelConfig(url="http://vllm-server.team-a.svc.cluster.local:8000/v1",name="meta-llama/Llama-3.2-1B-Instruct"),benchmarks=[BenchmarkConfig(id="mmlu", provider_id="lm_evaluation_harness")]))Terminal window curl -sS -k -X POST \-H "Authorization: Bearer $TOKEN" \-H "X-Tenant: team-a" \-H "Content-Type: application/json" \-d '{"model": {"url": "http://vllm-server.team-a.svc.cluster.local:8000/v1","name": "meta-llama/Llama-3.2-1B-Instruct"},"benchmarks": [{"id": "mmlu","provider_id": "lm_evaluation_harness"}]}' \"https://$EVALHUB_URL/api/v1/evaluations/jobs" | jq .The job pod will be created in the
team-anamespace using theevalhub-opendatahub-jobServiceAccount, regardless of which principal type submitted the request.Cross-tenant access is denied:
Terminal window # This will return an error — no access to team-bevalhub eval status --token $TOKEN --tenant team-b# This will raise an HTTP 403 errorteam_b = SyncEvalHubClient(base_url=os.environ["EVALHUB_URL"],auth_token=TOKEN,tenant="team-b")team_b.jobs.list() # 403 ForbiddenTerminal window # This will return 403 Forbiddencurl -sS -k -X GET \-H "Authorization: Bearer $TOKEN" \-H "X-Tenant: team-b" \"https://$EVALHUB_URL/api/v1/evaluations/jobs"The SAR check fails because the principal (whether a ServiceAccount or a User) has no RoleBinding in the
team-bnamespace.
Authorisation model
Section titled “Authorisation model”EvalHub uses an embedded SAR authoriser. The auth config (config/auth.yaml) maps API endpoints to Kubernetes resource attributes:
| Endpoint | Resource | Verb | Namespace source |
|---|---|---|---|
POST /api/v1/evaluations/jobs | evaluations | create | X-Tenant header |
GET /api/v1/evaluations/jobs | evaluations | get | X-Tenant header |
POST /api/v1/evaluations/jobs/*/events | status-events | create | X-Tenant header |
* /api/v1/evaluations/collections | collections | (from HTTP method) | X-Tenant header |
* /api/v1/evaluations/providers | providers | (from HTTP method) | X-Tenant header |
For POST /api/v1/evaluations/jobs, two additional SAR checks are performed for MLFlow access (mlflow.kubeflow.org/experiments with create and get verbs).
Request flow
Section titled “Request flow”The flow is identical for both principal types. The only difference is what the TokenReview returns and which subject kind is matched in the RoleBinding.
Note that the job pod always runs as the operator-provisioned job ServiceAccount (evalhub-opendatahub-job), regardless of whether the submitter was a User, Group member, or a ServiceAccount.
RBAC reference
Section titled “RBAC reference”ClusterRoles (installed by operator)
Section titled “ClusterRoles (installed by operator)”| ClusterRole | Purpose | Key permissions |
|---|---|---|
evalhub-auth-reviewer-role | Token and SAR validation | tokenreviews, subjectaccessreviews |
evalhub-jobs-writer | Create evaluation jobs | batch/jobs (create, delete) |
evalhub-job-config | Manage job config | configmaps (create, get, update, delete) |
evalhub-providers-access | Providers endpoint SAR | providers (get) |
evalhub-collections-access | Collections endpoint SAR | collections (get) |
evalhub-mlflow-access | API server MLFlow access | experiments (create, get, list, update, delete) |
evalhub-mlflow-jobs-access | Job pod MLFlow access | experiments (create, get, list) |
Per-tenant resources (operator-provisioned)
Section titled “Per-tenant resources (operator-provisioned)”For each namespace labelled with evalhub.trustyai.opendatahub.io/tenant, the operator creates:
| Resource | Name pattern | Purpose |
|---|---|---|
| ServiceAccount | {name}-{evalhub-ns}-job | Identity for job pods |
| Role | {name}-{evalhub-ns}-job-access-role | Allows status-events/create |
| RoleBinding | {name}-{evalhub-ns}-job-access-rb | Binds job SA to access Role |
| RoleBinding | {name}-{tenant-ns}-job-writer-rb | Binds API SA to jobs-writer ClusterRole |
| RoleBinding | {name}-{tenant-ns}-job-config-rb | Binds API SA to job-config ClusterRole |
| RoleBinding | {name}-{evalhub-ns}-mlflow-job-rb | Binds job SA to MLFlow ClusterRole |
| ConfigMap | {name}-service-ca | Service CA for TLS (OpenShift) |
ServiceAccount naming
Section titled “ServiceAccount naming”The job SA name includes the EvalHub instance namespace to prevent collisions when multiple EvalHub instances (potentially with the same CR name in different namespaces) create jobs in the same tenant namespace:
{evalhub-cr-name}-{evalhub-namespace}-jobFor example, an EvalHub CR named evalhub in namespace opendatahub produces:
evalhub-opendatahub-jobVerifying the setup
Section titled “Verifying the setup”Check operator-provisioned resources
Section titled “Check operator-provisioned resources”After labelling a namespace with evalhub.trustyai.opendatahub.io/tenant, the operator should create the job SA and RoleBindings within a short time. Verify:
# Ensure the namespace has the tenant labeloc get namespace team-a --show-labels | grep evalhub.trustyai.opendatahub.io/tenant
# Verify job SA exists in tenant namespace (created by operator)oc get sa evalhub-opendatahub-job -n team-a
# Verify RoleBindings (created by operator)oc get rolebindings -n team-a | grep evalhubTest permissions for the tenant principal:
# Test access in own tenant namespaceoc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-a \ --as=system:serviceaccount:team-a:team-a-user
# Test cross-tenant denialoc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-b \ --as=system:serviceaccount:team-a:team-a-user# Test access in own tenant namespaceoc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-a \ --as=alice
# Test cross-tenant denialoc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-b \ --as=alice# Test access via group membership (using --as-group)oc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-a \ --as=alice --as-group=team-a-evaluators
# Test cross-tenant denialoc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-b \ --as=alice --as-group=team-a-evaluators
# Verify group membershipoc get group team-a-evaluators -o jsonpath='{.users[*]}'Check job execution
Section titled “Check job execution”# List jobs in tenant namespaceoc get jobs -n team-a -l app.kubernetes.io/part-of=eval-hub
# Check job pod SAoc get pod -n team-a -l app.kubernetes.io/part-of=eval-hub \ -o jsonpath='{.items[0].spec.serviceAccountName}'# Expected: evalhub-opendatahub-jobTroubleshooting
Section titled “Troubleshooting”403 Forbidden on API calls
Section titled “403 Forbidden on API calls”The SAR check is failing. Verify:
- The
X-Tenantheader matches a namespace where the principal has a RoleBinding - The Role includes the correct resources and verbs
- The RoleBinding
subjects[].kindmatches the principal type (ServiceAccount,User, orGroup)
oc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-a \ --as=system:serviceaccount:team-a:team-a-user -v=6oc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-a \ --as=alice -v=6Also confirm the RoleBinding subject kind is User and has no namespace field:
oc get rolebinding evalhub-evaluator-binding -n team-a -o yaml | grep -A5 subjectsoc auth can-i create evaluations.trustyai.opendatahub.io \ -n team-a \ --as=alice --as-group=team-a-evaluators -v=6If the SAR check passes with --as-group but real requests still fail:
-
Token doesn’t include group claims — The user must re-login (
oc login) after being added to the group. Existing OAuth tokens may not include the new group membership. -
Verify group membership:
Terminal window oc get group team-a-evaluators -o jsonpath='{.users[*]}' -
Confirm the RoleBinding subject kind is
Group:Terminal window oc get rolebinding evalhub-evaluator-group-binding -n team-a -o yaml | grep -A5 subjects
Jobs not created in tenant namespace
Section titled “Jobs not created in tenant namespace”Verify the EvalHub API SA has the jobs-writer and job-config RoleBindings in the tenant namespace:
oc get rolebindings -n team-a -o wide | grep -E "jobs-writer|job-config"Job pods failing to post status events
Section titled “Job pods failing to post status events”The job SA needs the status-events/create permission in the tenant namespace:
oc auth can-i create status-events.trustyai.opendatahub.io \ -n team-a \ --as=system:serviceaccount:team-a:evalhub-opendatahub-job