diff --git a/go.mod b/go.mod
index 83e99e9e..2f8185f7 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
github.com/go-logr/zapr v1.2.4
github.com/gofrs/flock v0.8.1
github.com/golang/mock v1.6.0
+ github.com/google/cel-go v0.16.0
github.com/google/go-cmp v0.5.9
github.com/google/gofuzz v1.2.0
github.com/google/uuid v1.3.1
@@ -90,7 +91,6 @@ require (
github.com/golang/glog v1.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
- github.com/google/cel-go v0.16.0 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect
diff --git a/internal/celtransformer/celformer.go b/internal/celtransformer/celformer.go
new file mode 100644
index 00000000..409e064b
--- /dev/null
+++ b/internal/celtransformer/celformer.go
@@ -0,0 +1,278 @@
+// Copyright 2023 the Pinniped contributors. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package celtransformer is an implementation of upstream-to-downstream identity transformations
+// and policies using CEL scripts.
+//
+// The CEL language is documented in https://github.com/google/cel-spec/blob/master/doc/langdef.md
+// with optional extensions documented in https://github.com/google/cel-go/tree/master/ext.
+package celtransformer
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types/ref"
+ "github.com/google/cel-go/ext"
+
+ "go.pinniped.dev/internal/idtransform"
+)
+
+const (
+ usernameVariableName = "username"
+ groupsVariableName = "groups"
+
+ defaultPolicyRejectedAuthMessage = "Authentication was rejected by a configured policy"
+)
+
+// CELTransformer can compile any number of transformation expression pipelines.
+// Each compiled pipeline can be cached in memory for later thread-safe evaluation.
+type CELTransformer struct {
+ compiler *cel.Env
+ maxExpressionRuntime time.Duration
+}
+
+// NewCELTransformer returns a CELTransformer.
+// A running process should only need one instance of a CELTransformer.
+func NewCELTransformer(maxExpressionRuntime time.Duration) (*CELTransformer, error) {
+ env, err := newEnv()
+ if err != nil {
+ return nil, err
+ }
+ return &CELTransformer{compiler: env, maxExpressionRuntime: maxExpressionRuntime}, nil
+}
+
+// CompileTransformation compiles a CEL-based identity transformation expression.
+// The compiled transform can be cached in memory and executed repeatedly and in a thread-safe way.
+func (c *CELTransformer) CompileTransformation(t CELTransformation) (idtransform.IdentityTransformation, error) {
+ return t.compile(c)
+}
+
+// CELTransformation can be compiled into an IdentityTransformation.
+type CELTransformation interface {
+ compile(transformer *CELTransformer) (idtransform.IdentityTransformation, error)
+}
+
+// UsernameTransformation is a CEL expression that can transform a username (or leave it unchanged).
+// It implements CELTransformation.
+type UsernameTransformation struct {
+ Expression string
+}
+
+// GroupsTransformation is a CEL expression that can transform a list of group names (or leave it unchanged).
+// It implements CELTransformation.
+type GroupsTransformation struct {
+ Expression string
+}
+
+// AllowAuthenticationPolicy is a CEL expression that can allow the authentication to proceed by returning true.
+// It implements CELTransformation. When the CEL expression returns false, the authentication is rejected and the
+// RejectedAuthenticationMessage is used. When RejectedAuthenticationMessage is empty, a default message will be
+// used for rejected authentications.
+type AllowAuthenticationPolicy struct {
+ Expression string
+ RejectedAuthenticationMessage string
+}
+
+func compileProgram(transformer *CELTransformer, expectedExpressionType *cel.Type, expr string) (cel.Program, error) {
+ if strings.TrimSpace(expr) == "" {
+ return nil, fmt.Errorf("cannot compile empty CEL expression")
+ }
+
+ // compile does both parsing and type checking. The parsing phase indicates whether the expression is
+ // syntactically valid and expands any macros present within the environment. Parsing and checking are
+ // more computationally expensive than evaluation, so parsing and checking are done in advance.
+ ast, issues := transformer.compiler.Compile(expr)
+ if issues != nil {
+ return nil, fmt.Errorf("CEL expression compile error: %s", issues.String())
+ }
+
+ // The compiler's type checker has determined the type of the expression's result.
+ // Check that it matches the type that we expect.
+ if ast.OutputType().String() != expectedExpressionType.String() {
+ return nil, fmt.Errorf("CEL expression should return type %q but returns type %q", expectedExpressionType, ast.OutputType())
+ }
+
+ // The cel.Program is stateless, thread-safe, and cachable.
+ program, err := transformer.compiler.Program(ast,
+ cel.InterruptCheckFrequency(100), // Kubernetes uses 100 here, so we'll copy that setting.
+ cel.EvalOptions(cel.OptOptimize), // Optimize certain things now rather than at evaluation time.
+ )
+ if err != nil {
+ return nil, fmt.Errorf("CEL expression program construction error: %w", err)
+ }
+ return program, nil
+}
+
+func (t *UsernameTransformation) compile(transformer *CELTransformer) (idtransform.IdentityTransformation, error) {
+ program, err := compileProgram(transformer, cel.StringType, t.Expression)
+ if err != nil {
+ return nil, err
+ }
+ return &compiledUsernameTransformation{
+ program: program,
+ maxExpressionRuntime: transformer.maxExpressionRuntime,
+ }, nil
+}
+
+func (t *GroupsTransformation) compile(transformer *CELTransformer) (idtransform.IdentityTransformation, error) {
+ program, err := compileProgram(transformer, cel.ListType(cel.StringType), t.Expression)
+ if err != nil {
+ return nil, err
+ }
+ return &compiledGroupsTransformation{
+ program: program,
+ maxExpressionRuntime: transformer.maxExpressionRuntime,
+ }, nil
+}
+
+func (t *AllowAuthenticationPolicy) compile(transformer *CELTransformer) (idtransform.IdentityTransformation, error) {
+ program, err := compileProgram(transformer, cel.BoolType, t.Expression)
+ if err != nil {
+ return nil, err
+ }
+ return &compiledAllowAuthenticationPolicy{
+ program: program,
+ maxExpressionRuntime: transformer.maxExpressionRuntime,
+ rejectedAuthenticationMessage: t.RejectedAuthenticationMessage,
+ }, nil
+}
+
+// Implements idtransform.IdentityTransformation.
+type compiledUsernameTransformation struct {
+ program cel.Program
+ maxExpressionRuntime time.Duration
+}
+
+// Implements idtransform.IdentityTransformation.
+type compiledGroupsTransformation struct {
+ program cel.Program
+ maxExpressionRuntime time.Duration
+}
+
+// Implements idtransform.IdentityTransformation.
+type compiledAllowAuthenticationPolicy struct {
+ program cel.Program
+ maxExpressionRuntime time.Duration
+ rejectedAuthenticationMessage string
+}
+
+func evalProgram(ctx context.Context, program cel.Program, maxExpressionRuntime time.Duration, username string, groups []string) (ref.Val, error) {
+ // Limit the runtime of a CEL expression to avoid accidental very expensive expressions.
+ timeoutCtx, cancel := context.WithTimeout(ctx, maxExpressionRuntime)
+ defer cancel()
+
+ // Evaluation is thread-safe and side effect free. Many inputs can be sent to the same cel.Program
+ // and if fields are present in the input, but not referenced in the expression, they are ignored.
+ // The argument to Eval may either be an `interpreter.Activation` or a `map[string]interface{}`.
+ val, _, err := program.ContextEval(timeoutCtx, map[string]interface{}{
+ usernameVariableName: username,
+ groupsVariableName: groups,
+ })
+ return val, err
+}
+
+func (c *compiledUsernameTransformation) Evaluate(ctx context.Context, username string, groups []string) (*idtransform.TransformationResult, error) {
+ val, err := evalProgram(ctx, c.program, c.maxExpressionRuntime, username, groups)
+ if err != nil {
+ return nil, err
+ }
+ nativeValue, err := val.ConvertToNative(reflect.TypeOf(""))
+ if err != nil {
+ return nil, fmt.Errorf("could not convert expression result to string: %w", err)
+ }
+ stringValue, ok := nativeValue.(string)
+ if !ok {
+ return nil, fmt.Errorf("could not convert expression result to string")
+ }
+ return &idtransform.TransformationResult{
+ Username: stringValue,
+ Groups: groups, // groups are not modified by username transformations
+ AuthenticationAllowed: true,
+ }, nil
+}
+
+func (c *compiledGroupsTransformation) Evaluate(ctx context.Context, username string, groups []string) (*idtransform.TransformationResult, error) {
+ val, err := evalProgram(ctx, c.program, c.maxExpressionRuntime, username, groups)
+ if err != nil {
+ return nil, err
+ }
+ nativeValue, err := val.ConvertToNative(reflect.TypeOf([]string{}))
+ if err != nil {
+ return nil, fmt.Errorf("could not convert expression result to []string: %w", err)
+ }
+ stringSliceValue, ok := nativeValue.([]string)
+ if !ok {
+ return nil, fmt.Errorf("could not convert expression result to []string")
+ }
+ return &idtransform.TransformationResult{
+ Username: username, // username is not modified by groups transformations
+ Groups: stringSliceValue,
+ AuthenticationAllowed: true,
+ }, nil
+}
+
+func (c *compiledAllowAuthenticationPolicy) Evaluate(ctx context.Context, username string, groups []string) (*idtransform.TransformationResult, error) {
+ val, err := evalProgram(ctx, c.program, c.maxExpressionRuntime, username, groups)
+ if err != nil {
+ return nil, err
+ }
+ nativeValue, err := val.ConvertToNative(reflect.TypeOf(true))
+ if err != nil {
+ return nil, fmt.Errorf("could not convert expression result to bool: %w", err)
+ }
+ boolValue, ok := nativeValue.(bool)
+ if !ok {
+ return nil, fmt.Errorf("could not convert expression result to bool")
+ }
+ result := &idtransform.TransformationResult{
+ Username: username, // username is not modified by policies
+ Groups: groups, // groups are not modified by policies
+ AuthenticationAllowed: boolValue,
+ }
+ if !boolValue {
+ if len(c.rejectedAuthenticationMessage) == 0 {
+ result.RejectedAuthenticationMessage = defaultPolicyRejectedAuthMessage
+ } else {
+ result.RejectedAuthenticationMessage = c.rejectedAuthenticationMessage
+ }
+ }
+ return result, nil
+}
+
+func newEnv() (*cel.Env, error) {
+ // Note that Kubernetes uses CEL in several places, which are helpful to see as an example of
+ // how to configure the CEL compiler for production usage. Examples:
+ // https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/admission/plugin/validatingadmissionpolicy/compiler.go
+ // https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go
+ return cel.NewEnv(
+ // Declare our variable without giving them values yet. By declaring them here, the type is known during
+ // the parsing/checking phase.
+ cel.Variable(usernameVariableName, cel.StringType),
+ cel.Variable(groupsVariableName, cel.ListType(cel.StringType)),
+
+ // Enable the strings extensions.
+ // See https://github.com/google/cel-go/tree/master/ext#strings
+ // CEL also has other extensions for bas64 encoding/decoding and for math that we could choose to enable.
+ // See https://github.com/google/cel-go/tree/master/ext
+ // Kubernetes adds more extensions for extra regexp helpers, URLs, and extra list helpers that we could also
+ // consider enabling. Note that if we added their regexp extension, then we would also need to add
+ // cel.OptimizeRegex(library.ExtensionLibRegexOptimizations...) as an option when we call cel.Program.
+ // See https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/apiserver/pkg/cel/library
+ ext.Strings(),
+
+ // Just in case someone converts a string to a timestamp, make any time operations which do not include
+ // an explicit timezone argument default to UTC.
+ cel.DefaultUTCTimeZone(true),
+
+ // Check list and map literal entry types during type-checking.
+ cel.HomogeneousAggregateLiterals(),
+
+ // Check for collisions in declarations now instead of later.
+ cel.EagerlyValidateDeclarations(true),
+ )
+}
diff --git a/internal/celtransformer/celformer_test.go b/internal/celtransformer/celformer_test.go
new file mode 100644
index 00000000..762f8ecf
--- /dev/null
+++ b/internal/celtransformer/celformer_test.go
@@ -0,0 +1,734 @@
+// Copyright 2023 the Pinniped contributors. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package celtransformer
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "go.pinniped.dev/internal/here"
+ "go.pinniped.dev/internal/idtransform"
+)
+
+func TestTransformer(t *testing.T) {
+ var veryLargeGroupList []string
+ for i := 0; i < 10000; i++ {
+ veryLargeGroupList = append(veryLargeGroupList, fmt.Sprintf("g%d", i))
+ }
+
+ alreadyCancelledContext, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ tests := []struct {
+ name string
+ username string
+ groups []string
+ transforms []CELTransformation
+ ctx context.Context
+
+ wantUsername string
+ wantGroups []string
+ wantAuthRejected bool
+ wantAuthRejectedMessage string
+ wantCompileErr string
+ wantEvaluationErr string
+ }{
+ {
+ name: "empty transforms list does not change the identity and allows auth",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{},
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ },
+ {
+ name: "simple transforms which do not change the identity and allows auth",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `username`},
+ &GroupsTransformation{Expression: `groups`},
+ &AllowAuthenticationPolicy{Expression: `true`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ },
+ {
+ name: "transformations run in the order that they are given and accumulate results",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `"a:" + username`},
+ &UsernameTransformation{Expression: `"b:" + username`},
+ &GroupsTransformation{Expression: `groups.map(g, "a:" + g)`},
+ &GroupsTransformation{Expression: `groups.map(g, "b:" + g)`},
+ },
+ wantUsername: "b:a:ryan",
+ wantGroups: []string{"b:a:admins", "b:a:developers", "b:a:other"},
+ },
+ {
+ name: "policies which return false cause the pipeline to stop running and return a rejected auth result",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `"a:" + username`},
+ &AllowAuthenticationPolicy{Expression: `true`, RejectedAuthenticationMessage: `Everyone is allowed`},
+ &GroupsTransformation{Expression: `groups.map(g, "a:" + g)`},
+ &AllowAuthenticationPolicy{Expression: `username == "admin"`, RejectedAuthenticationMessage: `Only the username "admin" is allowed`},
+ &GroupsTransformation{Expression: `groups.map(g, "b:" + g)`}, // does not get evaluated
+ },
+ wantUsername: "a:ryan",
+ wantGroups: []string{"a:admins", "a:developers", "a:other"},
+ wantAuthRejected: true,
+ wantAuthRejectedMessage: `Only the username "admin" is allowed`,
+ },
+ {
+ name: "policies without a RejectedAuthenticationMessage get a default message",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `username == "admin"`, RejectedAuthenticationMessage: ""},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ wantAuthRejected: true,
+ wantAuthRejectedMessage: `Authentication was rejected by a configured policy`,
+ },
+ {
+ name: "any transformations can use the username and group variables",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `groups[0] == "admins" && username == "ryan"`},
+ &GroupsTransformation{Expression: `groups + [username]`},
+ &UsernameTransformation{Expression: `groups[2]`}, // changes the username to "other"
+ &GroupsTransformation{Expression: `groups + [username + "2"]`}, // by the time this expression runs, the username was already changed to "other"
+ },
+ wantUsername: "other",
+ wantGroups: []string{"admins", "developers", "other", "ryan", "other2"},
+ },
+ {
+ name: "the CEL string extensions are enabled for use in the expressions",
+ username: " ryan ",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.map(g, g.replace("mins", "ministrators"))`},
+ &UsernameTransformation{Expression: `username.upperAscii()`},
+ &AllowAuthenticationPolicy{Expression: `(username.lowerAscii()).trim() == "ryan"`, RejectedAuthenticationMessage: `Silly example`},
+ &UsernameTransformation{Expression: `username.trim()`},
+ },
+ wantUsername: "RYAN",
+ wantGroups: []string{"administrators", "developers", "other"},
+ },
+ {
+ name: "UTC is the default time zone for time operations",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `string(timestamp("2023-01-16T10:00:20.021-08:00").getHours())`},
+ },
+ // Without the compiler option cel.DefaultUTCTimeZone, this result would be 10.
+ // With the option, this result is the original hour from the timestamp string (10), plus the effect
+ // of the timezone (8), to move the hour into the UTC time zone.
+ wantUsername: "18",
+ wantGroups: []string{"admins", "developers", "other"},
+ },
+ {
+ name: "the default UTC time zone for time operations can be overridden by passing the time zone as an argument to the operation",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `string(timestamp("2023-01-16T10:00:20.021-08:00").getHours("US/Mountain"))`},
+ },
+ // This is the hour of the timestamp in Mountain time, which is one time zone over from Pacific (-08:00),
+ // hence it is one larger than the original "10" from the timestamp string.
+ wantUsername: "11",
+ wantGroups: []string{"admins", "developers", "other"},
+ },
+ {
+ name: "quick expressions are finished by CEL before CEL even looks at the cancel context",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `["one group"]`},
+ },
+ ctx: alreadyCancelledContext,
+ wantUsername: "ryan",
+ wantGroups: []string{"one group"},
+ },
+
+ //
+ // Unit tests to demonstrate practical examples of useful CEL expressions.
+ //
+ {
+ name: "can prefix username and all groups",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `"username_prefix:" + username`},
+ &GroupsTransformation{Expression: `groups.map(g, "group_prefix:" + g)`},
+ },
+ wantUsername: "username_prefix:ryan",
+ wantGroups: []string{"group_prefix:admins", "group_prefix:developers", "group_prefix:other"},
+ },
+ {
+ name: "can suffix username and all groups",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `username + ":username_suffix"`},
+ &GroupsTransformation{Expression: `groups.map(g, g + ":group_suffix")`},
+ },
+ wantUsername: "ryan:username_suffix",
+ wantGroups: []string{"admins:group_suffix", "developers:group_suffix", "other:group_suffix"},
+ },
+ {
+ name: "can change case of username and all groups",
+ username: "rYan ๐",
+ groups: []string{"aDmins", "dEvelopers", "oTher"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `username.lowerAscii()`},
+ &GroupsTransformation{Expression: `groups.map(g, g.upperAscii())`},
+ },
+ wantUsername: "ryan ๐",
+ wantGroups: []string{"ADMINS", "DEVELOPERS", "OTHER"},
+ },
+ {
+ name: "can replace whitespace",
+ username: " r\ty a n \n",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `username.replace(" ", "").replace("\n", "").replace("\t", "")`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ },
+ {
+ name: "can filter groups based on an allow list",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.filter(g, g in ["admins", "developers"])`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers"},
+ },
+ {
+ name: "can filter groups based on a disallow list",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.filter(g, !(g in ["admins", "developers"]))`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"other"},
+ },
+ {
+ name: "can filter groups based on a disallowed prefixes",
+ username: "ryan",
+ groups: []string{"disallowed1:admins", "disallowed2:developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.filter(group, !(["disallowed1:", "disallowed2:"].exists(prefix, group.startsWith(prefix))))`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"other"},
+ },
+ {
+ name: "can add a group",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups + ["new-group"]`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other", "new-group"},
+ },
+ {
+ name: "can add a group but only if they already belong to another group - when the user does belong to that other group",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `"other" in groups ? groups + ["new-group"] : groups`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other", "new-group"},
+ },
+ {
+ name: "can add a group but only if they already belong to another group - when the user does NOT belong to that other group",
+ username: "ryan",
+ groups: []string{"admins", "developers"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `"other" in groups ? groups + ["new-group"] : groups`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers"},
+ },
+ {
+ name: "can rename a group",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.map(g, g == "other" ? "other-renamed" : g)`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other-renamed"},
+ },
+ {
+ name: "can reject auth based on belonging to one group - when the user meets the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other", "super-admins"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `"super-admins" in groups`, RejectedAuthenticationMessage: `Only users who belong to the "super-admins" group are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other", "super-admins"},
+ },
+ {
+ name: "can reject auth based on belonging to one group - when the user does NOT meet the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `"super-admins" in groups`, RejectedAuthenticationMessage: `Only users who belong to the "super-admins" group are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ wantAuthRejected: true,
+ wantAuthRejectedMessage: `Only users who belong to the "super-admins" group are allowed`,
+ },
+ {
+ name: "can reject auth unless the user belongs to any one of the groups in a list - when the user meets the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "foobar", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `groups.exists(g, g in ["foobar", "foobaz", "foobat"])`, RejectedAuthenticationMessage: `Only users who belong to any of the groups in a list are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "foobar", "other"},
+ },
+ {
+ name: "can reject auth unless the user belongs to any one of the groups in a list - when the user does NOT meet the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `groups.exists(g, g in ["foobar", "foobaz", "foobat"])`, RejectedAuthenticationMessage: `Only users who belong to any of the groups in a list are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ wantAuthRejected: true,
+ wantAuthRejectedMessage: `Only users who belong to any of the groups in a list are allowed`,
+ },
+ {
+ name: "can reject auth unless the user belongs to all of the groups in a list - when the user meets the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other", "foobar", "foobaz", "foobat"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `["foobar", "foobaz", "foobat"].all(g, g in groups)`, RejectedAuthenticationMessage: `Only users who belong to all groups in a list are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other", "foobar", "foobaz", "foobat"},
+ },
+ {
+ name: "can reject auth unless the user belongs to all of the groups in a list - when the user does NOT meet the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other", "foobaz", "foobat"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `["foobar", "foobaz", "foobat"].all(g, g in groups)`, RejectedAuthenticationMessage: `Only users who belong to all groups in a list are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other", "foobaz", "foobat"},
+ wantAuthRejected: true,
+ wantAuthRejectedMessage: `Only users who belong to all groups in a list are allowed`,
+ },
+ {
+ name: "can reject auth if the user belongs to any groups in a disallowed groups list - when the user meets the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `!groups.exists(g, g in ["foobar", "foobaz"])`, RejectedAuthenticationMessage: `Only users who do not belong to any of the groups in a list are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ },
+ {
+ name: "can reject auth if the user belongs to any groups in a disallowed groups list - when the user does NOT meet the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other", "foobaz"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `!groups.exists(g, g in ["foobar", "foobaz"])`, RejectedAuthenticationMessage: `Only users who do not belong to any of the groups in a list are allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other", "foobaz"},
+ wantAuthRejected: true,
+ wantAuthRejectedMessage: `Only users who do not belong to any of the groups in a list are allowed`,
+ },
+ {
+ name: "can reject auth unless the username is in an allowed users list - when the user meets the criteria",
+ username: "foobaz",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `username in ["foobar", "foobaz"]`, RejectedAuthenticationMessage: `Only certain usernames allowed`},
+ },
+ wantUsername: "foobaz",
+ wantGroups: []string{"admins", "developers", "other"},
+ },
+ {
+ name: "can reject auth unless the username is in an allowed users list - when the user does NOT meet the criteria",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `username in ["foobar", "foobaz"]`, RejectedAuthenticationMessage: `Only certain usernames allowed`},
+ },
+ wantUsername: "ryan",
+ wantGroups: []string{"admins", "developers", "other"},
+ wantAuthRejected: true,
+ wantAuthRejectedMessage: `Only certain usernames allowed`,
+ },
+
+ //
+ // Error cases
+ //
+ {
+ name: "username transformation returns an empty string as the new username",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `""`},
+ },
+ wantEvaluationErr: "identity transformation returned an empty username, which is not allowed",
+ },
+ {
+ name: "username transformation returns a string containing only whitespace as the new username",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `" \n \t "`},
+ },
+ wantEvaluationErr: "identity transformation returned an empty username, which is not allowed",
+ },
+ {
+ name: "username transformation compiles to return null, which is not a string so it has the wrong type",
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `null`},
+ },
+ wantCompileErr: `CEL expression should return type "string" but returns type "null_type"`,
+ },
+ {
+ name: "groups transformation compiles to return null, which is not a string so it has the wrong type",
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `null`},
+ },
+ wantCompileErr: `CEL expression should return type "list(string)" but returns type "null_type"`,
+ },
+ {
+ name: "policy transformation compiles to return null, which is not a string so it has the wrong type",
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `null`},
+ },
+ wantCompileErr: `CEL expression should return type "bool" but returns type "null_type"`,
+ },
+ {
+ name: "username transformation has empty expression",
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: ``},
+ },
+ wantCompileErr: `cannot compile empty CEL expression`,
+ },
+ {
+ name: "groups transformation has empty expression",
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: ``},
+ },
+ wantCompileErr: `cannot compile empty CEL expression`,
+ },
+ {
+ name: "policy transformation has empty expression",
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: ``},
+ },
+ wantCompileErr: `cannot compile empty CEL expression`,
+ },
+ {
+ name: "username transformation has expression which contains only whitespace",
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: " \n\t "},
+ },
+ wantCompileErr: `cannot compile empty CEL expression`,
+ },
+ {
+ name: "groups transformation has expression which contains only whitespace",
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: " \n\t "},
+ },
+ wantCompileErr: `cannot compile empty CEL expression`,
+ },
+ {
+ name: "policy transformation has expression which contains only whitespace",
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: " \n\t "},
+ },
+ wantCompileErr: `cannot compile empty CEL expression`,
+ },
+ {
+ name: "slow username transformation expressions are canceled by the cancel context after partial evaluation",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`},
+ },
+ ctx: alreadyCancelledContext,
+ wantEvaluationErr: `identity transformation at index 0: operation interrupted`,
+ },
+ {
+ name: "slow groups transformation expressions are canceled by the cancel context after partial evaluation",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.filter(x, groups.all(x, true))`},
+ },
+ ctx: alreadyCancelledContext,
+ wantEvaluationErr: `identity transformation at index 0: operation interrupted`,
+ },
+ {
+ name: "slow policy expressions are canceled by the cancel context after partial evaluation",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: "username"},
+ &AllowAuthenticationPolicy{Expression: `groups.all(x, groups.all(x, true))`}, // this is the slow one
+ },
+ ctx: alreadyCancelledContext,
+ wantEvaluationErr: `identity transformation at index 1: operation interrupted`,
+ },
+ {
+ name: "slow transformation expressions are canceled and the rest of the expressions do not run",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `username`}, // quick expressions are allowed to run even though the context is cancelled
+ &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`},
+ &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`},
+ &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`},
+ },
+ ctx: alreadyCancelledContext,
+ wantEvaluationErr: `identity transformation at index 1: operation interrupted`,
+ },
+ {
+ name: "slow username transformation expressions are canceled after a maximum allowed duration",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ // On my laptop, evaluating this expression would take ~20 seconds if we allowed it to evaluate to completion.
+ &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`},
+ },
+ wantEvaluationErr: `identity transformation at index 0: operation interrupted`,
+ },
+ {
+ name: "slow groups transformation expressions are canceled after a maximum allowed duration",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ // On my laptop, evaluating this expression would take ~20 seconds if we allowed it to evaluate to completion.
+ &GroupsTransformation{Expression: `groups.filter(x, groups.all(x, true))`},
+ },
+ wantEvaluationErr: `identity transformation at index 0: operation interrupted`,
+ },
+ {
+ name: "slow policy transformation expressions are canceled after a maximum allowed duration",
+ username: "ryan",
+ groups: veryLargeGroupList,
+ transforms: []CELTransformation{
+ // On my laptop, evaluating this expression would take ~20 seconds if we allowed it to evaluate to completion.
+ &AllowAuthenticationPolicy{Expression: `groups.all(x, groups.all(x, true))`},
+ },
+ wantEvaluationErr: `identity transformation at index 0: operation interrupted`,
+ },
+ {
+ name: "compile errors are returned by the compile step for a username transform",
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: `foobar.junk()`},
+ },
+ wantCompileErr: here.Doc(`
+ CEL expression compile error: ERROR: :1:1: undeclared reference to 'foobar' (in container '')
+ | foobar.junk()
+ | ^
+ ERROR: :1:12: undeclared reference to 'junk' (in container '')
+ | foobar.junk()
+ | ...........^`,
+ ),
+ },
+ {
+ name: "compile errors are returned by the compile step for a groups transform",
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `foobar.junk()`},
+ },
+ wantCompileErr: here.Doc(`
+ CEL expression compile error: ERROR: :1:1: undeclared reference to 'foobar' (in container '')
+ | foobar.junk()
+ | ^
+ ERROR: :1:12: undeclared reference to 'junk' (in container '')
+ | foobar.junk()
+ | ...........^`,
+ ),
+ },
+ {
+ name: "compile errors are returned by the compile step for a policy",
+ transforms: []CELTransformation{
+ &AllowAuthenticationPolicy{Expression: `foobar.junk()`},
+ },
+ wantCompileErr: here.Doc(`
+ CEL expression compile error: ERROR: :1:1: undeclared reference to 'foobar' (in container '')
+ | foobar.junk()
+ | ^
+ ERROR: :1:12: undeclared reference to 'junk' (in container '')
+ | foobar.junk()
+ | ...........^`,
+ ),
+ },
+ {
+ name: "evaluation errors stop the pipeline and return an error",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &UsernameTransformation{Expression: "username"},
+ &AllowAuthenticationPolicy{Expression: `1 / 0 == 7`},
+ },
+ wantEvaluationErr: `identity transformation at index 1: division by zero`,
+ },
+ {
+ name: "HomogeneousAggregateLiterals compiler setting is enabled to help the user avoid type mistakes in expressions",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.all(g, g in ["admins", 1])`},
+ },
+ wantCompileErr: here.Doc(`
+ CEL expression compile error: ERROR: :1:31: expected type 'string' but found 'int'
+ | groups.all(g, g in ["admins", 1])
+ | ..............................^`,
+ ),
+ },
+ {
+ name: "when an expression's type cannot be determined at compile time, e.g. due to the use of dynamic types",
+ username: "ryan",
+ groups: []string{"admins", "developers", "other"},
+ transforms: []CELTransformation{
+ &GroupsTransformation{Expression: `groups.map(g, {"admins": dyn(1), "developers":"a"}[g])`},
+ },
+ wantCompileErr: `CEL expression should return type "list(string)" but returns type "list(dyn)"`,
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ transformer, err := NewCELTransformer(100 * time.Millisecond)
+ require.NoError(t, err)
+
+ pipeline := idtransform.NewTransformationPipeline()
+
+ for _, transform := range tt.transforms {
+ compiledTransform, err := transformer.CompileTransformation(transform)
+ if tt.wantCompileErr != "" {
+ require.EqualError(t, err, tt.wantCompileErr)
+ return // the rest of the test doesn't make sense when there was a compile error
+ }
+ require.NoError(t, err, "got an unexpected compile error")
+ pipeline.AppendTransformation(compiledTransform)
+ }
+
+ ctx := context.Background()
+ if tt.ctx != nil {
+ ctx = tt.ctx
+ }
+
+ result, err := pipeline.Evaluate(ctx, tt.username, tt.groups)
+ if tt.wantEvaluationErr != "" {
+ require.EqualError(t, err, tt.wantEvaluationErr)
+ return // the rest of the test doesn't make sense when there was an evaluation error
+ }
+ require.NoError(t, err, "got an unexpected evaluation error")
+
+ require.Equal(t, tt.wantUsername, result.Username)
+ require.Equal(t, tt.wantGroups, result.Groups)
+ require.Equal(t, !tt.wantAuthRejected, result.AuthenticationAllowed, "AuthenticationAllowed had unexpected value")
+ require.Equal(t, tt.wantAuthRejectedMessage, result.RejectedAuthenticationMessage)
+ })
+ }
+}
+
+func TestTypicalPerformanceAndThreadSafety(t *testing.T) {
+ t.Parallel()
+
+ transformer, err := NewCELTransformer(100 * time.Millisecond)
+ require.NoError(t, err)
+
+ pipeline := idtransform.NewTransformationPipeline()
+
+ var compiledTransform idtransform.IdentityTransformation
+ compiledTransform, err = transformer.CompileTransformation(&UsernameTransformation{Expression: `"username_prefix:" + username`})
+ require.NoError(t, err)
+ pipeline.AppendTransformation(compiledTransform)
+ compiledTransform, err = transformer.CompileTransformation(&GroupsTransformation{Expression: `groups.map(g, "group_prefix:" + g)`})
+ require.NoError(t, err)
+ pipeline.AppendTransformation(compiledTransform)
+ compiledTransform, err = transformer.CompileTransformation(&AllowAuthenticationPolicy{Expression: `username == "username_prefix:ryan"`})
+ require.NoError(t, err)
+ pipeline.AppendTransformation(compiledTransform)
+
+ var groups []string
+ var wantGroups []string
+ for i := 0; i < 100; i++ {
+ groups = append(groups, fmt.Sprintf("g%d", i))
+ wantGroups = append(wantGroups, fmt.Sprintf("group_prefix:g%d", i))
+ }
+
+ // Before looking at performance, check that the behavior of the function is correct.
+ result, err := pipeline.Evaluate(context.Background(), "ryan", groups)
+ require.NoError(t, err)
+ require.Equal(t, "username_prefix:ryan", result.Username)
+ require.Equal(t, wantGroups, result.Groups)
+ require.True(t, result.AuthenticationAllowed)
+ require.Empty(t, result.RejectedAuthenticationMessage)
+
+ // This loop is meant to give a sense of typical runtime of CEL expressions which transforms a username
+ // and 100 group names. It is not meant to be a pass/fail test or scientific benchmark test.
+ iterations := 1000
+ start := time.Now()
+ for i := 0; i < iterations; i++ {
+ _, _ = pipeline.Evaluate(context.Background(), "ryan", groups)
+ }
+ elapsed := time.Since(start)
+ t.Logf("TestTypicalPerformanceAndThreadSafety %d iterations of Evaluate took %s; average runtime %s", iterations, elapsed, elapsed/time.Duration(iterations))
+ // On my laptop this prints: TestTypicalPerformanceAndThreadSafety 1000 iterations of Evaluate took 257.981421ms; average runtime 257.981ยตs
+
+ // Now use the transformations pipeline from different goroutines at the same time. Hopefully the race detector
+ // will complain if this is not thread safe in some way. Use the pipeline enough that it will be very likely that
+ // there will be several parallel invocations of the Evaluate function. Every invocation should also yield the
+ // exact same result, since they are all using the same inputs. This assumes that the unit tests are run using
+ // the race detector.
+ var wg sync.WaitGroup
+ numGoroutines := 10
+ for i := 0; i < numGoroutines; i++ {
+ wg.Add(1) // increment WaitGroup counter for each goroutine
+ go func() {
+ defer wg.Done() // decrement WaitGroup counter when this goroutine finishes
+ for j := 0; j < iterations*2; j++ {
+ localResult, localErr := pipeline.Evaluate(context.Background(), "ryan", groups)
+ require.NoError(t, localErr)
+ require.Equal(t, "username_prefix:ryan", localResult.Username)
+ require.Equal(t, wantGroups, localResult.Groups)
+ require.True(t, localResult.AuthenticationAllowed)
+ require.Empty(t, localResult.RejectedAuthenticationMessage)
+ }
+ }()
+ }
+ wg.Wait() // wait for the counter to reach zero, indicating the all goroutines are finished
+}
diff --git a/internal/idtransform/identity_transformations.go b/internal/idtransform/identity_transformations.go
new file mode 100644
index 00000000..d7fb4d31
--- /dev/null
+++ b/internal/idtransform/identity_transformations.go
@@ -0,0 +1,72 @@
+// Copyright 2023 the Pinniped contributors. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package idtransform defines upstream-to-downstream identity transformations which could be
+// implemented using various approaches or languages.
+package idtransform
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+// TransformationResult is the result of evaluating a transformation against some inputs.
+type TransformationResult struct {
+ Username string // the new username for an allowed auth
+ Groups []string // the new group names for an allowed auth
+ AuthenticationAllowed bool // when false, disallow this authentication attempt
+ RejectedAuthenticationMessage string // should be set when AuthenticationAllowed is false
+}
+
+// IdentityTransformation is an individual identity transformation which can be evaluated.
+type IdentityTransformation interface {
+ Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error)
+}
+
+// TransformationPipeline is a list of identity transforms, which can be evaluated in order against some given input
+// values.
+type TransformationPipeline struct {
+ transforms []IdentityTransformation
+}
+
+// NewTransformationPipeline creates an empty TransformationPipeline.
+func NewTransformationPipeline() *TransformationPipeline {
+ return &TransformationPipeline{transforms: []IdentityTransformation{}}
+}
+
+// AppendTransformation adds a transformation to the end of the list of transformations for this pipeline.
+// This is not thread-safe, so be sure to add all transformations from a single goroutine before using Evaluate
+// from multiple goroutines.
+func (p *TransformationPipeline) AppendTransformation(t IdentityTransformation) {
+ p.transforms = append(p.transforms, t)
+}
+
+// Evaluate runs the transformation pipeline for a given input identity. It returns a potentially transformed or
+// rejected identity, or an error. If any transformation in the list rejects the authentication, then the list is
+// short-circuited but no error is returned. Only unexpected errors are returned as errors. This is safe to call
+// from multiple goroutines.
+func (p *TransformationPipeline) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) {
+ accumulatedResult := &TransformationResult{
+ Username: username,
+ Groups: groups,
+ AuthenticationAllowed: true,
+ }
+ var err error
+ for i, transform := range p.transforms {
+ accumulatedResult, err = transform.Evaluate(ctx, accumulatedResult.Username, accumulatedResult.Groups)
+ if err != nil {
+ // There was an unexpected error evaluating a transformation.
+ return nil, fmt.Errorf("identity transformation at index %d: %w", i, err)
+ }
+ if !accumulatedResult.AuthenticationAllowed {
+ // Auth has been rejected by a policy. Stop evaluating the rest of the transformations.
+ return accumulatedResult, nil
+ }
+ if strings.TrimSpace(accumulatedResult.Username) == "" {
+ return nil, fmt.Errorf("identity transformation returned an empty username, which is not allowed")
+ }
+ }
+ // There were no unexpected errors and no policy which rejected auth.
+ return accumulatedResult, nil
+}