vmafx-controller: Multi-Tenant Auth Gateway¶
ADRs: ADR-0794 (gateway), ADR-1518 (roles), ADR-1522 (tenant scoping), ADR-1519 (tenant registry)
The vmafx-controller supports multi-tenant deployments through a built-in JWT auth gateway. You configure it with VMAFX_* environment variables (the controller has no CLI flags beyond --version, since ADR-1119) and send a bearer token on every request.
The controller verifies tokens in one of two ways:
- One identity provider for every tenant: the controller trusts
VMAFX_JWKS_ENDPOINTandVMAFX_AUTH_ISSUERand takes the tenant from each token's tenant claim. Any tenant name the provider issues is accepted. The quick start uses this mode. - A tenant registry: the controller reads
VmafxTenantresources (from the Kubernetes API or a file) and accepts only those tenants, each with its own identity provider, suspension switch and role whitelist. See Tenant registry.
Every gRPC and HTTP request, except the liveness, readiness and metrics endpoints, must carry a valid RS256 bearer token from a configured OIDC provider. Requests are scoped to the tenant identified by the token, and every gRPC call and the HTTP POST /v1/score endpoint require a role from the token; see Roles and RBAC.
Table of contents¶
- Quick start
- Token structure
- OIDC provider configuration
- Auth0
- Keycloak
- Dex
- Roles and RBAC
- Tenant isolation
- Tenant registry
- Helm configuration
- VmafxTenant CRD
- Disabling auth
- Environment variables
- Key rotation
- Threat model summary
Quick start¶
-
Start the controller with auth enabled (Auth0 example).
-
Fetch a token from the identity provider.
-
Call the API with the bearer token.
Token structure¶
The controller extracts the following claims from the JWT payload:
| Claim | Required | Default field | Description |
|---|---|---|---|
iss | Yes | — | Must match VMAFX_AUTH_ISSUER. |
exp | Yes | — | Token expiry; checked on every request. |
aud | No | — | Checked if VMAFX_AUTH_AUDIENCE is set. |
sub | No | — | Subject (logged for audit). |
tid | Yes* | VMAFX_AUTH_TENANT_CLAIM | Tenant identifier. |
vmafx_roles | No | VMAFX_AUTH_ROLES_CLAIM | List of role strings. |
*tid is required unless VMAFX_AUTH_DISABLED=true is set.
Example payload:
{
"iss": "https://idp.example.com/",
"sub": "user|abc123",
"aud": "https://vmafx.example.com/api",
"exp": 1893456000,
"tid": "acme",
"vmafx_roles": ["vmafx:writer"]
}
OIDC provider configuration¶
The controller only needs the IdP's JWKS endpoint and issuer URL. It does not perform OIDC discovery automatically; provide the endpoint directly.
Rules for the signing keys in the JWKS:
- Keys must be RSA keys of at least 2048 bits with an odd public exponent (normally 65537).
- A key below 2048 bits is skipped and logged as
jwks: skipping RSA key below the minimum sizewith itskid. Tokens signed with that key are rejected with401; other keys in the same JWKS keep working. - A key with a malformed exponent makes the JWKS refresh fail.
Auth0¶
VMAFX_JWKS_ENDPOINT=https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json
VMAFX_AUTH_ISSUER=https://YOUR_DOMAIN.auth0.com/
VMAFX_AUTH_AUDIENCE=https://vmafx.example.com/api
VMAFX_AUTH_TENANT_CLAIM=org_id # Auth0 organisation ID claim
In Auth0, add the org_id claim to your token and create a custom vmafx_roles action in the Auth0 Login flow.
Keycloak¶
VMAFX_JWKS_ENDPOINT=https://keycloak.example.com/realms/vmafx/protocol/openid-connect/certs
VMAFX_AUTH_ISSUER=https://keycloak.example.com/realms/vmafx
VMAFX_AUTH_AUDIENCE=vmafx-api
VMAFX_AUTH_TENANT_CLAIM=tid # add as a custom mapper in Keycloak
VMAFX_AUTH_ROLES_CLAIM=vmafx_roles # add as a custom mapper in Keycloak
Dex¶
VMAFX_JWKS_ENDPOINT=https://dex.example.com/keys
VMAFX_AUTH_ISSUER=https://dex.example.com
VMAFX_AUTH_TENANT_CLAIM=tid
Roles and RBAC¶
Four roles are recognised. Include one or more in the vmafx_roles claim (a JSON array, or a single string):
| Role | May call |
|---|---|
vmafx:reader | GetJob, StreamJobs, VmafxScoring.Health |
vmafx:writer | Everything a reader may, plus SubmitJob, CancelJob, VmafxScoring.Score, VmafxScoring.ScoreStream and HTTP POST /v1/score |
vmafx:admin | Everything a writer may |
vmafx:node | The node API only: RegisterNode, Heartbeat, PullWork, ReportResult |
vmafx:node is the only role that reaches the node API, and it reaches nothing else: a node's token cannot read, submit or cancel jobs or score, and no user token can register a node or take jobs from the queue (ADR-1563). Give a node a token that holds vmafx:node alone.
The controller enforces this table on every call (ADR-1518):
- A gRPC call whose token holds none of the method's roles fails with
PERMISSION_DENIEDand the messagerole required: <roles>before the handler runs. A token without avmafx_rolesclaim, with an empty one, or with only unknown strings (for examplevmafx:root) holds no role and is refused everywhere. - HTTP
POST /v1/scoreanswers403 Forbiddento a token withoutvmafx:writerorvmafx:admin. - A gRPC method the controller does not list in its role table is refused for every caller, including the synthetic admin of disabled mode. The table lives in
cmd/vmafx-controller/grpc_roles.go, and a test fails when a served method is missing from it.
Roles are not inherited implicitly: the table above lists, for each call, every role that may make it. A writer can read because reader calls also list vmafx:writer.
Clients therefore need a token with the right role:
vmafx-mcpsendsVMAFX_CONTROLLER_TOKENwith every controller call; itssubmit_jobandcancel_jobtools needvmafx:writer,get_jobandlist_jobsneedvmafx:reader.- A compute node needs
vmafx:node(before ADR-1563 it neededvmafx:admin, which no longer reaches the node API).
Tenant isolation¶
Every job is tagged with the tenant_id of the submitter's token, and every call reads and writes only the jobs of its own token's tenant (ADR-1522):
SubmitJobstamps the new job with the caller's tenant.GetJobandCancelJobanswerPERMISSION_DENIED(resource belongs to another tenant) for another tenant's job, without naming that tenant.StreamJobsstreams only the caller's tenant's jobs; the tenant is part of the database query, so no other tenant's job is read.- A node session belongs to the tenant of the token that called
RegisterNode.Heartbeatanswersok=false, andPullWorkandReportResultanswerPERMISSION_DENIED, when called with that session and a token of another tenant.PullWorkgives a node only its tenant's jobs. ReportResultaccepts a result, final or partial, for a job assigned to the reporting node, or for a running job of the same tenant whose node has no live session any more (a node that registered again after a controller restart or an eviction reports what it finished before). A report for a job of another tenant, a pending job, a job of a live node or an unknown job answersPERMISSION_DENIED(job "<id>" is not assigned to node "<id>") and changes nothing. Repeating a final report of a finished job succeeds without changing it.
A deployment that serves several tenants from one pool of nodes therefore needs a node registration per tenant; nodes shared across tenants are not supported. Jobs stored before the auth gateway carry the empty tenant, which no token can hold, so no caller can read them.
Tenant IDs are opaque strings compared exactly (case and whitespace count).
Scoring roots¶
A caller may score only inputs that lie under its tenant's scoring roots (ADR-1577). Without that rule a writer of one tenant could make the controller or a node read another tenant's media, or any file the process can open, and read the scores as an oracle. A tenant without roots scores nothing: every input is refused (deny by default).
A root is one of:
| Kind | Example root | Admits |
|---|---|---|
| Absolute directory | /media/acme | /media/acme/ref.y4m, file:///media/acme/x/dis.y4m |
| http(s) prefix | https://media.example.com/acme/ | https://media.example.com/acme/a.y4m (scheme and host compared case-insensitively) |
| rclone remote prefix | s3:media/acme, s3://media/acme, :local:/srv/acme | s3:media/acme/k/ref.y4m, s3://media/acme/ref.y4m |
An input is refused when it is relative, contains a .. element (also percent-encoded in a URL), or lies outside every root (/media/acme-old is not under /media/acme). The refusal is PERMISSION_DENIED over gRPC and 403 over HTTP, with the message scoring input "<input>" is outside the tenant's scoring roots.
Where the check runs:
VmafxScoring.ScoreandPOST /v1/scoreread the files on the controller: each input is resolved there, symlinks followed, and must still lie under a root; the controller then scores the resolved path, so a link changed after the check is not followed. A link inside/media/acmethat points at/media/rival/secret.y4mis refused.SubmitJob's inputs are read by a node: the controller checks them as written, andPullWorkhands the job's tenant roots to the node (Job.scoring_roots), which resolves the inputs on its own file system before it prepares or scores them, and fails the job otherwise. A node refuses a job that arrives without roots.
Where the roots come from:
- With a tenant registry:
spec.scoring.rootsof eachVmafxTenant(at most 32). An invalid root makes the tenant invalid, like any other setting. - With one identity provider or with auth disabled:
VMAFX_SCORING_ROOTS, a comma-separated list for every caller;{tenant}in an entry becomes the caller's tenant ID (/media/{tenant},s3:media/{tenant}). A tenant ID that contains/,\,:or is.or..is refused rather than substituted.VMAFX_SCORING_ROOTStogether with a tenant registry stops the controller at startup.
Tenant registry¶
With VMAFX_AUTH_TENANTS_SOURCE set, the controller accepts a token only when it belongs to exactly one configured, enabled tenant:
- The token's
issclaim selects the tenants whoseoidc.issuerit names. No such tenant:UNAUTHENTICATED/401. - Each of those tenants checks the signature against its own
oidc.jwksEndpoint, the expiry, its own issuer and (when set) its ownoidc.audience, then reads its ownoidc.tenantClaim. The tenant whose claim value equals itstenantIdis the match. A token one tenant's provider issued with another tenant's ID matches nothing and is refused; a token matching two tenants is refused too. - A tenant with
enabled: falseis refused withPERMISSION_DENIED/403. - The caller's roles are the vmafx roles of the token's
oidc.rolesClaimthat are inrbac.allowedRoles; roles outside the list are dropped. A token that names no vmafx role at all getsrbac.defaultRole. A token whose only vmafx roles are all dropped keeps no role and is refused everywhere.
Defaults follow the CRD: enabled: true, tenantClaim: tid, rolesClaim: vmafx_roles, defaultRole: vmafx:reader, allowedRoles: [vmafx:reader, vmafx:writer]. So by default no token of a registry tenant can act as a node: list vmafx:node in allowedRoles for a tenant that runs its own nodes. defaultRole: vmafx:node is refused (the CRD does not offer it and the controller rejects it from a file source): a token without a role claim never acts as a node.
Set oidc.audience: without it, a token the tenant's provider issues for any other application, with the tenant's claim, is accepted. The controller logs a warning at startup for each tenant without one. When several tenants share one provider, the signature of a token is checked once per JWKS endpoint, not once per tenant.
Refused tokens get a fixed UNAUTHENTICATED message (invalid or missing token); the reason (unknown issuer, bad signature, expired, no matching tenant) is only logged, so a caller cannot probe which issuers and tenants are configured.
Sources¶
VMAFX_AUTH_TENANTS_SOURCE=kuberneteslists theVmafxTenantresources ofVMAFX_AUTH_TENANTS_NAMESPACE(default: the controller pod's namespace) with the pod's service account, which needsget,listandwatchonvmafxtenantsthere. The Helm chart grants it (see Helm configuration).VMAFX_AUTH_TENANTS_SOURCE=filereadsVMAFX_AUTH_TENANTS_FILE: YAML or JSON documents, each aVmafxTenant(as in the CRD example) or a list of them (kind: ListorVmafxTenantList, askubectl get vmafxtenants -o yamlwrites). Use it to run a tenant registry outside Kubernetes.
The controller re-reads the source every VMAFX_AUTH_TENANTS_REFRESH (default 30s, between 1s and 1h), so suspending a tenant or changing its roles takes effect within one interval, without a restart.
What stops the controller, and what it refuses at run time¶
At startup the controller does not start, and logs why, when:
- the source cannot be read, or the file is not YAML or JSON;
- a document is not
apiVersion: vmafx.dev/v1,kind: VmafxTenant(or a list of them), or its spec has a field the CRD does not define (for exampleallowedRoleinstead ofallowedRoles); - a tenant is invalid:
tenantIdnot matching the CRD pattern, an issuer that is not an absolutehttp(s)URL, a JWKS endpoint that is nothttps(plainhttpis accepted forlocalhostand loopback addresses only), an unknown role, an emptyallowedRoles, or adefaultRolethat is not inallowedRoles; - two tenants have the same
tenantId; - a tenant setting does not fit the source (
VMAFX_AUTH_TENANTS_FILEwithoutVMAFX_AUTH_TENANTS_SOURCE=file, an unknown source, a refresh interval out of range), or the tenant source is combined withVMAFX_AUTH_DISABLED=trueor withVMAFX_JWKS_ENDPOINT,VMAFX_AUTH_ISSUER,VMAFX_AUTH_AUDIENCE,VMAFX_AUTH_TENANT_CLAIMorVMAFX_AUTH_ROLES_CLAIM(each tenant carries its own; the global values would be ignored).
Because startup is strict, a VmafxTenant that the API server accepts but the controller refuses (for example defaultRole: vmafx:admin with the default allowedRoles) stops the controller from starting after its next restart, for every tenant. Check a new tenant in the controller log (refreshes report it, see below) before restarting.
On a refresh, an invalid tenant is dropped and logged (tenant refused; its tokens are rejected until it is fixed) while the other tenants are reloaded. A refresh that cannot read the source keeps the last tenant set; once that set is older than ten refresh intervals (five minutes by default) every request is refused with UNAVAILABLE / 503 (tenant configuration is stale), because the controller can no longer tell whether a tenant was suspended.
Helm configuration¶
The auth gateway is part of vmafx-controller, which the chart deploys as its own workload with controller.enabled (ADR-1589, Kubernetes guide). auth.* configures that workload only; auth.enabled and controller.enabled go together, and the render fails for one without the other. The server workload (vmafx-server) has no auth gateway and gets no auth settings; an image.repository naming a vmafx-controller image is refused.
One identity provider:
controller:
enabled: true
auth:
enabled: true
jwksEndpoint: https://idp.example.com/.well-known/jwks.json
issuer: https://idp.example.com/
audience: vmafx-api # optional
tenantClaim: tid # default
rolesClaim: vmafx_roles # default
scoringRoots: # VMAFX_SCORING_ROOTS; none refuses every input
- /media/{tenant}
With a tenant registry, give each entry of auth.tenants its scoring.roots; the chart refuses auth.scoringRoots next to a registry and without auth.enabled.
These values become the VMAFX_* variables of the environment table.
A tenant registry:
controller:
enabled: true
auth:
enabled: true
issuer: https://idp.example.com/ # default for entries without one
jwksEndpoint: https://idp.example.com/keys # default for entries without one
tenants:
- tenantId: acme
oidc:
issuer: https://acme.auth0.com/
jwksEndpoint: https://acme.auth0.com/.well-known/jwks.json
audience: vmafx-api
rbac:
defaultRole: vmafx:reader
allowedRoles: [vmafx:reader, vmafx:writer]
- tenantId: lab
enabled: false # suspended
A non-empty auth.tenants (or auth.tenantSource: kubernetes, for VmafxTenant resources managed outside the chart) switches the controller to the tenant registry. The chart then:
- creates one
VmafxTenantper entry in the release namespace, filling an entry's missingoidcfields from the globalauth.issuer,auth.jwksEndpoint,auth.audience,auth.tenantClaimandauth.rolesClaim(an entry with neither its own nor a global issuer or JWKS endpoint fails the render); - passes
VMAFX_AUTH_TENANTS_SOURCE=kubernetesandVMAFX_AUTH_TENANTS_NAMESPACE=<release namespace>, and none of the global provider variables; - grants the controller's own service account (
<serviceAccount name>-controller)get,listandwatchonvmafxtenantsin the release namespace (Role and RoleBinding<release>-tenant-reader); - with
networkPolicy.enabled, allows the controller's egress to the API server (networkPolicy.allow.serverToApiserver, policy<release>-allow-controller-to-apiserver, ports 443 and 6443). The rule allows those ports to any address, as the operator's rule does, because the API server's Service IP cannot be selected. The controller's JWKS fetches have their own rule while auth is on (networkPolicy.allow.controllerToIdentityProvider, port 443). Narrow both to your control plane's and identity providers' CIDRs where you know them.
The Role is bound to the controller's own service account and to no other account (ADR-1592): the chart creates <name>-controller (from serviceAccount.name, default the chart's full name, as the operator's <name>-operator) whenever controller.enabled, and only the controller pods use it. The server, job and node pods keep the chart's shared account, which holds no RBAC, and the operator's ClusterRole grants nothing on vmafxtenants. So only the controller can read the namespace's VmafxTenant resources (identity provider URLs, audiences and role lists).
The render also fails for auth.tenants or auth.tenantSource without auth.enabled, for auth.disabled combined with a tenant registry, for any env.VMAFX_AUTH_* / env.VMAFX_JWKS_* or controller.env.VMAFX_AUTH_* / VMAFX_JWKS_* / VMAFX_SCORING_ROOTS entry (set those through auth.*), and for an entry with an empty rbac.allowedRoles (the CRD would default it to reader and writer).
Nodes and the operator present tokens of their own to the controller: node.controllerToken and operator.controllerToken name a Secret whose key is mounted as VMAFX_CONTROLLER_TOKEN_FILE (Kubernetes guide).
VmafxTenant CRD¶
Each tenant is a Kubernetes custom resource; the controller reads them as described in Tenant registry:
apiVersion: vmafx.dev/v1
kind: VmafxTenant
metadata:
name: acme
spec:
tenantId: acme
enabled: true
oidc:
issuer: https://acme.auth0.com/
jwksEndpoint: https://acme.auth0.com/.well-known/jwks.json
audience: vmafx-api
tenantClaim: org_id
rolesClaim: vmafx_roles
rbac:
defaultRole: vmafx:reader
allowedRoles: [vmafx:reader, vmafx:writer]
The Helm auth.tenants list and a kubectl apply-ed VmafxTenant are two ways to create the same resource, and the controller reads both. The CRD is installed by the chart's crds/ directory. The vmafx-operator does not reconcile VmafxTenant; the controller reads the resources directly.
Disabling auth¶
For internal deployments or integration-test pipelines:
When disabled, all requests are processed as tenant dev with the roles vmafx:admin and vmafx:node, so a local node can register. Never use this in production.
Environment variables¶
The controller has no CLI flags beyond --version; all configuration is environment-only (ADR-1119).
The auth variables (VMAFX_AUTH_*, VMAFX_JWKS_ENDPOINT, VMAFX_SCORING_ROOTS) are rows of the controller's generated environment table, with their keys, defaults and the chart values that set them; the sections above explain how they combine.
Key rotation¶
When the controller receives a token whose kid (key ID) is not in the local JWKS cache, it fetches the JWKS endpoint once. To prevent thundering- herd on rotation, fetches (successful or not) are rate-limited to one per 30 seconds per endpoint.
If the new key is not present in the endpoint's response within the cooldown window, requests with the new kid are rejected with 401 until the cache refreshes successfully.
Fetched keys are used for 15 minutes; the first token after that refetches the JWKS, so a key the identity provider withdraws stops verifying tokens within 15 minutes even if nobody presents a new kid. When the refetch fails, the cached keys keep working for up to 24 hours after their last successful fetch; past that, tokens are refused until the endpoint answers again. A JWKS fetch that starts over https does not follow a redirect to plain http (except to a loopback host).
Threat model summary¶
| Threat | Mitigation |
|---|---|
Algorithm confusion (alg=none, alg=HS256) | Only RS256 is accepted; any other alg header is rejected before key lookup. |
| Token replay | exp checked on every request. |
| Cross-tenant data access | Every job read and write is scoped to the token's tenant (GetJob, CancelJob, StreamJobs, SubmitJob); node sessions belong to one tenant, nodes pull only that tenant's jobs and report only jobs assigned to them. Refusals do not name the owning tenant. |
| JWKS endpoint spoofing | Endpoint configured by operator via trusted Helm/env values. |
| Privilege escalation | Every gRPC method and HTTP POST /v1/score require a role from the token; a gRPC method without a role entry is refused. With a tenant registry, roles outside a tenant's allowedRoles are dropped. |
| One tenant's provider acting for another | With a tenant registry, a token is verified with the provider of the tenant it names, and that tenant's claim must carry its own ID. |
| Suspension not applied | enabled: false refuses the tenant within one refresh interval; a tenant set the controller cannot refresh for ten intervals refuses everyone. |
| Revocation | Use short-lived tokens (≤1 hour); revocation list support is a follow-up. |