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.
Overview
Section titled “Overview”Access tags are the core primitive for model gating:
- Users carry a set of access tags (e.g.
["pro", "experimental"]) stored in theirUserRecord - Models declare which tags are required to access them via
required_accesson 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_accessare 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.
How It Works
Section titled “How It Works”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/completionsBody: { "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...Tag Intersection Logic
Section titled “Tag Intersection Logic”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_access | User access_tags | Result |
|---|---|---|
["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 |
Admin Bypass
Section titled “Admin Bypass”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.
Fail-Closed Enforcement
Section titled “Fail-Closed Enforcement”Both gates fail-closed by design:
- Catalog lookup error →
500(request never proceeds) - User store lookup error →
500(request never proceeds) - User has no matching tags →
403 access_denied - Tenant ID mismatch →
403 tenant_access_denied
Candela never silently passes a request through when a gate component fails.
Configuration
Section titled “Configuration”Catalog Entries — required_access
Section titled “Catalog Entries — required_access”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:
# Restrict a model to users with "pro" or "enterprise" tagsbuf 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" }'Users — access_tags
Section titled “Users — access_tags”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:
# Grant a user "pro" and "experimental" accessbuf curl --protocol connect \ https://candela.example.com/candela.v1.UserService/UpdateUser \ -d '{ "user": { "user_id": "alice@example.com", "access_tags": ["pro", "experimental"] }, "updateMask": "accessTags" }'Tenant Isolation
Section titled “Tenant Isolation”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.
# Restrict a model to the "acme" tenant onlybuf 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_tenants | Request X-Candela-Tenant-Id | Result |
|---|---|---|
["acme"] | acme | ✅ Allowed |
["acme"] | other | ❌ 403 tenant_access_denied |
["acme", "globex"] | globex | ✅ Allowed |
[] (empty) | anything | ✅ Allowed — no restriction |
Service Accounts
Section titled “Service Accounts”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:
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"] } }'Error Responses
Section titled “Error Responses”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" }}Returned when the request’s tenant ID doesn’t match the model’s allowed_tenants:
{ "error": { "message": "model restricted to tenants: [acme]; request tenant: other", "type": "tenant_access_denied", "code": "403" }}Both error types include the exact tags or tenants involved, making it straightforward to diagnose access issues.
Tag Conventions
Section titled “Tag Conventions”Access tags are arbitrary strings — you define the naming scheme that fits your organization. Here are recommended conventions:
| Tag Pattern | Purpose | Examples |
|---|---|---|
| Tier tags | Control access by subscription level | basic, pro, enterprise |
| Preview tags | Gate unstable or experimental models | experimental, beta, preview |
| Geographic tags | Enforce data residency requirements | geo:us, geo:eu, geo:apac |
| Team tags | Restrict models to specific teams | team:research, team:platform |
Examples
Section titled “Examples”Tiered model access
Section titled “Tiered model access”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 accessExperimental model preview
Section titled “Experimental model preview”Gate a preview model so only opted-in users can access it:
# Tag the model as experimentalbuf 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 previewbuf curl --protocol connect \ https://candela.example.com/candela.v1.UserService/UpdateUser \ -d '{ "user": { "user_id": "alice@example.com", "access_tags": ["pro", "experimental"] }, "updateMask": "accessTags" }'Tenant-scoped model with access tags
Section titled “Tenant-scoped model with access tags”Combine both gates — restrict a fine-tuned model to a specific tenant and require a tag:
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.