feat(cel): implement CEL compiler with library (#4607)

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
Maksim Nabokikh
2026-03-13 21:25:00 +01:00
committed by GitHub
parent 0568abeb03
commit 175dc57a3b
17 changed files with 1336 additions and 4 deletions
+4
View File
@@ -0,0 +1,4 @@
// Package library provides custom CEL function libraries for Dex.
// Each library implements the cel.Library interface and can be registered
// in a CEL environment.
package library
+73
View File
@@ -0,0 +1,73 @@
package library
import (
"strings"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
// Email provides email-related CEL functions.
//
// Functions (V1):
//
// dex.emailDomain(email: string) -> string
// Returns the domain portion of an email address.
// Example: dex.emailDomain("user@example.com") == "example.com"
//
// dex.emailLocalPart(email: string) -> string
// Returns the local part of an email address.
// Example: dex.emailLocalPart("user@example.com") == "user"
type Email struct{}
func (Email) CompileOptions() []cel.EnvOption {
return []cel.EnvOption{
cel.Function("dex.emailDomain",
cel.Overload("dex_email_domain_string",
[]*cel.Type{cel.StringType},
cel.StringType,
cel.UnaryBinding(emailDomainImpl),
),
),
cel.Function("dex.emailLocalPart",
cel.Overload("dex_email_local_part_string",
[]*cel.Type{cel.StringType},
cel.StringType,
cel.UnaryBinding(emailLocalPartImpl),
),
),
}
}
func (Email) ProgramOptions() []cel.ProgramOption {
return nil
}
func emailDomainImpl(arg ref.Val) ref.Val {
email, ok := arg.Value().(string)
if !ok {
return types.NewErr("dex.emailDomain: expected string argument")
}
_, domain, found := strings.Cut(email, "@")
if !found {
return types.String("")
}
return types.String(domain)
}
func emailLocalPartImpl(arg ref.Val) ref.Val {
email, ok := arg.Value().(string)
if !ok {
return types.NewErr("dex.emailLocalPart: expected string argument")
}
localPart, _, found := strings.Cut(email, "@")
if !found {
return types.String(email)
}
return types.String(localPart)
}
+106
View File
@@ -0,0 +1,106 @@
package library_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
dexcel "github.com/dexidp/dex/pkg/cel"
)
func TestEmailDomain(t *testing.T) {
compiler, err := dexcel.NewCompiler(nil)
require.NoError(t, err)
tests := map[string]struct {
expr string
want string
}{
"standard email": {
expr: `dex.emailDomain("user@example.com")`,
want: "example.com",
},
"subdomain": {
expr: `dex.emailDomain("admin@sub.domain.org")`,
want: "sub.domain.org",
},
"no at sign": {
expr: `dex.emailDomain("nodomain")`,
want: "",
},
"empty string": {
expr: `dex.emailDomain("")`,
want: "",
},
"multiple at signs": {
expr: `dex.emailDomain("user@name@example.com")`,
want: "name@example.com",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
prog, err := compiler.CompileString(tc.expr)
require.NoError(t, err)
result, err := dexcel.EvalString(context.Background(), prog, map[string]any{})
require.NoError(t, err)
assert.Equal(t, tc.want, result)
})
}
}
func TestEmailLocalPart(t *testing.T) {
compiler, err := dexcel.NewCompiler(nil)
require.NoError(t, err)
tests := map[string]struct {
expr string
want string
}{
"standard email": {
expr: `dex.emailLocalPart("user@example.com")`,
want: "user",
},
"no at sign": {
expr: `dex.emailLocalPart("justuser")`,
want: "justuser",
},
"empty string": {
expr: `dex.emailLocalPart("")`,
want: "",
},
"multiple at signs": {
expr: `dex.emailLocalPart("user@name@example.com")`,
want: "user",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
prog, err := compiler.CompileString(tc.expr)
require.NoError(t, err)
result, err := dexcel.EvalString(context.Background(), prog, map[string]any{})
require.NoError(t, err)
assert.Equal(t, tc.want, result)
})
}
}
func TestEmailDomainWithIdentityVariable(t *testing.T) {
vars := dexcel.IdentityVariables()
compiler, err := dexcel.NewCompiler(vars)
require.NoError(t, err)
prog, err := compiler.CompileString(`dex.emailDomain(identity.email)`)
require.NoError(t, err)
result, err := dexcel.EvalString(context.Background(), prog, map[string]any{
"identity": dexcel.IdentityVal{Email: "admin@corp.example.com"},
})
require.NoError(t, err)
assert.Equal(t, "corp.example.com", result)
}
+123
View File
@@ -0,0 +1,123 @@
package library
import (
"path"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
)
// Groups provides group-related CEL functions.
//
// Functions (V1):
//
// dex.groupMatches(groups: list(string), pattern: string) -> list(string)
// Returns groups matching a glob pattern.
// Example: dex.groupMatches(["team:dev", "team:ops", "admin"], "team:*")
//
// dex.groupFilter(groups: list(string), allowed: list(string)) -> list(string)
// Returns only groups present in the allowed list.
// Example: dex.groupFilter(["admin", "dev", "ops"], ["admin", "ops"])
type Groups struct{}
func (Groups) CompileOptions() []cel.EnvOption {
return []cel.EnvOption{
cel.Function("dex.groupMatches",
cel.Overload("dex_group_matches_list_string",
[]*cel.Type{cel.ListType(cel.StringType), cel.StringType},
cel.ListType(cel.StringType),
cel.BinaryBinding(groupMatchesImpl),
),
),
cel.Function("dex.groupFilter",
cel.Overload("dex_group_filter_list_list",
[]*cel.Type{cel.ListType(cel.StringType), cel.ListType(cel.StringType)},
cel.ListType(cel.StringType),
cel.BinaryBinding(groupFilterImpl),
),
),
}
}
func (Groups) ProgramOptions() []cel.ProgramOption {
return nil
}
func groupMatchesImpl(lhs, rhs ref.Val) ref.Val {
groupList, ok := lhs.(traits.Lister)
if !ok {
return types.NewErr("dex.groupMatches: expected list(string) as first argument")
}
pattern, ok := rhs.Value().(string)
if !ok {
return types.NewErr("dex.groupMatches: expected string pattern as second argument")
}
iter := groupList.Iterator()
var matched []ref.Val
for iter.HasNext() == types.True {
item := iter.Next()
group, ok := item.Value().(string)
if !ok {
continue
}
ok, err := path.Match(pattern, group)
if err != nil {
return types.NewErr("dex.groupMatches: invalid pattern %q: %v", pattern, err)
}
if ok {
matched = append(matched, types.String(group))
}
}
return types.NewRefValList(types.DefaultTypeAdapter, matched)
}
func groupFilterImpl(lhs, rhs ref.Val) ref.Val {
groupList, ok := lhs.(traits.Lister)
if !ok {
return types.NewErr("dex.groupFilter: expected list(string) as first argument")
}
allowedList, ok := rhs.(traits.Lister)
if !ok {
return types.NewErr("dex.groupFilter: expected list(string) as second argument")
}
allowed := make(map[string]struct{})
iter := allowedList.Iterator()
for iter.HasNext() == types.True {
item := iter.Next()
s, ok := item.Value().(string)
if !ok {
continue
}
allowed[s] = struct{}{}
}
var filtered []ref.Val
iter = groupList.Iterator()
for iter.HasNext() == types.True {
item := iter.Next()
group, ok := item.Value().(string)
if !ok {
continue
}
if _, exists := allowed[group]; exists {
filtered = append(filtered, types.String(group))
}
}
return types.NewRefValList(types.DefaultTypeAdapter, filtered)
}
+141
View File
@@ -0,0 +1,141 @@
package library_test
import (
"context"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
dexcel "github.com/dexidp/dex/pkg/cel"
)
func TestGroupMatches(t *testing.T) {
vars := dexcel.IdentityVariables()
compiler, err := dexcel.NewCompiler(vars)
require.NoError(t, err)
tests := map[string]struct {
expr string
groups []string
want []string
}{
"wildcard pattern": {
expr: `dex.groupMatches(identity.groups, "team:*")`,
groups: []string{"team:dev", "team:ops", "admin"},
want: []string{"team:dev", "team:ops"},
},
"exact match": {
expr: `dex.groupMatches(identity.groups, "admin")`,
groups: []string{"team:dev", "admin", "user"},
want: []string{"admin"},
},
"no matches": {
expr: `dex.groupMatches(identity.groups, "nonexistent")`,
groups: []string{"team:dev", "admin"},
want: []string{},
},
"question mark pattern": {
expr: `dex.groupMatches(identity.groups, "team?")`,
groups: []string{"teamA", "teamB", "teams-long"},
want: []string{"teamA", "teamB"},
},
"match all": {
expr: `dex.groupMatches(identity.groups, "*")`,
groups: []string{"a", "b", "c"},
want: []string{"a", "b", "c"},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
prog, err := compiler.CompileStringList(tc.expr)
require.NoError(t, err)
out, err := dexcel.Eval(context.Background(), prog, map[string]any{
"identity": dexcel.IdentityVal{Groups: tc.groups},
})
require.NoError(t, err)
nativeVal, err := out.ConvertToNative(reflect.TypeOf([]string{}))
require.NoError(t, err)
got, ok := nativeVal.([]string)
require.True(t, ok, "expected []string, got %T", nativeVal)
assert.Equal(t, tc.want, got)
})
}
}
func TestGroupMatchesInvalidPattern(t *testing.T) {
vars := dexcel.IdentityVariables()
compiler, err := dexcel.NewCompiler(vars)
require.NoError(t, err)
prog, err := compiler.CompileStringList(`dex.groupMatches(identity.groups, "[invalid")`)
require.NoError(t, err)
_, err = dexcel.Eval(context.Background(), prog, map[string]any{
"identity": dexcel.IdentityVal{Groups: []string{"admin"}},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid pattern")
}
func TestGroupFilter(t *testing.T) {
vars := dexcel.IdentityVariables()
compiler, err := dexcel.NewCompiler(vars)
require.NoError(t, err)
tests := map[string]struct {
expr string
groups []string
want []string
}{
"filter to allowed": {
expr: `dex.groupFilter(identity.groups, ["admin", "ops"])`,
groups: []string{"admin", "dev", "ops"},
want: []string{"admin", "ops"},
},
"no overlap": {
expr: `dex.groupFilter(identity.groups, ["marketing"])`,
groups: []string{"admin", "dev"},
want: []string{},
},
"all allowed": {
expr: `dex.groupFilter(identity.groups, ["a", "b", "c"])`,
groups: []string{"a", "b", "c"},
want: []string{"a", "b", "c"},
},
"empty allowed list": {
expr: `dex.groupFilter(identity.groups, [])`,
groups: []string{"admin", "dev"},
want: []string{},
},
"preserves order": {
expr: `dex.groupFilter(identity.groups, ["z", "a"])`,
groups: []string{"a", "b", "z"},
want: []string{"a", "z"},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
prog, err := compiler.CompileStringList(tc.expr)
require.NoError(t, err)
out, err := dexcel.Eval(context.Background(), prog, map[string]any{
"identity": dexcel.IdentityVal{Groups: tc.groups},
})
require.NoError(t, err)
nativeVal, err := out.ConvertToNative(reflect.TypeOf([]string{}))
require.NoError(t, err)
got, ok := nativeVal.([]string)
require.True(t, ok, "expected []string, got %T", nativeVal)
assert.Equal(t, tc.want, got)
})
}
}