Browse Source

DEP: CEL integration (#4601)

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
Signed-off-by: Maksim Nabokikh <max.nabokih@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
pull/4611/head
Maksim Nabokikh 3 days ago committed by GitHub
parent
commit
0568abeb03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 732
      docs/enhancements/cel-expressions-2026-02-28.md

732
docs/enhancements/cel-expressions-2026-02-28.md

@ -0,0 +1,732 @@
# Dex Enhancement Proposal (DEP) - 2026-02-28 - CEL (Common Expression Language) Integration
## Table of Contents
- [Summary](#summary)
- [Context](#context)
- [Motivation](#motivation)
- [Goals/Pain](#goalspain)
- [Non-Goals](#non-goals)
- [Proposal](#proposal)
- [User Experience](#user-experience)
- [Implementation Details/Notes/Constraints](#implementation-detailsnotesconstraints)
- [Phase 1: pkg/cel - Core CEL Library](#phase-1-pkgcel---core-cel-library)
- [Phase 2: Authentication Policies](#phase-2-authentication-policies)
- [Phase 3: Token Policies](#phase-3-token-policies)
- [Phase 4: OIDC Connector Claim Mapping](#phase-4-oidc-connector-claim-mapping)
- [Policy Application Flow](#policy-application-flow)
- [Risks and Mitigations](#risks-and-mitigations)
- [Alternatives](#alternatives)
- [Future Improvements](#future-improvements)
## Summary
This DEP proposes integrating [CEL (Common Expression Language)][cel-spec] into Dex as a first-class
expression engine for policy evaluation, claim mapping, and token customization. A new reusable
`pkg/cel` package will provide a safe, sandboxed CEL environment with Kubernetes-grade compatibility
guarantees, cost budgets, and a curated set of extension libraries. Subsequent phases will leverage
this package to implement authentication policies, token policies, advanced claim mapping in
connectors, and per-client/global access rules — replacing the need for ad-hoc configuration fields
and external policy engines.
[cel-spec]: https://github.com/google/cel-spec
## Context
- [#1583 Add allowedGroups option for clients config][#1583] — a long-standing request for a
configuration option to allow a client to specify a list of allowed groups.
- [#1635 Connector Middleware][#1635] — long-standing request for a policy/middleware layer between
connectors and the server for claim transformations and access control.
- [#1052 Allow restricting connectors per client][#1052] — frequently requested feature to restrict
which connectors are available to specific OAuth2 clients.
- [#2178 Custom claims in ID tokens][#2178] — requests for including additional payload in issued tokens.
- [#2812 Token Exchange DEP][dep-token-exchange] — mentions CEL/Rego as future improvement for
policy-based assertions on exchanged tokens.
- The OIDC connector already has a growing set of ad-hoc claim mutation options
(`ClaimMapping`, `ClaimMutations.NewGroupFromClaims`, `FilterGroupClaims`, `ModifyGroupNames`)
that would benefit from a unified expression language.
- Previous community discussions explored OPA/Rego and JMESPath, but CEL offers a better fit
(see [Alternatives](#alternatives)).
[#1583]: https://github.com/dexidp/dex/pull/1583
[#1635]: https://github.com/dexidp/dex/issues/1635
[#1052]: https://github.com/dexidp/dex/issues/1052
[#2178]: https://github.com/dexidp/dex/issues/2178
[dep-token-exchange]: /docs/enhancements/token-exchange-2023-02-03-%232812.md
## Motivation
### Goals/Pain
1. **Complex query/filter capabilities** — Dex needs a way to express complex validations and
mutations in multiple places (authentication flow, token issuance, claim mapping). Today each
feature requires new Go code, new config fields, and a new release cycle. CEL allows operators
to express these rules declaratively without code changes.
2. **Authentication policies** — Operators want to control _who_ can log in based on rich
conditions: restrict specific connectors to specific clients, require group membership for
certain clients, deny login based on email domain, enforce MFA claims, etc. Currently there is
no unified mechanism; users rely on downstream applications or external proxies.
3. **Token policies** — Operators want to customize issued tokens: add extra claims to ID tokens,
restrict scopes per client, modify `aud` claims, include upstream connector metadata, etc.
Today this requires forking Dex or using a reverse proxy.
4. **Claim mapping in OIDC connector** — The OIDC connector has accumulated multiple ad-hoc config
options for claim mapping and group mutations (`ClaimMapping`, `NewGroupFromClaims`,
`FilterGroupClaims`, `ModifyGroupNames`). A single CEL expression field would replace all of
these with a more powerful and composable approach.
5. **Per-client and global policies** — One of the most frequent requests is allowing different
connectors for different clients and restricting group-based access per client. CEL policies at
the global and per-client level address this cleanly.
6. **CNCF ecosystem alignment** — CEL has massive adoption across the CNCF ecosystem:
| Project | CEL Usage | Evidence |
|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| **Kubernetes** | ValidatingAdmissionPolicy, CRD validation rules (`x-kubernetes-validations`), AuthorizationPolicy, field selectors, CEL-based match conditions in webhooks | [KEP-3488][k8s-cel-kep], [CRD Validation Rules][k8s-crd-cel], [AuthorizationPolicy KEP-3221][k8s-authz-cel] |
| **Kyverno** | CEL expressions in validation/mutation policies (v1.12+), preconditions | [Kyverno CEL docs][kyverno-cel] |
| **OPA Gatekeeper** | Partially added support for CEL in constraint templates | [Gatekeeper CEL][gatekeeper-cel] |
| **Istio** | AuthorizationPolicy conditions, request routing, telemetry | [Istio CEL docs][istio-cel] |
| **Envoy / Envoy Gateway** | RBAC filter, ext_authz, rate limiting, route matching, access logging | [Envoy CEL docs][envoy-cel] |
| **Tekton** | Pipeline when expressions, CEL custom tasks | [Tekton CEL Interceptor][tekton-cel] |
| **Knative** | Trigger filters using CEL expressions | [Knative CEL filters][knative-cel] |
| **Google Cloud** | IAM Conditions, Cloud Deploy, Security Command Center | [Google IAM CEL][gcp-cel] |
| **Cert-Manager** | CertificateRequestPolicy approval using CEL | [cert-manager approver-policy CEL][cert-manager-cel] |
| **Cilium** | Hubble CEL filter logic | [Cilium CEL docs][cilium-cel] |
| **Crossplane** | Composition functions with CEL-based patch transforms | [Crossplane CEL transforms][crossplane-cel] |
| **Kube-OVN** | Network policy extensions using CEL | [Kube-OVN CEL][kube-ovn-cel] |
[k8s-cel-kep]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3488-cel-admission-control
[k8s-crd-cel]: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules
[k8s-authz-cel]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3221-structured-authorization-configuration
[kyverno-cel]: https://kyverno.io/docs/writing-policies/cel/
[gatekeeper-cel]: https://open-policy-agent.github.io/gatekeeper/website/docs/validating-admission-policy/#policy-updates-to-add-vap-cel
[istio-cel]: https://istio.io/latest/docs/reference/config/security/conditions/
[envoy-cel]: https://www.envoyproxy.io/docs/envoy/latest/xds/type/v3/cel.proto
[tekton-cel]: https://tekton.dev/docs/triggers/cel_expressions/
[knative-cel]: https://github.com/knative/eventing/blob/main/docs/broker/filtering.md#add-cel-expression-filter
[gcp-cel]: https://cloud.google.com/iam/docs/conditions-overview
[cert-manager-cel]: https://cert-manager.io/docs/policy/approval/approver-policy/#validations
[cilium-cel]: https://docs.cilium.io/en/stable/_api/v1/flow/README/#flowfilter-experimental
[crossplane-cel]: https://github.com/crossplane-contrib/function-cel-filter
[kube-ovn-cel]: https://kubeovn.github.io/docs/stable/en/advance/cel-expression/
By choosing CEL, Dex operators who already use Kubernetes or other CNCF tools can reuse their
existing knowledge of the expression language.
### Non-Goals
- **Full policy engine** — This DEP does not aim to replace dedicated external policy engines
(OPA, Kyverno). CEL in Dex is scoped to identity and token operations.
- **Breaking changes to existing configuration** — All existing config fields (`ClaimMapping`,
`ClaimMutations`, etc.) will continue to work. CEL expressions are additive/opt-in.
- **Authorization (beyond Dex scope)** — Dex is an identity provider; downstream authorization
decisions remain the responsibility of relying parties. CEL policies in Dex are limited to
authentication and token issuance concerns.
- **Multi-phase CEL in a single DEP** — Only Phase 1 (`pkg/cel` package) is targeted for
immediate implementation. Phases 2-4 are included here for design context and will have their
own implementation PRs.
- **Multi-step logic** — CEL in Dex is scoped to single-expression evaluation. Each expression
is a standalone, stateless computation with no intermediate variables, chaining, or
multi-step transformations. If a use case requires sequential logic or conditionally chained
expressions, it belongs outside Dex (e.g. in an external policy engine or middleware).
This boundary protects the design from scope creep that pushes CEL beyond what it's good at.
## Proposal
### User Experience
#### Authentication Policy (Phase 2)
Operators can define global and per-client authentication policies in the Dex config:
```yaml
# Global authentication policy — each expression evaluates to bool.
# If true — the request is denied. Evaluated in order; first match wins.
authPolicy:
- expression: "!identity.email.endsWith('@example.com')"
message: "'Login restricted to example.com domain'"
- expression: "!identity.email_verified"
message: "'Email must be verified'"
staticClients:
- id: admin-app
name: Admin Application
secret: ...
redirectURIs: [...]
# Per-client policy — same structure as global
authPolicy:
- expression: "!(request.connector_id in ['okta', 'ldap'])"
message: "'This application requires Okta or LDAP login'"
- expression: "!('admin' in identity.groups)"
message: "'Admin group membership required'"
```
#### Token Policy (Phase 3)
Operators can add extra claims or mutate token contents:
```yaml
tokenPolicy:
# Global mutations applied to all ID tokens
claims:
# Add a custom claim based on group membership
- key: "'role'"
value: "identity.groups.exists(g, g == 'admin') ? 'admin' : 'user'"
# Include connector ID as a claim
- key: "'idp'"
value: "request.connector_id"
# Add department from upstream claims (only if present)
- key: "'department'"
value: "identity.extra['department']"
condition: "'department' in identity.extra"
staticClients:
- id: internal-api
name: Internal API
secret: ...
redirectURIs: [...]
tokenPolicy:
claims:
- key: "'custom-claim.company.com/team'"
value: "identity.extra['team'].orValue('engineering')"
# Only add on-call claim for ops group members
- key: "'on_call'"
value: "true"
condition: "identity.groups.exists(g, g == 'ops')"
# Restrict scopes
filter:
expression: "request.scopes.all(s, s in ['openid', 'email', 'profile'])"
message: "'Unsupported scope requested'"
```
#### OIDC Connector Claim Mapping (Phase 4)
Replace ad-hoc claim mapping with CEL:
```yaml
connectors:
- type: oidc
id: corporate-idp
name: Corporate IdP
config:
issuer: https://idp.example.com
clientID: dex-client
clientSecret: ...
# CEL-based claim mapping — replaces claimMapping and claimModifications
claimMappingExpressions:
username: "claims.preferred_username.orValue(claims.email)"
email: "claims.email"
groups: >
claims.groups
.filter(g, g.startsWith('dex:'))
.map(g, g.trimPrefix('dex:'))
emailVerified: "claims.email_verified.orValue(true)"
# Extra claims to pass through to token policies
extra:
department: "claims.department.orValue('unknown')"
cost_center: "claims.cost_center.orValue('')"
```
### Implementation Details/Notes/Constraints
### Phase 1: `pkg/cel` — Core CEL Library
This is the foundation that all subsequent phases build upon. The package provides a safe,
reusable CEL environment with Kubernetes-grade guarantees.
#### Package Structure
```
pkg/
cel/
cel.go # Core Environment, compilation, evaluation
types.go # CEL type declarations (Identity, Request, etc.)
cost.go # Cost estimation and budgeting
doc.go # Package documentation
library/
email.go # Email-related CEL functions
groups.go # Group-related CEL functions
```
#### Dependencies
```
github.com/google/cel-go v0.27.0
```
The `cel-go` library is the canonical Go implementation maintained by Google, used by Kubernetes
and all major CNCF projects. It follows semantic versioning and provides strong backward
compatibility guarantees.
#### Core API Design
**Public types:**
```go
// CompilationResult holds a compiled CEL program ready for evaluation.
type CompilationResult struct {
Program cel.Program
OutputType *cel.Type
Expression string
}
// Compiler compiles CEL expressions against a specific environment.
type Compiler struct { /* ... */ }
// CompilerOption configures a Compiler.
type CompilerOption func(*compilerConfig)
```
**Compilation pipeline:**
Each `Compile*` call performs these steps sequentially:
1. Reject expressions exceeding `MaxExpressionLength` (10,240 chars).
2. Compile and type-check the expression via `cel-go`.
3. Validate output type matches the expected type (for typed variants).
4. Estimate cost using `defaultCostEstimator` with size hints — reject if estimated max cost
exceeds the cost budget.
5. Create an optimized `cel.Program` with runtime cost limit.
Presence tests (`has(field)`, `'key' in map`) have zero cost, matching Kubernetes CEL behavior.
#### Variable Declarations
Variables are declared via `VariableDeclaration{Name, Type}` and registered with `NewCompiler`.
Helper constructors provide pre-defined variable sets:
**`IdentityVariables()`** — the `identity` variable (from `connector.Identity`),
typed as `cel.ObjectType`:
| Field | CEL Type | Source |
|-------|----------|--------|
| `identity.user_id` | `string` | `connector.Identity.UserID` |
| `identity.username` | `string` | `connector.Identity.Username` |
| `identity.preferred_username` | `string` | `connector.Identity.PreferredUsername` |
| `identity.email` | `string` | `connector.Identity.Email` |
| `identity.email_verified` | `bool` | `connector.Identity.EmailVerified` |
| `identity.groups` | `list(string)` | `connector.Identity.Groups` |
**`RequestVariables()`** — the `request` variable (from `RequestContext`),
typed as `cel.ObjectType`:
| Field | CEL Type |
|-------|----------|
| `request.client_id` | `string` |
| `request.connector_id` | `string` |
| `request.scopes` | `list(string)` |
| `request.redirect_uri` | `string` |
**`ClaimsVariable()`** — the `claims` variable for raw upstream claims as `map(string, dyn)`.
**Typing strategy:**
`identity` and `request` use `cel.ObjectType` with explicitly declared fields. This gives
compile-time type checking: a typo like `identity.emial` is rejected at config load time
rather than silently evaluating to null in production — critical for an auth system where a
misconfigured policy could lock users out.
`claims` remains `map(string, dyn)` because its shape is genuinely unknown — it carries
arbitrary upstream IdP data.
#### Compatibility Guarantees
Following the Kubernetes CEL compatibility model
([KEP-3488: CEL for Admission Control][kep-3488], [Kubernetes CEL Migration Guide][k8s-cel-compat]):
1. **Environment versioning** — The CEL environment is versioned. When new functions or variables
are added, they are introduced under a new environment version. Existing expressions compiled
against an older version continue to work.
```go
// EnvironmentVersion represents the version of the CEL environment.
// New variables, functions, or libraries are introduced in new versions.
type EnvironmentVersion uint32
const (
// EnvironmentV1 is the initial CEL environment.
EnvironmentV1 EnvironmentVersion = 1
)
// WithVersion sets the target environment version for the compiler.
func WithVersion(v EnvironmentVersion) CompilerOption
```
This is directly modeled on `k8s.io/apiserver/pkg/cel/environment`.
2. **Library stability** — Custom functions in the `pkg/cel/library` subpackage follow these rules:
- Functions MUST NOT be removed once released.
- Function signatures MUST NOT change once released.
- New functions MUST be added under a new `EnvironmentVersion`.
- If a function needs to be replaced, the old one is deprecated but kept forever.
3. **Type stability** — CEL types (`Identity`, `Request`, `Claims`) follow the same rules:
- Fields MUST NOT be removed.
- Field types MUST NOT change.
- New fields are added in a new `EnvironmentVersion`.
4. **Semantic versioning of `cel-go`** — The `cel-go` dependency follows semver. Dex pins to a
minor version range and updates are tested for behavioral changes. This is exactly the approach
Kubernetes takes: `k8s.io/apiextensions-apiserver` pins `cel-go` and gates new features behind
environment versions.
5. **Feature gates** — New CEL-powered features are gated behind Dex feature flags (using the
existing `pkg/featureflags` mechanism) during their alpha phase.
[kep-3488]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3488-cel-admission-control
[k8s-cel-compat]: https://kubernetes.io/docs/reference/using-api/cel/
#### Cost Estimation and Budgets
Like Kubernetes, Dex CEL expressions must be bounded to prevent denial-of-service.
**Constants:**
| Constant | Value | Description |
|----------|-------|-------------|
| `DefaultCostBudget` | `10_000_000` | Max cost units per evaluation (aligned with Kubernetes) |
| `MaxExpressionLength` | `10_240` | Max expression string length in characters |
| `DefaultStringMaxLength` | `256` | Estimated max string size for cost estimation |
| `DefaultListMaxLength` | `100` | Estimated max list size for cost estimation |
**How it works:**
A `defaultCostEstimator` (implementing `checker.CostEstimator`) provides size hints for known
variables (`identity`, `request`, `claims`) so the `cel-go` cost estimator doesn't assume
unbounded sizes. It also provides call cost estimates for custom Dex functions
(`dex.emailDomain`, `dex.emailLocalPart`, `dex.groupMatches`, `dex.groupFilter`).
Expressions are validated at three levels:
1. **Length check** — reject expressions exceeding `MaxExpressionLength`.
2. **Compile-time cost estimation** — reject expressions whose estimated max cost exceeds
the cost budget.
3. **Runtime cost limit** — abort evaluation if actual cost exceeds the budget.
#### Extension Libraries
The `pkg/cel` environment includes these cel-go standard extensions (same set as Kubernetes):
| Library | Description | Examples |
|---------|-------------|---------|
| `ext.Strings()` | Extended string functions | `"hello".upperAscii()`, `"foo:bar".split(':')`, `s.trim()`, `s.replace('a','b')` |
| `ext.Encoders()` | Base64 encoding/decoding | `base64.encode(bytes)`, `base64.decode(str)` |
| `ext.Lists()` | Extended list functions | `list.slice(1, 3)`, `list.flatten()` |
| `ext.Sets()` | Set operations on lists | `sets.contains(a, b)`, `sets.intersects(a, b)`, `sets.equivalent(a, b)` |
| `ext.Math()` | Math functions | `math.greatest(a, b)`, `math.least(a, b)` |
Plus custom Dex libraries in the `pkg/cel/library` subpackage, each implementing the
`cel.Library` interface:
**`library.Email`** — email-related helpers:
| Function | Signature | Description |
|----------|-----------|-------------|
| `dex.emailDomain` | `(string) -> string` | Returns the domain portion of an email address. `dex.emailDomain("user@example.com") == "example.com"` |
| `dex.emailLocalPart` | `(string) -> string` | Returns the local part of an email address. `dex.emailLocalPart("user@example.com") == "user"` |
**`library.Groups`** — group-related helpers:
| Function | Signature | Description |
|----------|-----------|-------------|
| `dex.groupMatches` | `(list(string), string) -> list(string)` | Returns groups matching a glob pattern. `dex.groupMatches(identity.groups, "team:*")` |
| `dex.groupFilter` | `(list(string), list(string)) -> list(string)` | Returns only groups present in the allowed list. `dex.groupFilter(identity.groups, ["admin", "ops"])` |
#### Example: Compile and Evaluate
```go
// 1. Create a compiler with identity and request variables
compiler, _ := cel.NewCompiler(
append(cel.IdentityVariables(), cel.RequestVariables()...),
)
// 2. Compile a policy expression (type-checked, cost-estimated)
prog, _ := compiler.CompileBool(
`identity.email.endsWith('@example.com') && 'admin' in identity.groups`,
)
// 3. Evaluate against real data
result, _ := cel.EvalBool(ctx, prog, map[string]any{
"identity": cel.IdentityFromConnector(connectorIdentity),
"request": cel.RequestFromContext(cel.RequestContext{...}),
})
// result == true
```
### Phase 2: Authentication Policies
**Config Model:**
```go
// AuthPolicy is a list of deny expressions evaluated after a user
// authenticates with a connector. Each expression evaluates to bool.
// If true — the request is denied. Evaluated in order; first match wins.
type AuthPolicy []PolicyExpression
// PolicyExpression is a CEL expression with an optional human-readable message.
type PolicyExpression struct {
// Expression is a CEL expression that evaluates to bool.
Expression string `json:"expression"`
// Message is a CEL expression that evaluates to string (displayed to the user on deny).
// If empty, a generic message is shown.
Message string `json:"message,omitempty"`
}
```
**Evaluation point:** After `connector.CallbackConnector.HandleCallback()` or
`connector.PasswordConnector.Login()` returns an identity, and before the auth request is
finalized. Implemented in `server/handlers.go` at `handleConnectorCallback`.
**Available CEL variables:** `identity` (from connector), `request` (client_id, connector_id,
scopes, redirect_uri).
**Compilation:** All policy expressions are compiled once at config load time (in
`cmd/dex/serve.go`) and stored in the `Server` struct. This ensures:
- Syntax/type errors are caught at startup, not at runtime.
- No compilation overhead per request.
- Cost estimation can warn operators about expensive expressions at startup.
**Evaluation flow:**
```
User authenticates via connector
v
connector.HandleCallback() returns Identity
v
Evaluate global authPolicy (in order)
- For each expression: evaluate → bool
- If true → deny with message, HTTP 403
v
Evaluate per-client authPolicy (in order)
- Same logic as global
v
Continue normal flow (approval screen or redirect)
```
### Phase 3: Token Policies
**Config Model:**
```go
// TokenPolicy defines policies for token issuance.
type TokenPolicy struct {
// Claims adds or overrides claims in the issued ID token.
Claims []ClaimExpression `json:"claims,omitempty"`
// Filter validates the token request. If expression evaluates to false,
// the request is denied.
Filter *PolicyExpression `json:"filter,omitempty"`
}
type ClaimExpression struct {
// Key is a CEL expression evaluating to string — the claim name.
Key string `json:"key"`
// Value is a CEL expression evaluating to dyn — the claim value.
Value string `json:"value"`
// Condition is an optional CEL expression evaluating to bool.
// When set, the claim is only included in the token if the condition
// evaluates to true. If omitted, the claim is always included.
Condition string `json:"condition,omitempty"`
}
```
**Evaluation point:** In `server/oauth2.go` during ID token construction, after standard
claims are built but before JWT signing.
**Available CEL variables:** `identity`, `request`, `existing_claims` (the standard claims already
computed as `map(string, dyn)`).
**Claim merge order:**
1. Standard Dex claims (sub, iss, aud, email, groups, etc.)
2. Global `tokenPolicy.claims` evaluated and merged
3. Per-client `tokenPolicy.claims` evaluated and merged (overrides global)
**Reserved (forbidden) claim names:**
Certain claim names are reserved and MUST NOT be set or overridden by CEL token policy
expressions. Attempting to use a reserved claim key will result in a config validation error at
startup. This prevents operators from accidentally breaking the OIDC/OAuth2 contract or
undermining Dex's security guarantees.
```go
// ReservedClaimNames is the set of claim names that CEL token policy
// expressions are forbidden from setting. These are core OIDC/OAuth2 claims
// managed exclusively by Dex.
var ReservedClaimNames = map[string]struct{}{
"iss": {}, // Issuer — always set by Dex to its own issuer URL
"sub": {}, // Subject — derived from connector identity, must not be spoofed
"aud": {}, // Audience — determined by the OAuth2 client, not policy
"exp": {}, // Expiration — controlled by Dex token TTL configuration
"iat": {}, // Issued At — set by Dex at signing time
"nbf": {}, // Not Before — set by Dex at signing time
"jti": {}, // JWT ID — generated by Dex for token revocation/uniqueness
"auth_time": {}, // Authentication Time — set by Dex from the auth session
"nonce": {}, // Nonce — echoed from the client's authorization request
"at_hash": {}, // Access Token Hash — computed by Dex from the access token
"c_hash": {}, // Code Hash — computed by Dex from the authorization code
}
```
The reserved list is enforced in two places:
1. **Config load time** — When compiling token policy `ClaimExpression` entries, Dex statically
evaluates the `Key` expression (which must be a string literal or constant-foldable) and rejects
it if the result is in `ReservedClaimNames`.
2. **Runtime (defense in depth)** — Before merging evaluated claims into the ID token, Dex checks
each key against `ReservedClaimNames` and logs a warning + skips the claim if it matches. This
guards against dynamic key expressions that couldn't be statically checked.
### Phase 4: OIDC Connector Claim Mapping
**Config Model:**
In `connector/oidc/oidc.go`:
```go
type Config struct {
// ... existing fields ...
// ClaimMappingExpressions provides CEL-based claim mapping.
// When set, these take precedence over ClaimMapping and ClaimMutations.
ClaimMappingExpressions *ClaimMappingExpression `json:"claimMappingExpressions,omitempty"`
}
type ClaimMappingExpression struct {
// Username is a CEL expression evaluating to string.
// Available variable: 'claims' (map of upstream claims).
Username string `json:"username,omitempty"`
// Email is a CEL expression evaluating to string.
Email string `json:"email,omitempty"`
// Groups is a CEL expression evaluating to list(string).
Groups string `json:"groups,omitempty"`
// EmailVerified is a CEL expression evaluating to bool.
EmailVerified string `json:"emailVerified,omitempty"`
// Extra is a map of claim names to CEL expressions evaluating to dyn.
// These are carried through to token policies.
Extra map[string]string `json:"extra,omitempty"`
}
```
**Available CEL variable:** `claims``map(string, dyn)` containing all raw upstream claims from
the ID token and/or UserInfo endpoint.
This replaces the need for `ClaimMapping`, `NewGroupFromClaims`, `FilterGroupClaims`, and
`ModifyGroupNames` with a single, more powerful mechanism.
**Backward compatibility:** When `claimMappingExpressions` is nil, the existing `ClaimMapping` and
`ClaimMutations` logic is used unchanged. When `claimMappingExpressions` is set, a startup warning is
logged if legacy mapping fields are also configured.
### Policy Application Flow
The following diagram shows the order in which CEL policies are applied.
Each step is optional — if not configured, it is skipped.
```
Connector Authentication
│ upstream claims → connector.Identity
v
Authentication Policies
│ Global authPolicy
│ Per-client authPolicy
v
Token Issuance
│ Global tokenPolicy.filter
│ Per-client tokenPolicy.filter
│ Global tokenPolicy.claims
│ Per-client tokenPolicy.claims
│ Sign JWT
v
Token Response
```
| Step | Policy | Scope | Action on match |
|------|--------|-------|-----------------|
| 2 | `authPolicy` (global) | Global | Expression → `true` = DENY login |
| 3 | `authPolicy` (per-client) | Per-client | Expression → `true` = DENY login |
| 4 | `tokenPolicy.filter` (global) | Global | Expression → `false` = DENY token |
| 5 | `tokenPolicy.filter` (per-client) | Per-client | Expression → `false` = DENY token |
| 6 | `tokenPolicy.claims` (global) | Global | Adds/overrides claims (with optional condition) |
| 7 | `tokenPolicy.claims` (per-client) | Per-client | Adds/overrides claims (overrides global) |
### Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| **CEL expression complexity / DoS** | Cost budgets with configurable limits (default aligned with Kubernetes). Expressions are validated at config load time. Runtime evaluation is aborted if cost exceeds budget. |
| **Learning curve for operators** | CEL has excellent documentation, playground ([cel.dev](https://cel.dev)), and massive CNCF adoption. Dex docs will include a dedicated CEL guide with examples. Most operators already know CEL from Kubernetes. |
| **`cel-go` dependency size** | `cel-go` adds ~5MB to binary. This is acceptable for the functionality provided. Kubernetes, Istio, Envoy all accept this trade-off. |
| **Breaking changes in `cel-go`** | Pin to semver minor range. Environment versioning ensures existing expressions continue to work across upgrades. |
| **Security: CEL expression injection** | CEL expressions are defined by operators in the server config, not by end users. No CEL expression is ever constructed from user input at runtime. |
| **Config migration** | Old config fields (`ClaimMapping`, `ClaimMutations`) continue to work. CEL expressions are opt-in. If both are specified, CEL takes precedence with a config-time warning. |
| **Error messages exposing internals** | CEL deny `message` expressions are controlled by the operator. Default messages are generic. Evaluation errors are logged server-side, not exposed to end users. |
| **Performance** | Expressions are compiled once at startup. Evaluation is sub-millisecond for typical identity operations. Cost budgets prevent pathological cases. Benchmarks will be included in `pkg/cel` tests. |
### Alternatives
#### OPA/Rego
OPA was previously considered ([#1635], token exchange DEP). While powerful, it has significant
drawbacks for Dex:
- **Separate daemon** — OPA typically runs as a sidecar or daemon; adds operational complexity.
Even the embedded Go library (`github.com/open-policy-agent/opa/rego`) is significantly
heavier than `cel-go`.
- **Rego learning curve** — Rego is a Datalog-derived language unfamiliar to most developers.
CEL syntax is closer to C/Java/Go and is immediately readable.
- **Overkill** — Dex needs simple expression evaluation, not a full policy engine with data
loading, bundles, and partial evaluation.
- **No inline expressions** — Rego policies are typically separate files, not inline config
expressions. This makes the config harder to understand and deploy.
- **Smaller CNCF footprint for embedding** — While OPA is a graduated CNCF project, CEL has
broader adoption as an _embedded_ language (Kubernetes, Istio, Envoy, Kyverno, etc.).
#### JMESPath
JMESPath was proposed for claim mapping. Drawbacks:
- **Query-only** — JMESPath is a JSON query language. It cannot express boolean conditions,
mutations, or string operations naturally.
- **Limited type system** — No type checking at compile time. Errors are only caught at runtime.
- **Small ecosystem** — Limited adoption compared to CEL. No CNCF projects use JMESPath for
policy evaluation.
- **No cost estimation** — No way to bound execution time.
#### Hardcoded Go Logic
The current approach: each feature requires new Go structs, config fields, and code. This is
unsustainable:
- `ClaimMapping`, `NewGroupFromClaims`, `FilterGroupClaims`, `ModifyGroupNames` are each separate
features that could be one CEL expression.
- Every new policy need requires a Dex code change and release.
- Combinatorial explosion of config options.
#### No Change
Without CEL or an equivalent:
- Operators continue to request per-client connector restrictions, custom claims, claim
transformations, and access policies — issues remain open indefinitely.
- Dex accumulates more ad-hoc config fields, increasing maintenance burden.
- Complex use cases require external reverse proxies, forking Dex, or middleware.
## Future Improvements
- **CEL in other connectors** — Extend CEL claim mapping beyond OIDC to LDAP (attribute mapping),
SAML (assertion mapping), and other connectors with complex attribute mapping needs.
- **Policy testing framework** — Unit test framework for operators to validate their CEL
expressions against fixture data before deployment.
- **Connector selection via CEL** — Replace the static connector-per-client mapping with a CEL
expression that dynamically determines which connectors to show based on request attributes.
Loading…
Cancel
Save