How to Configure Token Quotas and Model Access Policies for MaaS on OpenShift AI
A beginner's guide to Red Hat OpenShift AI's MaaS governance: how to configure a MaaSSubscription for token quotas and a MaaSAuthPolicy for model access control, step by step.
Getting a large language model running on a cluster is only the first half of turning it into a shared service. The second half is governance: deciding who is allowed to call the model at all, and how much of it they can use before one noisy team burns through the capacity everyone else needs. Red Hat OpenShift AI’s Models-as-a-Service (MaaS) capability handles that second half with two purpose-built Kubernetes custom resources, MaaSSubscription for token quotas and MaaSAuthPolicy for access control. This tutorial teaches you how both work and how to configure them yourself, field by field, on a cluster that already has MaaS installed.
Table Of Content
- Prerequisites
- Understanding the two gates: subscriptions and policies
- Step 1: Confirm your MaaS installation and find a model to govern
- Step 2: Create a MaaSSubscription to set a token quota
- What the fields mean
- Write the manifest
- Apply it and check the result
- Step 3: Create a MaaSAuthPolicy to control access
- What the fields mean
- Write the manifest
- Apply it and check the result
- Step 4: Understand what happens at request time
- Verify everything end to end
- What’s next
If you already followed our earlier MaaS API Keys guide, you have the other piece of this picture: how a user or service gets a credential in the first place. This guide picks up right after that, and covers what happens once someone actually presents that credential at the gateway: how much are they allowed to use, and which models can they reach.
Every field, type, and validation rule shown below comes directly from the opendatahub-io/models-as-a-service project on GitHub, the open source upstream that Red Hat OpenShift AI ships this feature from, cross-checked against Red Hat’s own MaaS governance announcement and the Kuadrant project’s own documentation for the policy engine MaaS builds on. Where this guide describes cluster behavior rather than quoting the source directly, that is called out explicitly.
Prerequisites
- A Red Hat OpenShift cluster on version 4.19.9 or later, the minimum the MaaS project has tested against. Gateway API, the traffic-routing layer MaaS depends on, reached general availability in the OpenShift 4.19 release line.
- Red Hat OpenShift AI 3.3 self-managed (or the upstream Open Data Hub operator) installed, with the
aigatewayandmodelsAsAServicecomponents set toManagedin your cluster’sDataScienceClusterresource. If MaaS itself is not installed yet, the project’s own MaaS setup guide walks through the gateway, database, and controller installation this tutorial assumes is already done. - At least one model already deployed and registered with MaaS as a
MaaSModelRef, showingstatus.phase: Ready. Step 1 below shows you how to check this; if nothing is Ready yet, deploy one of the project’s sample models first. - The
ocCLI (or plainkubectl, since these are ordinary Kubernetes custom resources) installed and logged in with permission to create resources in the namespace where your models live. - Comfort reading and applying basic Kubernetes YAML. No prior knowledge of MaaS, Kuadrant, or Gateway API is assumed; every term is explained the first time it comes up.
Understanding the two gates: subscriptions and policies
A Custom Resource Definition (CRD) is how Kubernetes lets a project add its own object types alongside built-ins like Pod or Service, so you manage them the exact same way, with YAML files and oc apply. MaaS adds several, but two are the governance layer this guide focuses on:
MaaSSubscriptionis the quota gate. It says how many tokens a given owner (one or more OpenShift users or groups) can consume against specific models within a time window.MaaSAuthPolicyis the access gate. It says which models a given set of subjects (again, users or groups) are allowed to reach at all, independent of how much quota they have.
Red Hat’s own announcement of this feature puts the relationship between the two plainly: “Both must pass before the request reaches the inference server.” They are deliberately separate resources rather than one combined object, because that lets them evolve independently. One team can share a generous token quota while still being restricted to a narrower set of models than another team with a smaller quota; changing who is allowed to reach a model does not require touching that team’s rate limits at all, and vice versa.
Neither resource enforces anything itself. Both are handled by the maas-controller, which generates and manages underlying Kuadrant policies at the Gateway API layer: a MaaSAuthPolicy compiles down to a Kuadrant AuthPolicy enforced by its Authorino component, and a MaaSSubscription‘s token limits feed Kuadrant’s Limitador rate-limiting component. You will see both of those names again in Step 4, when we look at what actually happens to a request that fails one of these checks.
Step 1: Confirm your MaaS installation and find a model to govern
You cannot sensibly set a quota or an access policy for a model that either does not exist yet or has not finished starting up. This step confirms both the current state and the exact name and namespace values you will reference in the next two steps, since those have to match exactly.
# Context: OpenShift AI 3.3 with MaaS installed, oc logged in, models deployed in the "llm" namespace (the default used by the project's own sample deployments).
# Purpose: list every registered model and its readiness in one line each.
oc get maasmodelref -n llm -o jsonpath='{range .items[*]}{.metadata.name}: phase={.status.phase} endpoint={.status.endpoint}{"\n"}{end}'
Expected output is one line per model, for example:
llama-3-8b-instruct: phase=Ready endpoint=https://llama-3-8b-instruct.apps.example.com
Explanation: the jsonpath expression loops over every MaaSModelRef object in the namespace and prints just its name alongside two status fields: phase, the model’s current lifecycle state, and endpoint, the URL the gateway resolved for it. This is the same status shape the upstream install docs use to confirm a model is ready to serve traffic.
Common mistake: if phase shows anything other than Ready (commonly Pending while the underlying inference service is still starting), stop here and resolve that first. A subscription or auth policy that references a model before it is Ready will be accepted by the API server, since nothing here checks for that at creation time, but it will not do anything useful until the model itself catches up. Note the exact name and the namespace you queried (llm in the example above); you will reference both by name in the next two steps, and a typo here is silent, since Kubernetes will happily let you create a subscription or policy that points at a model name that does not exist.
Step 2: Create a MaaSSubscription to set a token quota
What the fields mean
A MaaSSubscription‘s spec has four parts, taken directly from the project’s Go type definitions:
owner: who this subscription belongs to, as a list ofusers(Kubernetes/OpenShift user names) and/orgroups.modelRefs: which models this subscription covers, each with its ownname,namespace, and one or moretokenRateLimitsentries.tokenMetadata(optional): anorganizationId,costCenter, and free-formlabelsyou can attach for billing and usage attribution later.priority: an integer, defaulting to 0, used to decide which subscription wins when a single user matches more than one. Higher numbers win.
Each entry in tokenRateLimits has exactly two fields, and both carry real validation rules enforced by the Kubernetes API server itself, not just documented conventions: limit must be an integer between 1 and 1,000,000,000, and window must match the pattern ^[1-9]\d{0,3}(s|m|h)$, meaning a number from 1 to 9999 immediately followed by s, m, or h. There is deliberately no d for days; express a daily limit as 24h instead.
Write the manifest
# team-alpha-quota.yaml
# Grants users alice and bob a shared 500,000-token budget per 24 hours
# against the llama-3-8b-instruct model confirmed Ready in Step 1.
apiVersion: maas.opendatahub.io/v1alpha1
kind: MaaSSubscription
metadata:
name: team-alpha-quota
namespace: llm
spec:
owner:
users:
- alice
- bob
modelRefs:
- name: llama-3-8b-instruct
namespace: llm
tokenRateLimits:
- limit: 500000
window: 24h
tokenMetadata:
organizationId: team-alpha
costCenter: cc-4471
priority: 10
Adjust metadata.namespace, the users list, and modelRefs[].name/namespace to match your own cluster and the model you confirmed in Step 1. The tokenMetadata block is optional; include it if you plan to feed usage back into a billing or cost-attribution system later, since those two fields exist specifically for that.
Apply it and check the result
# Context: manifest saved as team-alpha-quota.yaml.
# Purpose: create the subscription and confirm the API server accepted it.
oc apply -f team-alpha-quota.yaml
Expected output: maassubscription.maas.opendatahub.io/team-alpha-quota created.
# Context: right after creation.
# Purpose: check the subscription's own reconciliation status.
oc get maassubscription team-alpha-quota -n llm -o jsonpath='{.status.phase}{"\n"}'
Explanation: the MaaSSubscription‘s status also tracks modelRefStatuses and tokenRateLimitStatuses, reporting whether each referenced model and each generated rate limit actually reconciled. If phase stays anything other than Ready, inspect the full object with oc get maassubscription team-alpha-quota -n llm -o yaml and check those two status fields for which specific reference failed, most commonly a modelRefs[].name or namespace that does not match an existing MaaSModelRef.
Common mistakes: writing window: 24d instead of 24h will be rejected outright by the API server before the object is even stored, since the pattern is enforced at the OpenAPI schema level, not by the controller after the fact; expect an error citing the field path and the regular expression it failed. The other frequent mistake is pointing modelRefs[].namespace at the subscription’s own namespace out of habit, when the model actually lives somewhere else; the two namespaces do not have to match, and the API server will not warn you if they do not, since a cross-namespace reference is a normal, supported case.
Step 3: Create a MaaSAuthPolicy to control access
What the fields mean
A MaaSAuthPolicy‘s spec, from the same source file, has three parts:
modelRefs: the same shape as before, but here it just names which models (bynameandnamespace) this policy grants access to. At least one is required.subjects: who gets that access, asgroupsand/orusers. A cluster-enforced validation rule requires at least one of the two to be non-empty; you cannot create a policy that grants access to nobody.meteringMetadata(optional): the same kind of billing and tracking fields as a subscription’stokenMetadata, kept as a separate type since access policies and quotas are metered independently.
Write the manifest
# team-alpha-access.yaml
# Grants users alice and bob access to llama-3-8b-instruct.
# Access and quota are independent: this does not set how much they can use, only that they may reach it at all.
apiVersion: maas.opendatahub.io/v1alpha1
kind: MaaSAuthPolicy
metadata:
name: team-alpha-access
namespace: llm
spec:
modelRefs:
- name: llama-3-8b-instruct
namespace: llm
subjects:
users:
- alice
- bob
meteringMetadata:
organizationId: team-alpha
costCenter: cc-4471
Apply it and check the result
# Context: manifest saved as team-alpha-access.yaml.
# Purpose: create the policy and inspect what it generated underneath.
oc apply -f team-alpha-access.yaml
oc get maasauthpolicy team-alpha-access -n llm -o yaml
Explanation: look at the object’s status block for three things: phase (should reach Ready), conditions (the usual Kubernetes condition list showing the reconciliation history), and authPolicies, a field unique to this resource that lists the underlying Kuadrant AuthPolicy objects the controller actually created to enforce this. That last field is your first stop for troubleshooting, since it tells you the real Kuadrant object name to inspect if access still is not behaving the way you expect.
Common mistakes: leaving both subjects.groups and subjects.users empty does not create a policy that denies everyone, it fails validation and is rejected immediately, since the CRD’s validation rule requires size(self.groups) > 0 || size(self.users) > 0. If you intend to deny access by default, do that by simply not creating a policy for those subjects at all, rather than trying to create an empty one. The second common mistake is assuming a MaaSAuthPolicy alone is enough; it is not, since it only answers “can this subject reach this model,” never “how much.” Without a matching MaaSSubscription, a user with access but no subscription has nothing defining their quota.
Step 4: Understand what happens at request time
With both resources in place, a request arriving at the MaaS gateway for a governed model is checked twice, in order, before it ever reaches the model itself:
- Authorino evaluates the compiled
MaaSAuthPolicyfirst. If the caller’s identity is not covered by any policy’ssubjectsfor the model being requested, Kuadrant’s documented behavior is to return HTTP 403 Forbidden, and the request never reaches Limitador or the model. - Only if that check passes does Limitador evaluate the token rate limit derived from the matching
MaaSSubscription. If the caller has already exhausted theirtokenRateLimitsbudget for the current window, Kuadrant’s documented behavior is to return HTTP 429 Too Many Requests.
Both of those status codes are Kuadrant’s own documented behavior for AuthPolicy and RateLimitPolicy enforcement respectively, not something specific to MaaS. That is by design: MaaS is a thin, purpose-built layer that generates standard Kuadrant policies from a simpler, AI-specific schema, rather than reimplementing gateway enforcement itself. If you are debugging an unexpected 403 or 429, the underlying Kuadrant objects referenced in each resource’s status (shown in Step 3) are where to look next.
Verify everything end to end
Before considering this done, check all three resources together, since a working setup means all three agree:
# Context: MaaSSubscription and MaaSAuthPolicy both applied.
# Purpose: confirm the model, the quota, and the access policy are all Ready in one pass.
oc get maasmodelref llama-3-8b-instruct -n llm -o jsonpath='{.status.phase}{"\n"}'
oc get maassubscription team-alpha-quota -n llm -o jsonpath='{.status.phase}{"\n"}'
oc get maasauthpolicy team-alpha-access -n llm -o jsonpath='{.status.phase}{"\n"}'
Expected output: Ready three times. If any one of the three is not, that resource’s own status.conditions (visible with -o yaml in place of the jsonpath query) will name the specific reason, whether that is a missing model reference, a rejected field, or something still reconciling. Only once all three agree should you expect a real request from alice or bob to reach llama-3-8b-instruct, and only up to the 500,000-token, 24-hour budget the subscription defined.
What’s next
From here, a few natural directions to keep exploring: the project’s multi-tenant setup guide covers isolating separate business units with their own gateways and AITenant resources instead of sharing one; our earlier MaaS API Keys guide covers the credential side these policies check against; and Kuadrant’s own rate-limiting documentation is worth a read if you want to understand the policy engine underneath MaaS well enough to debug it directly, rather than only through the simplified MaaSSubscription and MaaSAuthPolicy layer this guide taught.








No Comment! Be the first one.