diff --git a/go.mod b/go.mod index 99e88730..a9f5c352 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.3 github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-sql-driver/mysql v1.9.3 + github.com/google/cel-go v0.27.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 @@ -34,7 +35,7 @@ require ( go.etcd.io/etcd/client/pkg/v3 v3.6.8 go.etcd.io/etcd/client/v3 v3.6.8 golang.org/x/crypto v0.49.0 - golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.271.0 @@ -44,6 +45,7 @@ require ( require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect dario.cat/mergo v1.0.1 // indirect @@ -52,6 +54,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect diff --git a/go.sum b/go.sum index f735bcd6..b8f97985 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -30,6 +32,8 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= @@ -89,6 +93,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -264,14 +270,15 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 h1:fGZugkZk2UgYBxtpKmvub51Yno1LJDeEsRp2xGD+0gY= -golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= diff --git a/pkg/cel/cel.go b/pkg/cel/cel.go new file mode 100644 index 00000000..8dd686ba --- /dev/null +++ b/pkg/cel/cel.go @@ -0,0 +1,232 @@ +package cel + +import ( + "context" + "fmt" + "reflect" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/checker" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" + + "github.com/dexidp/dex/pkg/cel/library" +) + +// 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 +) + +// CompilationResult holds a compiled CEL program ready for evaluation. +type CompilationResult struct { + Program cel.Program + OutputType *cel.Type + Expression string + + ast *cel.Ast +} + +// CompilerOption configures a Compiler. +type CompilerOption func(*compilerConfig) + +type compilerConfig struct { + costBudget uint64 + version EnvironmentVersion +} + +func defaultCompilerConfig() *compilerConfig { + return &compilerConfig{ + costBudget: DefaultCostBudget, + version: EnvironmentV1, + } +} + +// WithCostBudget sets a custom cost budget for expression evaluation. +func WithCostBudget(budget uint64) CompilerOption { + return func(cfg *compilerConfig) { + cfg.costBudget = budget + } +} + +// WithVersion sets the target environment version for the compiler. +// Defaults to the latest version. Specifying an older version ensures +// that only functions/types available at that version are used. +func WithVersion(v EnvironmentVersion) CompilerOption { + return func(cfg *compilerConfig) { + cfg.version = v + } +} + +// Compiler compiles CEL expressions against a specific environment. +type Compiler struct { + env *cel.Env + cfg *compilerConfig +} + +// NewCompiler creates a new CEL compiler with the specified variable +// declarations and options. +// +// All custom Dex libraries are automatically included. +// The environment is configured with cost limits and safe defaults. +func NewCompiler(variables []VariableDeclaration, opts ...CompilerOption) (*Compiler, error) { + cfg := defaultCompilerConfig() + for _, opt := range opts { + opt(cfg) + } + + envOpts := make([]cel.EnvOption, 0, 8+len(variables)) + envOpts = append(envOpts, + cel.DefaultUTCTimeZone(true), + + // Standard extension libraries (same set as Kubernetes) + ext.Strings(), + ext.Encoders(), + ext.Lists(), + 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{}), + + // Presence tests like has(field) and 'key' in map are O(1) hash + // lookups on map(string, dyn) variables, so they should not count + // toward the cost budget. Without this, expressions with multiple + // 'in' checks (e.g. "'admin' in identity.groups") would accumulate + // inflated cost estimates. This matches Kubernetes CEL behavior + // where presence tests are free for CRD validation rules. + cel.CostEstimatorOptions( + checker.PresenceTestHasCost(false), + ), + ) + + for _, v := range variables { + envOpts = append(envOpts, cel.Variable(v.Name, v.Type)) + } + + env, err := cel.NewEnv(envOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + + return &Compiler{env: env, cfg: cfg}, nil +} + +// CompileBool compiles a CEL expression that must evaluate to bool. +func (c *Compiler) CompileBool(expression string) (*CompilationResult, error) { + return c.compile(expression, cel.BoolType) +} + +// CompileString compiles a CEL expression that must evaluate to string. +func (c *Compiler) CompileString(expression string) (*CompilationResult, error) { + return c.compile(expression, cel.StringType) +} + +// CompileStringList compiles a CEL expression that must evaluate to list(string). +func (c *Compiler) CompileStringList(expression string) (*CompilationResult, error) { + return c.compile(expression, cel.ListType(cel.StringType)) +} + +// Compile compiles a CEL expression with any output type. +func (c *Compiler) Compile(expression string) (*CompilationResult, error) { + return c.compile(expression, nil) +} + +func (c *Compiler) compile(expression string, expectedType *cel.Type) (*CompilationResult, error) { + if len(expression) > MaxExpressionLength { + return nil, fmt.Errorf("expression exceeds maximum length of %d characters", MaxExpressionLength) + } + + ast, issues := c.env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("CEL compilation failed: %w", issues.Err()) + } + + if expectedType != nil && !ast.OutputType().IsEquivalentType(expectedType) { + return nil, fmt.Errorf( + "expected expression output type %s, got %s", + expectedType, ast.OutputType(), + ) + } + + // Estimate cost at compile time and reject expressions that are too expensive. + costEst, err := c.env.EstimateCost(ast, &defaultCostEstimator{}) + if err != nil { + return nil, fmt.Errorf("CEL cost estimation failed: %w", err) + } + + if costEst.Max > c.cfg.costBudget { + return nil, fmt.Errorf( + "CEL expression estimated cost %d exceeds budget %d", + costEst.Max, c.cfg.costBudget, + ) + } + + prog, err := c.env.Program(ast, + cel.EvalOptions(cel.OptOptimize), + cel.CostLimit(c.cfg.costBudget), + ) + if err != nil { + return nil, fmt.Errorf("CEL program creation failed: %w", err) + } + + return &CompilationResult{ + Program: prog, + OutputType: ast.OutputType(), + Expression: expression, + ast: ast, + }, nil +} + +// Eval evaluates a compiled program against the given variables. +func Eval(ctx context.Context, result *CompilationResult, variables map[string]any) (ref.Val, error) { + out, _, err := result.Program.ContextEval(ctx, variables) + if err != nil { + return nil, fmt.Errorf("CEL evaluation failed: %w", err) + } + + return out, nil +} + +// EvalBool is a convenience function that evaluates and asserts bool output. +func EvalBool(ctx context.Context, result *CompilationResult, variables map[string]any) (bool, error) { + out, err := Eval(ctx, result, variables) + if err != nil { + return false, err + } + + v, ok := out.Value().(bool) + if !ok { + return false, fmt.Errorf("expected bool result, got %T", out.Value()) + } + + return v, nil +} + +// EvalString is a convenience function that evaluates and asserts string output. +func EvalString(ctx context.Context, result *CompilationResult, variables map[string]any) (string, error) { + out, err := Eval(ctx, result, variables) + if err != nil { + return "", err + } + + v, ok := out.Value().(string) + if !ok { + return "", fmt.Errorf("expected string result, got %T", out.Value()) + } + + return v, nil +} diff --git a/pkg/cel/cel_test.go b/pkg/cel/cel_test.go new file mode 100644 index 00000000..b211f344 --- /dev/null +++ b/pkg/cel/cel_test.go @@ -0,0 +1,280 @@ +package cel_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/connector" + dexcel "github.com/dexidp/dex/pkg/cel" +) + +func TestCompileBool(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + wantErr bool + }{ + "true literal": { + expr: "true", + }, + "comparison": { + expr: "1 == 1", + }, + "string type mismatch": { + expr: "'hello'", + wantErr: true, + }, + "int type mismatch": { + expr: "42", + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := compiler.CompileBool(tc.expr) + if tc.wantErr { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + } + }) + } +} + +func TestCompileString(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + wantErr bool + }{ + "string literal": { + expr: "'hello'", + }, + "string concatenation": { + expr: "'hello' + ' ' + 'world'", + }, + "bool type mismatch": { + expr: "true", + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := compiler.CompileString(tc.expr) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + } + }) + } +} + +func TestCompileStringList(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + result, err := compiler.CompileStringList("['a', 'b', 'c']") + assert.NoError(t, err) + assert.NotNil(t, result) + + _, err = compiler.CompileStringList("'not a list'") + assert.Error(t, err) +} + +func TestCompile(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + // Compile accepts any type + result, err := compiler.Compile("true") + assert.NoError(t, err) + assert.NotNil(t, result) + + result, err = compiler.Compile("'hello'") + assert.NoError(t, err) + assert.NotNil(t, result) + + result, err = compiler.Compile("42") + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestCompileErrors(t *testing.T) { + compiler, err := dexcel.NewCompiler(nil) + require.NoError(t, err) + + tests := map[string]struct { + expr string + }{ + "syntax error": { + expr: "1 +", + }, + "undefined variable": { + expr: "undefined_var", + }, + "undefined function": { + expr: "undefinedFunc()", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + _, err := compiler.Compile(tc.expr) + assert.Error(t, err) + }) + } +} + +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) + + longExpr := "'" + strings.Repeat("a", dexcel.MaxExpressionLength) + "'" + _, err = compiler.Compile(longExpr) + assert.Error(t, err) + assert.Contains(t, err.Error(), "maximum length") +} + +func TestEvalBool(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]struct { + expr string + identity dexcel.IdentityVal + want bool + }{ + "email endsWith": { + 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: dexcel.IdentityVal{Email: "user@other.com"}, + want: false, + }, + "email_verified": { + expr: "identity.email_verified == true", + identity: dexcel.IdentityVal{EmailVerified: true}, + want: true, + }, + "group membership": { + expr: "identity.groups.exists(g, g == 'admin')", + identity: dexcel.IdentityVal{Groups: []string{"admin", "dev"}}, + want: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.CompileBool(tc.expr) + require.NoError(t, err) + + result, err := dexcel.EvalBool(context.Background(), prog, map[string]any{ + "identity": tc.identity, + }) + require.NoError(t, err) + assert.Equal(t, tc.want, result) + }) + } +} + +func TestEvalString(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + // 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": dexcel.IdentityVal{Email: "user@example.com"}, + }) + require.NoError(t, err) + assert.Equal(t, "user@example.com", result) +} + +func TestEvalWithIdentityAndRequest(t *testing.T) { + vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...) + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + prog, err := compiler.CompileBool( + `identity.email.endsWith('@example.com') && 'admin' in identity.groups && request.connector_id == 'okta'`, + ) + require.NoError(t, err) + + identity := dexcel.IdentityFromConnector(connector.Identity{ + UserID: "123", + Username: "john", + Email: "john@example.com", + Groups: []string{"admin", "dev"}, + }) + request := dexcel.RequestFromContext(dexcel.RequestContext{ + ClientID: "my-app", + ConnectorID: "okta", + Scopes: []string{"openid", "email"}, + }) + + result, err := dexcel.EvalBool(context.Background(), prog, map[string]any{ + "identity": identity, + "request": request, + }) + require.NoError(t, err) + assert.True(t, result) +} + +func TestNewCompilerWithVariables(t *testing.T) { + // Claims variable — remains map(string, dyn) + compiler, err := dexcel.NewCompiler(dexcel.ClaimsVariable()) + require.NoError(t, err) + + // claims.email returns dyn from map access, use Compile (not CompileString) + prog, err := compiler.Compile("claims.email") + require.NoError(t, err) + + result, err := dexcel.EvalString(context.Background(), prog, map[string]any{ + "claims": map[string]any{ + "email": "test@example.com", + }, + }) + require.NoError(t, err) + assert.Equal(t, "test@example.com", result) +} diff --git a/pkg/cel/cost.go b/pkg/cel/cost.go new file mode 100644 index 00000000..d7a09102 --- /dev/null +++ b/pkg/cel/cost.go @@ -0,0 +1,105 @@ +package cel + +import ( + "fmt" + + "github.com/google/cel-go/checker" +) + +// DefaultCostBudget is the default cost budget for a single expression +// evaluation. Aligned with Kubernetes defaults: enough for typical identity +// operations but prevents runaway expressions. +const DefaultCostBudget uint64 = 10_000_000 + +// MaxExpressionLength is the maximum length of a CEL expression string. +const MaxExpressionLength = 10_240 + +// DefaultStringMaxLength is the estimated max length of string values +// (emails, usernames, group names, etc.) used for compile-time cost estimation. +const DefaultStringMaxLength = 256 + +// DefaultListMaxLength is the estimated max length of list values +// (groups, scopes) used for compile-time cost estimation. +const DefaultListMaxLength = 100 + +// CostEstimate holds the estimated cost range for a compiled expression. +type CostEstimate struct { + Min uint64 + Max uint64 +} + +// EstimateCost returns the estimated cost range for a compiled expression. +// This is computed statically at compile time without evaluating the expression. +func (c *Compiler) EstimateCost(result *CompilationResult) (CostEstimate, error) { + costEst, err := c.env.EstimateCost(result.ast, &defaultCostEstimator{}) + if err != nil { + return CostEstimate{}, fmt.Errorf("CEL cost estimation failed: %w", err) + } + + return CostEstimate{Min: costEst.Min, Max: costEst.Max}, nil +} + +// defaultCostEstimator provides size hints for compile-time cost estimation. +// Without these hints, the CEL cost estimator assumes unbounded sizes for +// variables, leading to wildly overestimated max costs. +type defaultCostEstimator struct{} + +func (defaultCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate { + // Provide size hints for map(string, dyn) variables: identity, request, claims. + // Without these, the estimator assumes lists/strings can be infinitely large. + if element.Path() == nil { + return nil + } + + path := element.Path() + if len(path) == 0 { + return nil + } + + root := path[0] + + switch root { + case "identity", "request", "claims": + // Nested field access (e.g. identity.email, identity.groups) + if len(path) >= 2 { + field := path[1] + switch field { + case "groups", "scopes": + // list(string) fields + return &checker.SizeEstimate{Min: 0, Max: DefaultListMaxLength} + case "email_verified": + // bool field — size is always 1 + return &checker.SizeEstimate{Min: 1, Max: 1} + default: + // string fields (email, username, user_id, client_id, etc.) + return &checker.SizeEstimate{Min: 0, Max: DefaultStringMaxLength} + } + } + // The map itself: number of keys + return &checker.SizeEstimate{Min: 0, Max: 20} + } + + return nil +} + +func (defaultCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { + switch function { + case "dex.emailDomain", "dex.emailLocalPart": + // Simple string split — O(n) where n is string length, bounded. + return &checker.CallEstimate{ + CostEstimate: checker.CostEstimate{Min: 1, Max: 2}, + } + case "dex.groupMatches": + // Iterates over groups list and matches each against a pattern. + return &checker.CallEstimate{ + CostEstimate: checker.CostEstimate{Min: 1, Max: DefaultListMaxLength}, + } + case "dex.groupFilter": + // Builds a set from allowed list, then iterates groups. + return &checker.CallEstimate{ + CostEstimate: checker.CostEstimate{Min: 1, Max: 2 * DefaultListMaxLength}, + } + } + + return nil +} diff --git a/pkg/cel/cost_test.go b/pkg/cel/cost_test.go new file mode 100644 index 00000000..9a068be4 --- /dev/null +++ b/pkg/cel/cost_test.go @@ -0,0 +1,137 @@ +package cel_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dexcel "github.com/dexidp/dex/pkg/cel" +) + +func TestEstimateCost(t *testing.T) { + vars := dexcel.IdentityVariables() + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]struct { + expr string + }{ + "simple bool": { + expr: "true", + }, + "string comparison": { + expr: "identity.email == 'test@example.com'", + }, + "group membership": { + expr: "identity.groups.exists(g, g == 'admin')", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + prog, err := compiler.Compile(tc.expr) + require.NoError(t, err) + + est, err := compiler.EstimateCost(prog) + require.NoError(t, err) + assert.True(t, est.Max >= est.Min, "max cost should be >= min cost") + assert.True(t, est.Max <= dexcel.DefaultCostBudget, + "estimated max cost %d should be within default budget %d", est.Max, dexcel.DefaultCostBudget) + }) + } +} + +func TestCompileTimeCostAcceptsSimpleExpressions(t *testing.T) { + vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...) + compiler, err := dexcel.NewCompiler(vars) + require.NoError(t, err) + + tests := map[string]string{ + "literal": "true", + "email endsWith": "identity.email.endsWith('@example.com')", + "group check": "'admin' in identity.groups", + "emailDomain": `dex.emailDomain(identity.email)`, + "groupMatches": `dex.groupMatches(identity.groups, "team:*")`, + "groupFilter": `dex.groupFilter(identity.groups, ["admin", "dev"])`, + "combined policy": `identity.email.endsWith('@example.com') && 'admin' in identity.groups`, + "complex policy": `identity.email.endsWith('@example.com') && + identity.groups.exists(g, g == 'admin') && + request.connector_id == 'okta' && + request.scopes.exists(s, s == 'openid')`, + "filter+map chain": `identity.groups + .filter(g, g.startsWith('team:')) + .map(g, g.replace('team:', '')) + .size() > 0`, + } + + for name, expr := range tests { + t.Run(name, func(t *testing.T) { + _, err := compiler.Compile(expr) + assert.NoError(t, err, "expression should compile within default budget") + }) + } +} + +func TestCompileTimeCostRejection(t *testing.T) { + vars := append(dexcel.IdentityVariables(), dexcel.RequestVariables()...) + + tests := map[string]struct { + budget uint64 + expr string + }{ + "simple exists exceeds tiny budget": { + budget: 1, + expr: "identity.groups.exists(g, g == 'admin')", + }, + "endsWith exceeds tiny budget": { + budget: 2, + expr: "identity.email.endsWith('@example.com')", + }, + "nested comprehension over groups exceeds moderate budget": { + // Two nested iterations over groups: O(n^2) where n=100 → ~280K + budget: 10_000, + expr: `identity.groups.exists(g1, + identity.groups.exists(g2, + g1 != g2 && g1.startsWith(g2) + ) + )`, + }, + "cross-variable comprehension exceeds moderate budget": { + // filter groups then check each against scopes: O(n*m) → ~162K + budget: 10_000, + expr: `identity.groups + .filter(g, g.startsWith('team:')) + .exists(g, request.scopes.exists(s, s == g))`, + }, + "chained filter+map+filter+map exceeds small budget": { + budget: 1000, + expr: `identity.groups + .filter(g, g.startsWith('team:')) + .map(g, g.replace('team:', '')) + .filter(g, g.size() > 3) + .map(g, g.upperAscii()) + .size() > 0`, + }, + "many independent exists exceeds small budget": { + budget: 5000, + expr: `identity.groups.exists(g, g.contains('a')) && + identity.groups.exists(g, g.contains('b')) && + identity.groups.exists(g, g.contains('c')) && + identity.groups.exists(g, g.contains('d')) && + identity.groups.exists(g, g.contains('e'))`, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + compiler, err := dexcel.NewCompiler(vars, dexcel.WithCostBudget(tc.budget)) + require.NoError(t, err) + + _, err = compiler.Compile(tc.expr) + assert.Error(t, err) + assert.Contains(t, err.Error(), "estimated cost") + assert.Contains(t, err.Error(), "exceeds budget") + }) + } +} diff --git a/pkg/cel/doc.go b/pkg/cel/doc.go new file mode 100644 index 00000000..64c1dbd3 --- /dev/null +++ b/pkg/cel/doc.go @@ -0,0 +1,5 @@ +// Package cel provides a safe, sandboxed CEL (Common Expression Language) +// environment for policy evaluation, claim mapping, and token customization +// in Dex. It includes cost budgets, Kubernetes-grade compatibility guarantees, +// and a curated set of extension libraries. +package cel diff --git a/pkg/cel/library/doc.go b/pkg/cel/library/doc.go new file mode 100644 index 00000000..1452d2b9 --- /dev/null +++ b/pkg/cel/library/doc.go @@ -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 diff --git a/pkg/cel/library/email.go b/pkg/cel/library/email.go new file mode 100644 index 00000000..38fe0dee --- /dev/null +++ b/pkg/cel/library/email.go @@ -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) +} diff --git a/pkg/cel/library/email_test.go b/pkg/cel/library/email_test.go new file mode 100644 index 00000000..d13e73a1 --- /dev/null +++ b/pkg/cel/library/email_test.go @@ -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) +} diff --git a/pkg/cel/library/groups.go b/pkg/cel/library/groups.go new file mode 100644 index 00000000..fd7f3603 --- /dev/null +++ b/pkg/cel/library/groups.go @@ -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) +} diff --git a/pkg/cel/library/groups_test.go b/pkg/cel/library/groups_test.go new file mode 100644 index 00000000..70a68fb2 --- /dev/null +++ b/pkg/cel/library/groups_test.go @@ -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) + }) + } +} diff --git a/pkg/cel/types.go b/pkg/cel/types.go new file mode 100644 index 00000000..4e657922 --- /dev/null +++ b/pkg/cel/types.go @@ -0,0 +1,109 @@ +package cel + +import ( + "github.com/google/cel-go/cel" + + "github.com/dexidp/dex/connector" +) + +// VariableDeclaration declares a named variable and its CEL type +// that will be available in expressions. +type VariableDeclaration struct { + Name string + Type *cel.Type +} + +// 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 +// identity.preferred_username — string +// identity.email — string +// identity.email_verified — bool +// identity.groups — list(string) +func IdentityVariables() []VariableDeclaration { + return []VariableDeclaration{ + {Name: "identity", Type: cel.ObjectType(identityTypeName)}, + } +} + +// RequestVariables provides the 'request' variable with typed fields. +// +// request.client_id — string +// request.connector_id — string +// request.scopes — list(string) +// request.redirect_uri — string +func RequestVariables() []VariableDeclaration { + return []VariableDeclaration{ + {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 { + return []VariableDeclaration{ + {Name: "claims", Type: cel.MapType(cel.StringType, cel.DynType)}, + } +} + +// 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, + } +} + +// RequestContext represents the authentication/token request context +// available as the 'request' variable in CEL expressions. +type RequestContext struct { + ClientID string + ConnectorID string + Scopes []string + RedirectURI string +} + +// 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, + } +} diff --git a/pkg/featureflags/doc.go b/pkg/featureflags/doc.go new file mode 100644 index 00000000..27033293 --- /dev/null +++ b/pkg/featureflags/doc.go @@ -0,0 +1,3 @@ +// Package featureflags provides a mechanism for toggling experimental or +// optional Dex features via environment variables (DEX_). +package featureflags diff --git a/pkg/groups/doc.go b/pkg/groups/doc.go new file mode 100644 index 00000000..f1a21d02 --- /dev/null +++ b/pkg/groups/doc.go @@ -0,0 +1,2 @@ +// Package groups contains helper functions related to groups. +package groups diff --git a/pkg/groups/groups.go b/pkg/groups/groups.go index 5dde65ab..d31a5dee 100644 --- a/pkg/groups/groups.go +++ b/pkg/groups/groups.go @@ -1,4 +1,3 @@ -// Package groups contains helper functions related to groups package groups // Filter filters out any groups of given that are not in required. Thus it may diff --git a/pkg/httpclient/doc.go b/pkg/httpclient/doc.go new file mode 100644 index 00000000..3d028a3a --- /dev/null +++ b/pkg/httpclient/doc.go @@ -0,0 +1,3 @@ +// Package httpclient provides a configurable HTTP client constructor with +// support for custom CA certificates, root CAs, and TLS settings. +package httpclient