From 8961b6fe8e8e49abff4e1cd3caa5c2dfeb79e3dd Mon Sep 17 00:00:00 2001 From: Rui Vieira Date: Mon, 6 Jul 2026 13:06:41 +0100 Subject: [PATCH 1/2] refactor: reorganize tenancy documentation and update architecture sections - Updated the architecture section to group multi-tenancy and single-tenancy under a new "Tenancy" overview. - Introduced a new "Single-Tenancy" guide detailing deployment in a single namespace. - Enhanced the "Multi-Tenancy" guide with clearer descriptions and removed outdated content. - Adjusted references in the home documentation to reflect the new tenancy structure. --- astro.config.mjs | 10 +- .../docs/architecture/multi-tenancy.mdx | 229 +--------- .../docs/architecture/single-tenancy.mdx | 408 ++++++++++++++++++ src/content/docs/architecture/tenancy.mdx | 291 +++++++++++++ src/content/docs/home.mdx | 2 +- 5 files changed, 718 insertions(+), 222 deletions(-) create mode 100644 src/content/docs/architecture/single-tenancy.mdx create mode 100644 src/content/docs/architecture/tenancy.mdx diff --git a/astro.config.mjs b/astro.config.mjs index 3afe63f..b0de2bb 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -107,7 +107,15 @@ export default defineConfig({ label: 'Architecture', items: [ { label: 'System Overview', slug: 'architecture/system-overview' }, - { label: 'Multi-Tenancy', slug: 'architecture/multi-tenancy' }, + { + label: 'Tenancy', + collapsed: false, + items: [ + { label: 'Overview', slug: 'architecture/tenancy' }, + { label: 'Multi-Tenancy', slug: 'architecture/multi-tenancy' }, + { label: 'Single-Tenancy', slug: 'architecture/single-tenancy' }, + ], + }, ], }, { diff --git a/src/content/docs/architecture/multi-tenancy.mdx b/src/content/docs/architecture/multi-tenancy.mdx index 4370bd8..8ceae79 100644 --- a/src/content/docs/architecture/multi-tenancy.mdx +++ b/src/content/docs/architecture/multi-tenancy.mdx @@ -4,47 +4,9 @@ title: "Multi-Tenancy" import { Steps, Tabs, TabItem } from '@astrojs/starlight/components'; -Guide for deploying EvalHub with namespace-based multi-tenancy on OpenShift. +Step-by-step guide for deploying EvalHub with `spec.tenancy: multi` (the default) on OpenShift. One EvalHub instance in a control-plane namespace serves multiple tenant namespaces. -## 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**. - -```mermaid -flowchart LR - subgraph "Control Plane (opendatahub)" - Operator[TrustyAI Operator] - EvalHub[EvalHub API] - end - - subgraph "Tenant A (team-a)" - SA_A[Job SA] - Job_A[Eval Job] - end - - subgraph "Tenant B (team-b)" - SA_B[Job SA] - Job_B[Eval Job] - end - - Operator -->|creates RBAC| SA_A - Operator -->|creates RBAC| SA_B - EvalHub -->|schedules jobs| Job_A - EvalHub -->|schedules jobs| Job_B -``` - -### How tenant isolation works - -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 - -:::note[Tenant ID and user identity are independent] -The tenant is always determined by the `X-Tenant` request header — **not** by the identity in the token. A ServiceAccount has a home namespace, but EvalHub does not use it. A real OpenShift User or Group member has no namespace at all. In all cases the caller must supply `X-Tenant` explicitly, and a matching RoleBinding (for the User, ServiceAccount, or Group) must exist in that namespace. -::: +For shared concepts — tenant isolation, SAR authorisation, ClusterRoles, and common troubleshooting — see the [Tenancy overview](/architecture/tenancy/). ## Prerequisites @@ -60,7 +22,7 @@ Before setting up multi-tenancy, ensure you have: 1. **Deploy EvalHub** - Create an EvalHub instance in the control-plane namespace. For multi-tenancy, a persistent database is recommended: + Create an EvalHub instance in the control-plane namespace. `spec.tenancy: multi` is the default and can be omitted. For multi-tenancy, a persistent database is recommended: ```yaml apiVersion: trustyai.opendatahub.io/v1alpha1 @@ -295,10 +257,6 @@ Before setting up multi-tenancy, ensure you have: - :::note[Virtual resources] - The resources in the Role (`evaluations`, `collections`, `providers`, `status-events`) are virtual -- they don't correspond to actual Kubernetes API resources. EvalHub uses them as SAR targets to enforce fine-grained access control via the Kubernetes authorisation API. - ::: - 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. @@ -488,121 +446,7 @@ Before setting up multi-tenancy, ensure you have: -## 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 - -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. - - - - -```mermaid -sequenceDiagram - participant User as Tenant SA - participant EH as EvalHub API - participant K8s as Kubernetes API - participant Job as Eval Job Pod - - User->>EH: POST /jobs (Bearer token + X-Tenant: team-a) - EH->>K8s: TokenReview (validate token) - K8s-->>EH: user=system:serviceaccount:team-a:team-a-user - EH->>K8s: SAR (can SA create evaluations in team-a?) - K8s-->>EH: Allowed (kind:ServiceAccount RoleBinding matched) - EH->>K8s: Create Job in team-a (SA: evalhub-opendatahub-job) - K8s-->>EH: Job created - EH-->>User: 202 Accepted - - Job->>EH: POST /jobs/{id}/events (job SA token + X-Tenant: team-a) - EH->>K8s: SAR (can job SA create status-events in team-a?) - K8s-->>EH: Allowed - EH-->>Job: 200 OK -``` - - - - - -```mermaid -sequenceDiagram - participant User as OpenShift User (alice) - participant EH as EvalHub API - participant K8s as Kubernetes API - participant Job as Eval Job Pod - - User->>EH: POST /jobs (OAuth token + X-Tenant: team-a) - EH->>K8s: TokenReview (validate token) - K8s-->>EH: user=alice - EH->>K8s: SAR (can alice create evaluations in team-a?) - K8s-->>EH: Allowed (kind:User RoleBinding matched) - EH->>K8s: Create Job in team-a (SA: evalhub-opendatahub-job) - K8s-->>EH: Job created - EH-->>User: 202 Accepted - - Job->>EH: POST /jobs/{id}/events (job SA token + X-Tenant: team-a) - EH->>K8s: SAR (can job SA create status-events in team-a?) - K8s-->>EH: Allowed - EH-->>Job: 200 OK -``` - - - - - -```mermaid -sequenceDiagram - participant User as Group Member (alice) - participant EH as EvalHub API - participant K8s as Kubernetes API - participant Job as Eval Job Pod - - User->>EH: POST /jobs (OAuth token + X-Tenant: team-a) - EH->>K8s: TokenReview (validate token) - K8s-->>EH: user=alice, groups=[team-a-evaluators, ...] - EH->>K8s: SAR (can alice create evaluations in team-a?) - K8s-->>EH: Allowed (kind:Group RoleBinding matched via group membership) - EH->>K8s: Create Job in team-a (SA: evalhub-opendatahub-job) - K8s-->>EH: Job created - EH-->>User: 202 Accepted - - Job->>EH: POST /jobs/{id}/events (job SA token + X-Tenant: team-a) - EH->>K8s: SAR (can job SA create status-events in team-a?) - K8s-->>EH: Allowed - EH-->>Job: 200 OK -``` - - - - -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 - -### 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) +## RBAC reference — per-tenant resources For each namespace labelled with `evalhub.trustyai.opendatahub.io/tenant`, the operator creates: @@ -716,66 +560,7 @@ oc get pod -n team-a -l app.kubernetes.io/part-of=eval-hub \ ## Troubleshooting -### 403 Forbidden on API calls - -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`) - - - - -```bash -oc auth can-i create evaluations.trustyai.opendatahub.io \ - -n team-a \ - --as=system:serviceaccount:team-a:team-a-user -v=6 -``` - - - - - -```bash -oc auth can-i create evaluations.trustyai.opendatahub.io \ - -n team-a \ - --as=alice -v=6 -``` - -Also confirm the RoleBinding subject kind is `User` and has no `namespace` field: - -```bash -oc get rolebinding evalhub-evaluator-binding -n team-a -o yaml | grep -A5 subjects -``` - - - - - -```bash -oc auth can-i create evaluations.trustyai.opendatahub.io \ - -n team-a \ - --as=alice --as-group=team-a-evaluators -v=6 -``` - -If the SAR check passes with `--as-group` but real requests still fail: - -1. **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. -2. **Verify group membership:** - - ```bash - oc get group team-a-evaluators -o jsonpath='{.users[*]}' - ``` - -3. **Confirm the RoleBinding subject kind is `Group`:** - - ```bash - oc get rolebinding evalhub-evaluator-group-binding -n team-a -o yaml | grep -A5 subjects - ``` - - - +For common issues such as 403 Forbidden responses and missing `X-Tenant` headers, see [Troubleshooting on the Tenancy overview](/architecture/tenancy/#troubleshooting). ### Jobs not created in tenant namespace @@ -794,3 +579,7 @@ oc auth can-i create status-events.trustyai.opendatahub.io \ -n team-a \ --as=system:serviceaccount:team-a:evalhub-opendatahub-job ``` + +--- + +For single-namespace deployments, see [Single-Tenancy](/architecture/single-tenancy/). diff --git a/src/content/docs/architecture/single-tenancy.mdx b/src/content/docs/architecture/single-tenancy.mdx new file mode 100644 index 0000000..578e34c --- /dev/null +++ b/src/content/docs/architecture/single-tenancy.mdx @@ -0,0 +1,408 @@ +--- +title: "Single-Tenancy" +--- + +import { Steps, Tabs, TabItem } from '@astrojs/starlight/components'; + +Step-by-step guide for deploying EvalHub with `spec.tenancy: single` on OpenShift. EvalHub runs in the same namespace as the workloads it serves — ideal for a single team or project that does not need a shared control-plane instance. + +For shared concepts — tenant isolation, SAR authorisation, ClusterRoles, and common troubleshooting — see the [Tenancy overview](/architecture/tenancy/). + +## Prerequisites + +Before setting up single-tenancy, ensure you have: + +- The TrustyAI Operator installed and reconciling +- Namespace-admin access in the workload namespace (no cluster-admin required for tenant labelling) +- A namespace for your team or project (e.g. `team-a`) + +## Set up + + + +1. **Deploy EvalHub** + + Create an EvalHub instance in the workload namespace: + + ```yaml + apiVersion: trustyai.opendatahub.io/v1alpha1 + kind: EvalHub + metadata: + name: evalhub + namespace: team-a + spec: + tenancy: single + replicas: 1 + database: + type: sqlite + providers: + - lm-evaluation-harness + - garak + - garak-kfp + - guidellm + - lighteval + - ibm-clear + collections: + - leaderboard-v2 + - safety-and-fairness-v1 + - toxicity-and-ethical-principles + ``` + + For production workloads, use PostgreSQL instead of SQLite: + + ```yaml + database: + type: postgresql + secret: evalhub-db-credentials + ``` + + The operator automatically creates in the instance namespace: + + | Resource | Purpose | + |----------|---------| + | `evalhub-service` SA | API server identity | + | `evalhub-team-a-job` SA | Identity for evaluation job pods | + | ClusterRole RoleBindings | jobs-writer, job-config, auth-reviewer, MLFlow | + | `evalhub-tenant-admin` Role | Full CRUD on virtual EvalHub resources + MLflow read | + | `evalhub-user` Role | Read providers/collections, submit via `evalhubs/proxy`, create status-events | + | `evalhub-tenant-admin-binding` | Binds admin Role to `system:serviceaccounts:team-a` | + + :::note[Default admin access] + The operator binds **all** ServiceAccounts in the namespace to `evalhub-tenant-admin`. For restricted access, create an explicit RoleBinding to `evalhub-user` instead. + ::: + +2. **Grant user access** + + Unlike multi-tenancy, you do not need to write a custom `evalhub-evaluator` Role. The operator creates `evalhub-user` and `evalhub-tenant-admin` for you. Bind your principals to `evalhub-user` for standard evaluation access. + + EvalHub supports three principal types: + + | Principal | Token source | RoleBinding `kind` | Has home namespace? | + |-----------|-------------|-------------------|---------------------| + | ServiceAccount | `oc create token` | `ServiceAccount` | Yes (where SA lives) | + | OpenShift User | `oc whoami -t` (OAuth) | `User` | No | + | OpenShift Group | `oc whoami -t` (OAuth, via member) | `Group` | No | + + + + + **Create the ServiceAccount:** + + ```bash + oc apply -f - < + + + + **Bind an OpenShift User to `evalhub-user`:** + + ```bash + oc apply -f - < + + + + **Create the Group and add members:** + + ```bash + oc adm groups new team-a-evaluators + oc adm groups add-users team-a-evaluators alice + oc adm groups add-users team-a-evaluators bob + ``` + + **Bind the group to `evalhub-user`:** + + ```bash + oc apply -f - < + + +3. **Access the API** + + All evaluation API requests must include the `X-Tenant` header set to the EvalHub instance namespace. In single-tenancy mode, this is always the namespace where EvalHub is deployed. + + **Get the EvalHub route:** + + ```bash + EVALHUB_URL=$(oc get route evalhub -n team-a -o jsonpath='{.spec.host}') + ``` + + **Get a token:** + + + + + ```bash + TOKEN=$(oc create token team-a-user -n team-a --duration=1h) + ``` + + + + + + ```bash + oc login --username=alice --password= + TOKEN=$(oc whoami -t) + ``` + + + + + + ```bash + oc login --username=alice --password= + TOKEN=$(oc whoami -t) + ``` + + + + + **List providers:** + + + + + ```bash + evalhub providers list --token $TOKEN --tenant team-a + ``` + + + + + ```python + client = SyncEvalHubClient( + base_url=os.environ["EVALHUB_URL"], + auth_token=TOKEN, + tenant="team-a" + ) + providers = client.providers.list() + ``` + + + + + ```bash + curl -sS -k \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-Tenant: team-a" \ + "https://$EVALHUB_URL/api/v1/evaluations/providers" | jq . + ``` + + + + + **Submit an evaluation job:** + + + + + ```bash + 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 + ``` + + + + + ```python + job = client.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") + ] + )) + ``` + + + + + ```bash + 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 is created in `team-a` using the `evalhub-team-a-job` ServiceAccount. + + + +## RBAC reference — convenience Roles + +In single-tenancy mode, the operator creates these namespace-scoped Roles (garbage-collected when the EvalHub CR is deleted): + +### `evalhub-tenant-admin` + +| API group | Resources | Verbs | +|-----------|-----------|-------| +| `trustyai.opendatahub.io` | `evaluations`, `collections`, `providers`, `status-events` | get, list, watch, create, update, patch, delete | +| `trustyai.opendatahub.io` | `evalhubs/proxy` (scoped to instance name) | get, create | +| `mlflow.kubeflow.org` | `experiments` | get, list | + +Bound to `system:serviceaccounts:{namespace}` via `evalhub-tenant-admin-binding`, so all ServiceAccounts in the namespace receive admin access by default. + +### `evalhub-user` + +| API group | Resources | Verbs | +|-----------|-----------|-------| +| `trustyai.opendatahub.io` | `collections`, `providers` | get, list, watch | +| `trustyai.opendatahub.io` | `evalhubs/proxy` (scoped to instance name) | get, create | +| `trustyai.opendatahub.io` | `status-events` | create | +| `mlflow.kubeflow.org` | `experiments` | get, list, create | + +Bind individual principals to this Role for standard evaluation access without full admin permissions. + +## Tenant provider and collection ConfigMaps + +You can add custom providers and collections in the instance namespace without modifying the operator namespace. Label ConfigMaps with: + +- Providers: `trustyai.opendatahub.io/evalhub-provider-type=tenant` +- Collections: `trustyai.opendatahub.io/evalhub-collection-type=tenant` + +The operator discovers these in-place and mounts them at `/config/providers/tenant/` and `/config/collections/tenant/` — separate from system configs at `/config/providers/` and `/config/collections/` to avoid shadowing. + +Changes to tenant ConfigMaps trigger reconciliation via the operator's ConfigMap watch; no CR edit is required. + +## What does not happen + +In single-tenancy mode, the operator does **not**: + +- Watch for `evalhub.trustyai.opendatahub.io/tenant` labels on other namespaces +- Provision job ServiceAccounts or RoleBindings in namespaces other than the instance namespace +- Create `evalhub-discovery` ConfigMaps in other namespaces + +## Verifying the setup + +```bash +# Convenience Roles created by the operator +oc get role evalhub-tenant-admin evalhub-user -n team-a +oc get rolebinding evalhub-tenant-admin-binding -n team-a + +# Job SA in the instance namespace +oc get sa evalhub-team-a-job -n team-a + +# Confirm no EvalHub resources in a second namespace +oc get sa,role,rolebinding -n team-b 2>/dev/null | grep evalhub || echo "No cross-namespace resources (expected)" +``` + +Test API access with the correct tenant header: + +```bash +oc auth can-i get providers.trustyai.opendatahub.io \ + -n team-a \ + --as=system:serviceaccount:team-a:team-a-user +``` + +## Troubleshooting + +For common issues such as 403 Forbidden responses, see [Troubleshooting on the Tenancy overview](/architecture/tenancy/#troubleshooting). + +### Wrong `X-Tenant` value + +In single-tenancy mode, `X-Tenant` must be set to the EvalHub instance namespace — not a different namespace. A request with `X-Tenant: other-ns` is rejected unless the caller has a RoleBinding in `other-ns`. + +### Missing `X-Tenant` header + +Requests without the `X-Tenant` header are rejected even though there is only one tenant. Always set `X-Tenant` to the instance namespace. + +### Default admin binding vs `evalhub-user` + +If a ServiceAccount can access the API without an explicit `evalhub-user` RoleBinding, it is likely receiving permissions through `evalhub-tenant-admin-binding` (which binds all namespace ServiceAccounts to admin). To enforce least privilege, remove or avoid relying on the default binding and bind principals explicitly to `evalhub-user`. + +--- + +For shared control-plane deployments serving multiple teams, see [Multi-Tenancy](/architecture/multi-tenancy/). diff --git a/src/content/docs/architecture/tenancy.mdx b/src/content/docs/architecture/tenancy.mdx new file mode 100644 index 0000000..3c38476 --- /dev/null +++ b/src/content/docs/architecture/tenancy.mdx @@ -0,0 +1,291 @@ +--- +title: "Tenancy" +--- + +import { LinkCard, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components'; + +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 TrustyAI Operator controls deployment behaviour via `spec.tenancy` on the EvalHub custom resource. Two modes are available: + +| | `multi` (default) | `single` | +|---|---|---| +| EvalHub placement | Control-plane namespace (e.g. `opendatahub`) | Workload namespace | +| Tenant discovery | Label `evalhub.trustyai.opendatahub.io/tenant` on namespaces | None — serves own namespace only | +| Cross-namespace RBAC | Operator provisions job SA + RoleBindings per tenant | Not performed | +| Convenience Roles | Not created | `evalhub-tenant-admin`, `evalhub-user`, admin binding | +| InvalidPlacement guard | Yes — cannot deploy in tenant-labelled NS | No — may deploy anywhere | +| Database | PostgreSQL recommended | SQLite acceptable for dev/small teams | + +The core principle in both modes is: **namespace = tenant = MLFlow workspace**. + +## Topologies + + + + +One EvalHub instance in a control-plane namespace serves multiple tenant namespaces. + +```mermaid +flowchart LR + subgraph "Control Plane (opendatahub)" + Operator[TrustyAI Operator] + EvalHub[EvalHub API] + end + + subgraph "Tenant A (team-a)" + SA_A[Job SA] + Job_A[Eval Job] + end + + subgraph "Tenant B (team-b)" + SA_B[Job SA] + Job_B[Eval Job] + end + + Operator -->|creates RBAC| SA_A + Operator -->|creates RBAC| SA_B + EvalHub -->|schedules jobs| Job_A + EvalHub -->|schedules jobs| Job_B +``` + + + + + +EvalHub runs in the same namespace as the workloads it serves. + +```mermaid +flowchart LR + subgraph "Workload Namespace (team-a)" + Operator[TrustyAI Operator] + EvalHub[EvalHub API] + SA[Job SA] + Job[Eval Job] + User[User SA] + end + + Operator -->|creates RBAC| SA + Operator -->|creates convenience Roles| User + EvalHub -->|schedules jobs| Job + User -->|API calls| EvalHub +``` + + + + +## How tenant isolation works + +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 + +:::note[Tenant ID and user identity are independent] +The tenant is always determined by the `X-Tenant` request header — **not** by the identity in the token. A ServiceAccount has a home namespace, but EvalHub does not use it. A real OpenShift User or Group member has no namespace at all. In all cases the caller must supply `X-Tenant` explicitly, and a matching RoleBinding (for the User, ServiceAccount, or Group) must exist in that namespace. +::: + +## 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 + +The flow is identical for all principal types. The only difference is what the TokenReview returns and which subject kind is matched in the RoleBinding. + + + + +```mermaid +sequenceDiagram + participant User as Tenant SA + participant EH as EvalHub API + participant K8s as Kubernetes API + participant Job as Eval Job Pod + + User->>EH: POST /jobs (Bearer token + X-Tenant: team-a) + EH->>K8s: TokenReview (validate token) + K8s-->>EH: user=system:serviceaccount:team-a:team-a-user + EH->>K8s: SAR (can SA create evaluations in team-a?) + K8s-->>EH: Allowed (kind:ServiceAccount RoleBinding matched) + EH->>K8s: Create Job in team-a (SA: evalhub-opendatahub-job) + K8s-->>EH: Job created + EH-->>User: 202 Accepted + + Job->>EH: POST /jobs/{id}/events (job SA token + X-Tenant: team-a) + EH->>K8s: SAR (can job SA create status-events in team-a?) + K8s-->>EH: Allowed + EH-->>Job: 200 OK +``` + + + + + +```mermaid +sequenceDiagram + participant User as OpenShift User (alice) + participant EH as EvalHub API + participant K8s as Kubernetes API + participant Job as Eval Job Pod + + User->>EH: POST /jobs (OAuth token + X-Tenant: team-a) + EH->>K8s: TokenReview (validate token) + K8s-->>EH: user=alice + EH->>K8s: SAR (can alice create evaluations in team-a?) + K8s-->>EH: Allowed (kind:User RoleBinding matched) + EH->>K8s: Create Job in team-a (SA: evalhub-opendatahub-job) + K8s-->>EH: Job created + EH-->>User: 202 Accepted + + Job->>EH: POST /jobs/{id}/events (job SA token + X-Tenant: team-a) + EH->>K8s: SAR (can job SA create status-events in team-a?) + K8s-->>EH: Allowed + EH-->>Job: 200 OK +``` + + + + + +```mermaid +sequenceDiagram + participant User as Group Member (alice) + participant EH as EvalHub API + participant K8s as Kubernetes API + participant Job as Eval Job Pod + + User->>EH: POST /jobs (OAuth token + X-Tenant: team-a) + EH->>K8s: TokenReview (validate token) + K8s-->>EH: user=alice, groups=[team-a-evaluators, ...] + EH->>K8s: SAR (can alice create evaluations in team-a?) + K8s-->>EH: Allowed (kind:Group RoleBinding matched via group membership) + EH->>K8s: Create Job in team-a (SA: evalhub-opendatahub-job) + K8s-->>EH: Job created + EH-->>User: 202 Accepted + + Job->>EH: POST /jobs/{id}/events (job SA token + X-Tenant: team-a) + EH->>K8s: SAR (can job SA create status-events in team-a?) + K8s-->>EH: Allowed + EH-->>Job: 200 OK +``` + + + + +Note that the job pod always runs as the operator-provisioned job ServiceAccount (e.g. `evalhub-opendatahub-job`), regardless of whether the submitter was a User, Group member, or a ServiceAccount. + +## RBAC reference — ClusterRoles + +The operator installs these ClusterRoles cluster-wide. Mode-specific Role and RoleBinding details are documented on the setup pages linked below. + +| 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) | + +:::note[Virtual resources] +The SAR targets (`evaluations`, `collections`, `providers`, `status-events`) are virtual — they don't correspond to actual Kubernetes API resources. EvalHub uses them to enforce fine-grained access control via the Kubernetes authorisation API. +::: + +## Mode switching + +You can change `spec.tenancy` on a running EvalHub instance. The operator reconciles the difference automatically: + +| Transition | Operator behaviour | +|---|---| +| `single` → `multi` | Removes convenience Roles; provisions cross-namespace RBAC in tenant-labelled namespaces | +| `multi` → `single` | Cleans up cross-namespace resources; creates convenience Roles in the instance namespace | + +:::caution[InvalidPlacement] +A **multi-tenant** instance placed in a namespace carrying the `evalhub.trustyai.opendatahub.io/tenant` label enters `Error` phase with reason `InvalidPlacement`. Fix by removing the tenant label from that namespace or switching to `spec.tenancy: single`. +::: + +## Troubleshooting + +### 403 Forbidden on API calls + +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`) + + + + +```bash +oc auth can-i create evaluations.trustyai.opendatahub.io \ + -n team-a \ + --as=system:serviceaccount:team-a:team-a-user -v=6 +``` + + + + + +```bash +oc auth can-i create evaluations.trustyai.opendatahub.io \ + -n team-a \ + --as=alice -v=6 +``` + +Also confirm the RoleBinding subject kind is `User` and has no `namespace` field: + +```bash +oc get rolebinding evalhub-evaluator-binding -n team-a -o yaml | grep -A5 subjects +``` + + + + + +```bash +oc auth can-i create evaluations.trustyai.opendatahub.io \ + -n team-a \ + --as=alice --as-group=team-a-evaluators -v=6 +``` + +If the SAR check passes with `--as-group` but real requests still fail: + +1. **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. +2. **Verify group membership:** + + ```bash + oc get group team-a-evaluators -o jsonpath='{.users[*]}' + ``` + +3. **Confirm the RoleBinding subject kind is `Group`:** + + ```bash + oc get rolebinding evalhub-evaluator-group-binding -n team-a -o yaml | grep -A5 subjects + ``` + + + + +### Missing `X-Tenant` header + +All evaluation API requests require the `X-Tenant` header, even in single-tenancy mode where the tenant is always the EvalHub instance namespace. Requests without the header are rejected. + +## Setup guides + + + + + diff --git a/src/content/docs/home.mdx b/src/content/docs/home.mdx index 6aa58a1..95af1ff 100644 --- a/src/content/docs/home.mdx +++ b/src/content/docs/home.mdx @@ -29,7 +29,7 @@ EvalHub provides a unified way to evaluate LLMs across multiple frameworks — s - **Provider registry** with benchmark discovery - **OCI artifact persistence** for evaluation results - **Prometheus metrics** and OpenTelemetry tracing -- **Multi-tenancy** with namespace-based isolation and Kubernetes RBAC +- **[Tenancy](/architecture/tenancy/)** with multi- and single-namespace deployment modes and Kubernetes RBAC ## How It Works From 61a1f4f07206546b115499e937db888165e410eb Mon Sep 17 00:00:00 2001 From: Rui Vieira Date: Mon, 6 Jul 2026 13:19:43 +0100 Subject: [PATCH 2/2] docs(single-tenancy): clarify role permissions and access notes - Updated the `evalhub-tenant-admin` role description to specify MLflow read-only access. - Enhanced the default admin access note to clarify job submission requirements and permissions for `evalhub-user`. - Added details on MLflow submission permissions related to `evaluations` and `experiments`. --- src/content/docs/architecture/single-tenancy.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/content/docs/architecture/single-tenancy.mdx b/src/content/docs/architecture/single-tenancy.mdx index 578e34c..842ff6b 100644 --- a/src/content/docs/architecture/single-tenancy.mdx +++ b/src/content/docs/architecture/single-tenancy.mdx @@ -63,12 +63,12 @@ Before setting up single-tenancy, ensure you have: | `evalhub-service` SA | API server identity | | `evalhub-team-a-job` SA | Identity for evaluation job pods | | ClusterRole RoleBindings | jobs-writer, job-config, auth-reviewer, MLFlow | - | `evalhub-tenant-admin` Role | Full CRUD on virtual EvalHub resources + MLflow read | - | `evalhub-user` Role | Read providers/collections, submit via `evalhubs/proxy`, create status-events | + | `evalhub-tenant-admin` Role | Full CRUD on virtual EvalHub resources; MLflow read-only (get, list) | + | `evalhub-user` Role | Read providers/collections, submit via `evalhubs/proxy`, create status-events, MLflow create/get | | `evalhub-tenant-admin-binding` | Binds admin Role to `system:serviceaccounts:team-a` | - :::note[Default admin access] - The operator binds **all** ServiceAccounts in the namespace to `evalhub-tenant-admin`. For restricted access, create an explicit RoleBinding to `evalhub-user` instead. + :::note[Default admin access and job submission] + The operator binds **all** ServiceAccounts in the namespace to `evalhub-tenant-admin`. That Role grants `evaluations` create but MLflow `experiments` are read-only. `POST /api/v1/evaluations/jobs` also requires MLflow `create` and `get` — bind principals to `evalhub-user` for those permissions (RBAC unions across bindings). For restricted access without admin CRUD, bind `evalhub-user` only. ::: 2. **Grant user access** @@ -333,6 +333,8 @@ In single-tenancy mode, the operator creates these namespace-scoped Roles (garba | `trustyai.opendatahub.io` | `evalhubs/proxy` (scoped to instance name) | get, create | | `mlflow.kubeflow.org` | `experiments` | get, list | +MLflow access is read-only — no `create` verb. Although this Role includes `evaluations` create, `POST /api/v1/evaluations/jobs` also performs SAR checks for MLflow `experiments` with `create` and `get`. Bind principals to [`evalhub-user`](#evalhub-user) for MLflow submission permissions. + Bound to `system:serviceaccounts:{namespace}` via `evalhub-tenant-admin-binding`, so all ServiceAccounts in the namespace receive admin access by default. ### `evalhub-user`