Browse Source

Introduce typed variables for identity and request in CEL integration

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
pull/4607/head
maksim.nabokikh 4 days ago
parent
commit
5945844549
  1. 9
      pkg/cel/cel.go
  2. 64
      pkg/cel/cel_test.go
  3. 3
      pkg/cel/cost.go
  4. 4
      pkg/cel/library/email_test.go
  5. 12
      pkg/cel/library/groups_test.go
  6. 69
      pkg/cel/types.go

9
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{}),

64
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)

3
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}

4
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)

12
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)

69
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,
}
}

Loading…
Cancel
Save