Skip to content

Multi-Tenancy

Guide for deploying EvalHub with namespace-based multi-tenancy on OpenShift.

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.

  1. 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.
  2. Authorisation — Every API request includes an X-Tenant header specifying the target namespace. EvalHub runs a SAR check: “can this principal perform this action in that namespace?”
  3. Data isolation — All database queries are filtered by tenant_id
  4. Job isolation — Evaluation jobs run in the tenant namespace with a scoped ServiceAccount

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
  1. Deploy EvalHub

    Create an EvalHub instance in the control-plane namespace. For multi-tenancy, a persistent database is recommended:

    apiVersion: trustyai.opendatahub.io/v1alpha1
    kind: EvalHub
    metadata:
    name: evalhub
    namespace: opendatahub
    spec:
    replicas: 1
    database:
    type: postgresql
    secret: evalhub-db-credentials
    providers:
    - lm-evaluation-harness
    - garak
    - garak-kfp
    - guidellm
    - lighteval
    - ibm-clear
    collections:
    - leaderboard-v2
    - safety-and-fairness-v1
    - toxicity-and-ethical-principles

    The operator automatically creates:

    ResourceNamespacePurpose
    evalhub-service SAopendatahubAPI server identity
    evalhub-opendatahub-job SAopendatahubJobs in the control-plane namespace
    ClusterRoleBinding for auth-reviewercluster-wideAllows SAR and TokenReview checks
    RoleBindings for jobs-writer, job-configopendatahubAllows creating Jobs and ConfigMaps
  2. 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-a
    oc 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:

    ResourcePurpose
    Job ServiceAccount ({evalhub-name}-{evalhub-namespace}-job)Identity for evaluation job pods
    Job access Role + RoleBindingAllows job pods to create status-events
    jobs-writer RoleBindingBinds EvalHub API SA to evalhub-jobs-writer ClusterRole (create/delete Jobs)
    job-config RoleBindingBinds EvalHub API SA to evalhub-job-config ClusterRole (ConfigMaps)
    MLFlow job RoleBindingBinds job SA to evalhub-mlflow-jobs-access ClusterRole
    Service CA ConfigMapFor 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.

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

    PrincipalToken sourceRoleBinding kindHas home namespace?
    ServiceAccountoc create tokenServiceAccountYes (where SA lives)
    OpenShift Useroc whoami -t (OAuth)UserNo
    OpenShift Groupoc whoami -t (OAuth, via member)GroupNo

    In all cases the tenant namespace is supplied by the caller via the X-Tenant header, 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 - <<EOF
    apiVersion: v1
    kind: ServiceAccount
    metadata:
    name: team-a-user
    namespace: team-a
    EOF

    Grant evaluation permissions:

    Terminal window
    oc apply -f - <<EOF
    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
    name: evalhub-evaluator
    namespace: team-a
    rules:
    - 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/v1
    kind: RoleBinding
    metadata:
    name: evalhub-evaluator-binding
    namespace: team-a
    roleRef:
    apiGroup: rbac.authorization.k8s.io
    kind: Role
    name: evalhub-evaluator
    subjects:
    - kind: ServiceAccount
    name: team-a-user
    namespace: team-a # required for ServiceAccount subjects
    EOF
  4. Access the API with tenant scoping

    All evaluation API requests must include the X-Tenant header 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.

    List providers (scoped to team-a):

    Terminal window
    evalhub providers list --token $TOKEN --tenant team-a

    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 mmlu

    The job pod will be created in the team-a namespace using the evalhub-opendatahub-job ServiceAccount, regardless of which principal type submitted the request.

    Cross-tenant access is denied:

    Terminal window
    # This will return an error — no access to team-b
    evalhub eval status --token $TOKEN --tenant team-b

    The SAR check fails because the principal (whether a ServiceAccount or a User) has no RoleBinding in the team-b namespace.

EvalHub uses an embedded SAR authoriser. The auth config (config/auth.yaml) maps API endpoints to Kubernetes resource attributes:

EndpointResourceVerbNamespace source
POST /api/v1/evaluations/jobsevaluationscreateX-Tenant header
GET /api/v1/evaluations/jobsevaluationsgetX-Tenant header
POST /api/v1/evaluations/jobs/*/eventsstatus-eventscreateX-Tenant header
* /api/v1/evaluations/collectionscollections(from HTTP method)X-Tenant header
* /api/v1/evaluations/providersproviders(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).

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.

ClusterRolePurposeKey permissions
evalhub-auth-reviewer-roleToken and SAR validationtokenreviews, subjectaccessreviews
evalhub-jobs-writerCreate evaluation jobsbatch/jobs (create, delete)
evalhub-job-configManage job configconfigmaps (create, get, update, delete)
evalhub-providers-accessProviders endpoint SARproviders (get)
evalhub-collections-accessCollections endpoint SARcollections (get)
evalhub-mlflow-accessAPI server MLFlow accessexperiments (create, get, list, update, delete)
evalhub-mlflow-jobs-accessJob pod MLFlow accessexperiments (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:

ResourceName patternPurpose
ServiceAccount{name}-{evalhub-ns}-jobIdentity for job pods
Role{name}-{evalhub-ns}-job-access-roleAllows status-events/create
RoleBinding{name}-{evalhub-ns}-job-access-rbBinds job SA to access Role
RoleBinding{name}-{tenant-ns}-job-writer-rbBinds API SA to jobs-writer ClusterRole
RoleBinding{name}-{tenant-ns}-job-config-rbBinds API SA to job-config ClusterRole
RoleBinding{name}-{evalhub-ns}-mlflow-job-rbBinds job SA to MLFlow ClusterRole
ConfigMap{name}-service-caService CA for TLS (OpenShift)

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}-job

For example, an EvalHub CR named evalhub in namespace opendatahub produces:

evalhub-opendatahub-job

After labelling a namespace with evalhub.trustyai.opendatahub.io/tenant, the operator should create the job SA and RoleBindings within a short time. Verify:

Terminal window
# Ensure the namespace has the tenant label
oc 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 evalhub

Test permissions for the tenant principal:

Terminal window
# Test access in own tenant namespace
oc auth can-i create evaluations.trustyai.opendatahub.io \
-n team-a \
--as=system:serviceaccount:team-a:team-a-user
# Test cross-tenant denial
oc auth can-i create evaluations.trustyai.opendatahub.io \
-n team-b \
--as=system:serviceaccount:team-a:team-a-user
Terminal window
# List jobs in tenant namespace
oc get jobs -n team-a -l app.kubernetes.io/part-of=eval-hub
# Check job pod SA
oc get pod -n team-a -l app.kubernetes.io/part-of=eval-hub \
-o jsonpath='{.items[0].spec.serviceAccountName}'
# Expected: evalhub-opendatahub-job

The SAR check is failing. Verify:

  1. The X-Tenant header matches a namespace where the principal has a RoleBinding
  2. The Role includes the correct resources and verbs
  3. The RoleBinding subjects[].kind matches the principal type (ServiceAccount, User, or Group)
Terminal window
oc auth can-i create evaluations.trustyai.opendatahub.io \
-n team-a \
--as=system:serviceaccount:team-a:team-a-user -v=6

Verify the EvalHub API SA has the jobs-writer and job-config RoleBindings in the tenant namespace:

Terminal window
oc get rolebindings -n team-a -o wide | grep -E "jobs-writer|job-config"

The job SA needs the status-events/create permission in the tenant namespace:

Terminal window
oc auth can-i create status-events.trustyai.opendatahub.io \
-n team-a \
--as=system:serviceaccount:team-a:evalhub-opendatahub-job