From 185bcb6c8c52981d85d8b68d081e68cdc1f61084 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 6 Feb 2023 14:43:50 -0800 Subject: [PATCH] Add identity transformation packages idtransform and celformer Implements Supervisor identity transformations helpers using CEL. --- go.mod | 6 +- go.sum | 11 +- internal/celtransformer/celformer.go | 278 +++++++ internal/celtransformer/celformer_test.go | 734 ++++++++++++++++++ .../idtransform/identity_transformations.go | 72 ++ 5 files changed, 1093 insertions(+), 8 deletions(-) create mode 100644 internal/celtransformer/celformer.go create mode 100644 internal/celtransformer/celformer_test.go create mode 100644 internal/idtransform/identity_transformations.go diff --git a/go.mod b/go.mod index b46654fe..0a85675b 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-logr/zapr v1.2.3 github.com/gofrs/flock v0.8.1 github.com/golang/mock v1.6.0 + github.com/google/cel-go v0.13.0 github.com/google/go-cmp v0.5.9 github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.3.0 @@ -85,7 +86,6 @@ require ( github.com/golang/glog v1.0.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/google/cel-go v0.12.6 // indirect github.com/google/gnostic v0.6.9 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect @@ -146,8 +146,8 @@ require ( golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect golang.org/x/tools v0.4.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90 // indirect - google.golang.org/grpc v1.49.0 // indirect + google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c // indirect + google.golang.org/grpc v1.50.1 // indirect google.golang.org/protobuf v1.28.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.66.6 // indirect diff --git a/go.sum b/go.sum index 5e63bc82..7a1c6928 100644 --- a/go.sum +++ b/go.sum @@ -260,8 +260,8 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= -github.com/google/cel-go v0.12.6 h1:kjeKudqV0OygrAqA9fX6J55S8gj+Jre2tckIm5RoG4M= -github.com/google/cel-go v0.12.6/go.mod h1:Jk7ljRzLBhkmiAwBoUxB1sZSCVBAzkqPF25olK/iRDw= +github.com/google/cel-go v0.13.0 h1:z+8OBOcmh7IeKyqwT/6IlnMvy621fYUqnTVPEdegGlU= +github.com/google/cel-go v0.13.0/go.mod h1:K2hpQgEjDp18J76a2DKFRlPBPpgRZgi6EbnpDgIhJ8s= github.com/google/gnostic v0.6.9 h1:ZK/5VhkoX835RikCHpSUJV9a+S3e1zLh59YnyWeBW+0= github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -1079,8 +1079,9 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90 h1:4SPz2GL2CXJt28MTF8V6Ap/9ZiVbQlJeGSd9qtA7DLs= google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c h1:QgY/XxIAIeccR+Ca/rDdKubLIU9rcJ3xfy1DC/Wd2Oo= +google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1115,8 +1116,8 @@ google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11 google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw= -google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY= +google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 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 +}