Skip to content

Security & Authentication

Candela uses a multi-strategy authentication system designed to serve three distinct client types: browser users, developer CLI tools, and service accounts.

flowchart TD
REQ[Incoming Request] --> SKIP{Path = /healthz?}
SKIP -->|yes| PASS[Pass through]
SKIP -->|no| DEV{Dev Mode?}
DEV -->|yes| SYNTH["Inject synthetic admin<br/>(admin@localhost)"]
DEV -->|no| XCA{Has X-Candela-Auth?}
XCA -->|yes| S3X["Strategy 3:<br/>OAuth2 Access Token<br/>(from X-Candela-Auth)"]
S3X -->|valid| AUTH[Authenticated ]
S3X -->|invalid| DENY["401 Unauthorized"]
XCA -->|no| TOK{Has Bearer Token?}
TOK -->|no| DENY
TOK -->|yes| S1["Strategy 1:<br/>Firebase ID Token"]
S1 -->|valid| AUTH
S1 -->|invalid| S2["Strategy 2:<br/>Google ID Token"]
S2 -->|valid| AUTH
S2 -->|invalid| S3["Strategy 3:<br/>OAuth2 Access Token"]
S3 -->|valid| AUTH
S3 -->|invalid| DENY
AUTH --> CTX["User injected into<br/>request context"]
CTX --> HANDLER[ConnectRPC Handler]

The middleware checks X-Candela-Auth first (sent by candela behind IAP). If absent, it falls back to three strategies on the Authorization header — the first successful validation wins:

# Strategy Client Token Source
X-Candela-Auth (priority) candela behind IAP User’s ADC OAuth2 access token
1 Firebase ID Token Browser UI Firebase JS SDK
2 Google ID Token Service accounts, candela CLI (no IAP) idtoken.NewTokenSource(audience)
3 OAuth2 Access Token candela with user ADC (no IAP) candela auth login (or gcloud auth application-default login)

Candela uses a deny-by-default service account policy. All service account tokens are rejected with 403 Forbidden unless explicitly allowlisted:

config.yaml
auth:
allowed_service_accounts:
- "candela-ci@my-project.iam.gserviceaccount.com"

If the allowed_service_accounts list is empty or omitted, all service accounts are blocked. This prevents unmetered cost vectors — SA traffic bypasses per-user budget deduction.


Role Description
developer Use proxy, view own traces/costs, self-service RPCs
admin Full access: manage users, budgets, view all data

Self-Service RPCs (any authenticated user):

RPC Description
GetCurrentUser Returns the caller’s own profile, budget, and active grants
GetMyBudget Returns the caller’s budget and current-period spending

Admin-Only RPCs:

Category RPCs
Users CreateUser, ListUsers, GetUser, UpdateUser, DeactivateUser, ReactivateUser
Budgets SetBudget, GetBudget, ResetSpend
Grants CreateGrant, ListGrants, RevokeGrant
Audit ListAuditLog

Non-admin developers can only see their own traces. This is enforced at two levels:

  1. Query-time filtering — All list/search endpoints inject user_id filters into storage queries. Admins see all data; developers see only their own.

  2. Post-fetch auth gateGetTrace (which queries by trace ID, not user) fetches the full trace, extracts the owner, and returns PermissionDenied on mismatch.

All storage backends (BigQuery, DuckDB, SQLite) apply the filter in SQL:

AND (? = '' OR user_id = ?)

Mode Auth Required How
Solo No All requests to :1234 and :8181 are unauthenticated
Solo + Cloud ADC only candela auth login — tokens used for upstream Vertex AI calls
Team (no IAP) OIDC candela injects a Google ID token as Authorization: Bearer
Team (IAP) Dual-token candela sends IAP OIDC + user identity via X-Candela-Auth

When candela authenticates with SA credentials (e.g., Workload Identity, SA key), it mints a single OIDC ID token:

SA Credential → idtoken.NewTokenSource(audience) → single ID token
└── Authorization: Bearer <oidc-id-token>

The server validates this via Strategy 2 (Google ID Token).

Strategy 1.5: Dual-Token for IAP (User ADC + iap_service_account)

Section titled “Strategy 1.5: Dual-Token for IAP (User ADC + iap_service_account)”

When iap_service_account is set in the config and the developer has user ADC, candela sends two tokens on every cloud-model request:

IDE → candela (:1234)
├── Local model → Ollama (no auth)
└── Cloud model → Candela Server (behind IAP)
│ Proxy-Authorization: Bearer <impersonated-sa-oidc-token>
│ X-Candela-Auth: Bearer <user-adc-oauth2-access-token>
IAP (validates Proxy-Authorization)
│ ← IAP replaces Authorization with its own JWT
Auth Middleware (reads X-Candela-Auth for user identity)

Token breakdown:

Header Value Purpose
Proxy-Authorization Impersonated SA OIDC ID token Authenticates to IAP
X-Candela-Auth User’s ADC OAuth2 access token Carries the developer’s real identity to the server

The impersonation flow:

User ADC → impersonate iap_service_account → generateIdToken(audience) → IAP OIDC token

This uses a custom iapIdTokenCreator IAM role bound to the service account, which grants only:

Permission Included
iam.serviceAccounts.getOpenIdToken
iam.serviceAccounts.getAccessToken

Without iap_service_account, candela sends the user’s ADC access token directly:

User ADC → oauth2.TokenSource → access token
└── Authorization: Bearer <user-access-token>

The server validates this via Strategy 3 (OAuth2 Access Token → userinfo endpoint).


All requests are validated server-side using protovalidate. Key constraints:

Field Constraint
IngestSpans batch Max 1,000 spans
GenAI content fields Max 1 MB
Pagination page_size [0, 1000]
All ID fields Max 128 chars

Item Status
Token validation on all non-health endpoints
Email claim normalization (lowercase)
Admin role enforcement via ConnectRPC interceptor
Per-user trace/span data isolation
Internal error message sanitization
Rate limiting per user
Budget enforcement before proxy calls
Secrets not baked into container images
ADC token auto-refresh
API key hashing (bcrypt)
Proxy does not store upstream API keys
CORS origin allowlist
Audit logging for admin actions
eBPF enforcement (Tetragon + Cilium + iptables)
Circuit breaker resilience for upstream providers
Fuzz testing for proxy SSE parser
Tetragon gRPC audit pipeline with graceful shutdown
Multi-cloud auth (GCP OAuth2 + AWS SSO)