Add identity transformation packages idtransform and celformer

Implements Supervisor identity transformations helpers using CEL.
This commit is contained in:
Ryan Richard 2023-02-06 14:43:50 -08:00
parent be11966a64
commit 5385fb38db
4 changed files with 1085 additions and 1 deletions

2
go.mod
View File

@ -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

View File

@ -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),
)
}

View File

@ -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: <input>:1:1: undeclared reference to 'foobar' (in container '')
| foobar.junk()
| ^
ERROR: <input>: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: <input>:1:1: undeclared reference to 'foobar' (in container '')
| foobar.junk()
| ^
ERROR: <input>: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: <input>:1:1: undeclared reference to 'foobar' (in container '')
| foobar.junk()
| ^
ERROR: <input>: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: <input>: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
}

View File

@ -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
}