From 5945844549ad9b251b0bcbb118ffeced2a79d394 Mon Sep 17 00:00:00 2001 From: "maksim.nabokikh" Date: Fri, 13 Mar 2026 21:01:27 +0100 Subject: [PATCH] Introduce typed variables for identity and request in CEL integration Signed-off-by: maksim.nabokikh --- pkg/cel/cel.go | 9 +++++ pkg/cel/cel_test.go | 64 ++++++++++++++++++------------- pkg/cel/cost.go | 3 -- pkg/cel/library/email_test.go | 4 +- pkg/cel/library/groups_test.go | 12 ++---- pkg/cel/types.go | 69 ++++++++++++++++++++++++---------- 6 files changed, 99 insertions(+), 62 deletions(-) diff --git a/pkg/cel/cel.go b/pkg/cel/cel.go index 8f2ae956..8dd686ba 100644 --- a/pkg/cel/cel.go +++ b/pkg/cel/cel.go @@ -3,6 +3,7 @@ package cel import ( "context" "fmt" + "reflect" "github.com/google/cel-go/cel" "github.com/google/cel-go/checker" @@ -89,6 +90,14 @@ func NewCompiler(variables []VariableDeclaration, opts ...CompilerOption) (*Comp ext.Sets(), ext.Math(), + // Native Go types for typed variable access. + // This gives compile-time field checking: identity.emial → error at config load. + ext.NativeTypes( + ext.ParseStructTags(true), + reflect.TypeOf(IdentityVal{}), + reflect.TypeOf(RequestVal{}), + ), + // Custom Dex libraries cel.Lib(&library.Email{}), cel.Lib(&library.Groups{}), diff --git a/pkg/cel/cel_test.go b/pkg/cel/cel_test.go index 65573c96..b211f344 100644 --- a/pkg/cel/cel_test.go +++ b/pkg/cel/cel_test.go @@ -139,6 +139,26 @@ func TestCompileErrors(t *testing.T) { } } +func TestCompileRejectsUnknownFields(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + // Typo in field name: should fail at compile time with ObjectType + _, err = compiler.CompileBool("identity.emial == 'test@example.com'") + assert.Error(t, err) + assert.Contains(t, err.Error(), "compilation failed") + + // Type mismatch: comparing string field to int should fail at compile time + _, err = compiler.CompileBool("identity.email == 123") + assert.Error(t, err) + assert.Contains(t, err.Error(), "compilation failed") + + // Valid field: should compile fine + _, err = compiler.CompileBool("identity.email == 'test@example.com'") + assert.NoError(t, err) +} + func TestMaxExpressionLength(t *testing.T) { compiler, err := dexcel.NewCompiler(nil) require.NoError(t, err) @@ -156,36 +176,28 @@ func TestEvalBool(t *testing.T) { tests := map[string]struct { expr string - identity map[string]any + identity dexcel.IdentityVal want bool }{ "email endsWith": { - expr: "identity.email.endsWith('@example.com')", - identity: map[string]any{ - "email": "user@example.com", - }, - want: true, + expr: "identity.email.endsWith('@example.com')", + identity: dexcel.IdentityVal{Email: "user@example.com"}, + want: true, }, "email endsWith false": { - expr: "identity.email.endsWith('@example.com')", - identity: map[string]any{ - "email": "user@other.com", - }, - want: false, + expr: "identity.email.endsWith('@example.com')", + identity: dexcel.IdentityVal{Email: "user@other.com"}, + want: false, }, "email_verified": { - expr: "identity.email_verified == true", - identity: map[string]any{ - "email_verified": true, - }, - want: true, + expr: "identity.email_verified == true", + identity: dexcel.IdentityVal{EmailVerified: true}, + want: true, }, "group membership": { - expr: "identity.groups.exists(g, g == 'admin')", - identity: map[string]any{ - "groups": []string{"admin", "dev"}, - }, - want: true, + expr: "identity.groups.exists(g, g == 'admin')", + identity: dexcel.IdentityVal{Groups: []string{"admin", "dev"}}, + want: true, }, } @@ -208,14 +220,12 @@ func TestEvalString(t *testing.T) { compiler, err := dexcel.NewCompiler(vars) require.NoError(t, err) - // identity.email returns dyn from map access, use Compile (not CompileString) - prog, err := compiler.Compile("identity.email") + // With ObjectType, identity.email is typed as string, so CompileString works. + prog, err := compiler.CompileString("identity.email") require.NoError(t, err) result, err := dexcel.EvalString(context.Background(), prog, map[string]any{ - "identity": map[string]any{ - "email": "user@example.com", - }, + "identity": dexcel.IdentityVal{Email: "user@example.com"}, }) require.NoError(t, err) assert.Equal(t, "user@example.com", result) @@ -252,7 +262,7 @@ func TestEvalWithIdentityAndRequest(t *testing.T) { } func TestNewCompilerWithVariables(t *testing.T) { - // Claims variable + // Claims variable — remains map(string, dyn) compiler, err := dexcel.NewCompiler(dexcel.ClaimsVariable()) require.NoError(t, err) diff --git a/pkg/cel/cost.go b/pkg/cel/cost.go index 7ddad80d..d7a09102 100644 --- a/pkg/cel/cost.go +++ b/pkg/cel/cost.go @@ -70,9 +70,6 @@ func (defaultCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeE case "email_verified": // bool field — size is always 1 return &checker.SizeEstimate{Min: 1, Max: 1} - case "redirect_uris": - // list(string) field on request - return &checker.SizeEstimate{Min: 0, Max: DefaultListMaxLength} default: // string fields (email, username, user_id, client_id, etc.) return &checker.SizeEstimate{Min: 0, Max: DefaultStringMaxLength} diff --git a/pkg/cel/library/email_test.go b/pkg/cel/library/email_test.go index 176be420..d13e73a1 100644 --- a/pkg/cel/library/email_test.go +++ b/pkg/cel/library/email_test.go @@ -99,9 +99,7 @@ func TestEmailDomainWithIdentityVariable(t *testing.T) { require.NoError(t, err) result, err := dexcel.EvalString(context.Background(), prog, map[string]any{ - "identity": map[string]any{ - "email": "admin@corp.example.com", - }, + "identity": dexcel.IdentityVal{Email: "admin@corp.example.com"}, }) require.NoError(t, err) assert.Equal(t, "corp.example.com", result) diff --git a/pkg/cel/library/groups_test.go b/pkg/cel/library/groups_test.go index 99663bd0..70a68fb2 100644 --- a/pkg/cel/library/groups_test.go +++ b/pkg/cel/library/groups_test.go @@ -54,9 +54,7 @@ func TestGroupMatches(t *testing.T) { require.NoError(t, err) out, err := dexcel.Eval(context.Background(), prog, map[string]any{ - "identity": map[string]any{ - "groups": tc.groups, - }, + "identity": dexcel.IdentityVal{Groups: tc.groups}, }) require.NoError(t, err) @@ -79,9 +77,7 @@ func TestGroupMatchesInvalidPattern(t *testing.T) { require.NoError(t, err) _, err = dexcel.Eval(context.Background(), prog, map[string]any{ - "identity": map[string]any{ - "groups": []string{"admin"}, - }, + "identity": dexcel.IdentityVal{Groups: []string{"admin"}}, }) require.Error(t, err) assert.Contains(t, err.Error(), "invalid pattern") @@ -130,9 +126,7 @@ func TestGroupFilter(t *testing.T) { require.NoError(t, err) out, err := dexcel.Eval(context.Background(), prog, map[string]any{ - "identity": map[string]any{ - "groups": tc.groups, - }, + "identity": dexcel.IdentityVal{Groups: tc.groups}, }) require.NoError(t, err) diff --git a/pkg/cel/types.go b/pkg/cel/types.go index ad59d4a0..4e657922 100644 --- a/pkg/cel/types.go +++ b/pkg/cel/types.go @@ -13,7 +13,34 @@ type VariableDeclaration struct { Type *cel.Type } -// IdentityVariables provides the 'identity' variable with user claims. +// IdentityVal is the CEL native type for the identity variable. +// Fields are typed so that the CEL compiler rejects unknown field access +// (e.g. identity.emial) at config load time rather than at evaluation time. +type IdentityVal struct { + UserID string `cel:"user_id"` + Username string `cel:"username"` + PreferredUsername string `cel:"preferred_username"` + Email string `cel:"email"` + EmailVerified bool `cel:"email_verified"` + Groups []string `cel:"groups"` +} + +// RequestVal is the CEL native type for the request variable. +type RequestVal struct { + ClientID string `cel:"client_id"` + ConnectorID string `cel:"connector_id"` + Scopes []string `cel:"scopes"` + RedirectURI string `cel:"redirect_uri"` +} + +// identityTypeName is the CEL type name for IdentityVal. +// Derived by ext.NativeTypes as simplePkgAlias(pkgPath) + "." + structName. +const identityTypeName = "cel.IdentityVal" + +// requestTypeName is the CEL type name for RequestVal. +const requestTypeName = "cel.RequestVal" + +// IdentityVariables provides the 'identity' variable with typed fields. // // identity.user_id — string // identity.username — string @@ -23,11 +50,11 @@ type VariableDeclaration struct { // identity.groups — list(string) func IdentityVariables() []VariableDeclaration { return []VariableDeclaration{ - {Name: "identity", Type: cel.MapType(cel.StringType, cel.DynType)}, + {Name: "identity", Type: cel.ObjectType(identityTypeName)}, } } -// RequestVariables provides the 'request' variable with request context. +// RequestVariables provides the 'request' variable with typed fields. // // request.client_id — string // request.connector_id — string @@ -35,11 +62,13 @@ func IdentityVariables() []VariableDeclaration { // request.redirect_uri — string func RequestVariables() []VariableDeclaration { return []VariableDeclaration{ - {Name: "request", Type: cel.MapType(cel.StringType, cel.DynType)}, + {Name: "request", Type: cel.ObjectType(requestTypeName)}, } } // ClaimsVariable provides a 'claims' map for raw upstream claims. +// Claims remain map(string, dyn) because their shape is genuinely +// unknown — they carry arbitrary upstream IdP data. // // claims — map(string, dyn) func ClaimsVariable() []VariableDeclaration { @@ -48,15 +77,15 @@ func ClaimsVariable() []VariableDeclaration { } } -// IdentityFromConnector converts a connector.Identity to a CEL-compatible map. -func IdentityFromConnector(id connector.Identity) map[string]any { - return map[string]any{ - "user_id": id.UserID, - "username": id.Username, - "preferred_username": id.PreferredUsername, - "email": id.Email, - "email_verified": id.EmailVerified, - "groups": id.Groups, +// IdentityFromConnector converts a connector.Identity to a CEL-compatible IdentityVal. +func IdentityFromConnector(id connector.Identity) IdentityVal { + return IdentityVal{ + UserID: id.UserID, + Username: id.Username, + PreferredUsername: id.PreferredUsername, + Email: id.Email, + EmailVerified: id.EmailVerified, + Groups: id.Groups, } } @@ -69,12 +98,12 @@ type RequestContext struct { RedirectURI string } -// RequestFromContext converts a RequestContext to a CEL-compatible map. -func RequestFromContext(rc RequestContext) map[string]any { - return map[string]any{ - "client_id": rc.ClientID, - "connector_id": rc.ConnectorID, - "scopes": rc.Scopes, - "redirect_uri": rc.RedirectURI, +// RequestFromContext converts a RequestContext to a CEL-compatible RequestVal. +func RequestFromContext(rc RequestContext) RequestVal { + return RequestVal{ + ClientID: rc.ClientID, + ConnectorID: rc.ConnectorID, + Scopes: rc.Scopes, + RedirectURI: rc.RedirectURI, } }