Skip to content

Model Access Control

Model access control lets you restrict which LLM models each user or tenant can access. It works through access tags — lightweight string labels assigned to users and required by models. Requests from users who lack the required tags are rejected with 403 Forbidden before they ever reach the LLM provider.

Access tags are the core primitive for model gating:

  • Users carry a set of access tags (e.g. ["pro", "experimental"]) stored in their UserRecord
  • Models declare which tags are required to access them via required_access on the catalog entry
  • At request time, Candela checks for an any-match intersection — if the user has any of the required tags, the request proceeds
  • Models with no required_access are open to everyone

There’s also a tenant isolation gate — models can be restricted to specific tenant IDs via allowed_tenants, enforced independently of access tags.

Both gates are fail-closed: catalog lookup errors, user store errors, and missing data always result in a 500 or 403 — never silent pass-through.

The access tag gate is evaluated as a pre-flight check in the governance pipeline, after the request body is parsed but before the budget gate:

Request: POST /proxy/openai/v1/chat/completions
Body: { "model": "gpt-4o", ... }
┌─────────────────┐
│ Catalog Lookup │ Look up ModelCatalogEntry
└────────┬────────┘
┌─────────────────┐
│ Tenant Gate │──── allowed_tenants mismatch? ──▶ 403 tenant_access_denied
└────────┬────────┘
│ ✅
┌─────────────────┐
│ Access Tag │──── No matching tags? ──────────▶ 403 access_denied
│ Gate │──── Admin user? ────────────────▶ ✅ Bypass
└────────┬────────┘
│ ✅
Continue to budget gate...

The access check uses any-match intersection: the user must have at least one tag that appears in the model’s required_access list.

Model required_accessUser access_tagsResult
["pro"]["pro", "basic"]✅ Allowed — pro matches
["pro", "enterprise"]["basic"]❌ Denied — no overlap
["experimental"]["pro", "experimental"]✅ Allowed — experimental matches
[] (empty)[] (empty)✅ Allowed — no restriction
[] (empty)["pro"]✅ Allowed — no restriction

Users with the admin role bypass all access tag checks. Admins can always access every model, regardless of required_access or allowed_tenants on the catalog entry.

Both gates fail-closed by design:

  • Catalog lookup error500 (request never proceeds)
  • User store lookup error500 (request never proceeds)
  • User has no matching tags403 access_denied
  • Tenant ID mismatch403 tenant_access_denied

Candela never silently passes a request through when a gate component fails.

Set required_access on a ModelCatalogEntry to restrict which users can access the model. This is a repeated string field on the proto:

message ModelCatalogEntry {
// ...existing fields...
repeated string required_access = 15; // tags required to access this model
repeated string allowed_tenants = 12; // tenant IDs allowed to access this model
}

Via the admin API:

Terminal window
# Restrict a model to users with "pro" or "enterprise" tags
buf curl --protocol connect \
https://candela.example.com/candela.v1.ModelCatalogService/UpdateModelCatalogEntry \
-d '{
"entry": {
"provider": "openai",
"model_id": "o1-pro",
"required_access": ["pro", "enterprise"]
},
"updateMask": "requiredAccess"
}'

Set access_tags on a User record to grant model access. This is a repeated string field on the proto:

message User {
// ...existing fields...
repeated string access_tags = 10; // e.g. ["pro", "experimental"]
}

Via the admin API:

Terminal window
# Grant a user "pro" and "experimental" access
buf curl --protocol connect \
https://candela.example.com/candela.v1.UserService/UpdateUser \
-d '{
"user": {
"user_id": "alice@example.com",
"access_tags": ["pro", "experimental"]
},
"updateMask": "accessTags"
}'

The allowed_tenants field on a catalog entry restricts which tenants can access a model. This gate is evaluated independently of access tags and uses the X-Candela-Tenant-Id request header.

Terminal window
# Restrict a model to the "acme" tenant only
buf curl --protocol connect \
https://candela.example.com/candela.v1.ModelCatalogService/UpdateModelCatalogEntry \
-d '{
"entry": {
"provider": "google",
"model_id": "gemini-2.5-pro",
"allowed_tenants": ["acme", "globex"]
},
"updateMask": "allowedTenants"
}'
Model allowed_tenantsRequest X-Candela-Tenant-IdResult
["acme"]acme✅ Allowed
["acme"]other403 tenant_access_denied
["acme", "globex"]globex✅ Allowed
[] (empty)anything✅ Allowed — no restriction

Service accounts (SAs) are treated as individual users — they are not granted any special privileges for access tag gating. The proxy looks up the SA’s UserRecord in the user store and checks its access_tags like any other user.

To grant a service account access to restricted models, create a UserRecord for it with the appropriate access_tags:

Terminal window
buf curl --protocol connect \
https://candela.example.com/candela.v1.UserService/CreateUser \
-d '{
"user": {
"user_id": "pipeline-sa@your-project.iam.gserviceaccount.com",
"role": "DEVELOPER",
"access_tags": ["pro"]
}
}'

When access is denied, the proxy returns a 403 with a structured error body:

Returned when the user lacks the required access tags for a model:

{
"error": {
"message": "model requires access tags: [pro]; user has: []",
"type": "access_denied",
"code": "403"
}
}

Both error types include the exact tags or tenants involved, making it straightforward to diagnose access issues.

Access tags are arbitrary strings — you define the naming scheme that fits your organization. Here are recommended conventions:

Tag PatternPurposeExamples
Tier tagsControl access by subscription levelbasic, pro, enterprise
Preview tagsGate unstable or experimental modelsexperimental, beta, preview
Geographic tagsEnforce data residency requirementsgeo:us, geo:eu, geo:apac
Team tagsRestrict models to specific teamsteam:research, team:platform

Offer different model tiers based on user subscription level:

# Catalog entries (conceptual)
- model_id: "gpt-4o-mini"
required_access: [] # open to everyone
- model_id: "gpt-4o"
required_access: ["pro"] # pro users and above
- model_id: "o1-pro"
required_access: ["enterprise"] # enterprise only
# User records
- user_id: "free-user@example.com"
access_tags: ["basic"] # can only use gpt-4o-mini
- user_id: "paid-user@example.com"
access_tags: ["basic", "pro"] # can use gpt-4o-mini and gpt-4o
- user_id: "vip@example.com"
access_tags: ["basic", "pro", "enterprise"] # full access

Gate a preview model so only opted-in users can access it:

Terminal window
# Tag the model as experimental
buf curl --protocol connect \
https://candela.example.com/candela.v1.ModelCatalogService/UpdateModelCatalogEntry \
-d '{
"entry": {
"provider": "google",
"model_id": "gemini-3.0-flash-preview",
"required_access": ["experimental"]
},
"updateMask": "requiredAccess"
}'
# Opt a user into the preview
buf curl --protocol connect \
https://candela.example.com/candela.v1.UserService/UpdateUser \
-d '{
"user": {
"user_id": "alice@example.com",
"access_tags": ["pro", "experimental"]
},
"updateMask": "accessTags"
}'

Combine both gates — restrict a fine-tuned model to a specific tenant and require a tag:

Terminal window
buf curl --protocol connect \
https://candela.example.com/candela.v1.ModelCatalogService/UpdateModelCatalogEntry \
-d '{
"entry": {
"provider": "openai",
"model_id": "ft:gpt-4o:acme-medical:2026-06",
"allowed_tenants": ["acme"],
"required_access": ["enterprise"]
},
"updateMask": "allowedTenants,requiredAccess"
}'

This model is only accessible to users in the acme tenant who also have the enterprise access tag. Admin users bypass both checks.