mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
feat(cel): implement CEL compiler with library (#4607)
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user