Compare commits
3 Commits
main
...
starlark_t
Author | SHA1 | Date | |
---|---|---|---|
|
516abf669e | ||
|
185bcb6c8c | ||
|
aa57a5150e |
7
go.mod
7
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
|
||||
@ -30,6 +31,7 @@ require (
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/tdewolff/minify/v2 v2.12.4
|
||||
go.starlark.net v0.0.0-20210602144842-1cdb82c9e17a
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.5.0
|
||||
golang.org/x/net v0.5.0
|
||||
@ -84,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
|
||||
@ -145,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
|
||||
|
13
go.sum
13
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=
|
||||
@ -613,6 +613,8 @@ go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/A
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw=
|
||||
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
|
||||
go.starlark.net v0.0.0-20210602144842-1cdb82c9e17a h1:wDtSCWGrX9tusypq2Qq9xzaA3Tf/+4D2KaWO+HQvGZE=
|
||||
go.starlark.net v0.0.0-20210602144842-1cdb82c9e17a/go.mod h1:t3mmBBPzAVvK0L0n1drDmrQsJ8FoIx4INCqVMTr/Zo0=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
@ -1077,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=
|
||||
@ -1113,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=
|
||||
|
334
internal/celtransformer/celformer.go
Normal file
334
internal/celtransformer/celformer.go
Normal file
@ -0,0 +1,334 @@
|
||||
// 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"
|
||||
"regexp"
|
||||
"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"
|
||||
constStringVariableName = "strConst"
|
||||
constStringListVariableName = "strListConst"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// TransformationConstants can be used to make more variables available to compiled CEL expressions for convenience.
|
||||
type TransformationConstants struct {
|
||||
// A map of variable names to their string values. If a key "x" has value "123", then it will be available
|
||||
// to CEL expressions as the variable `strConst.x` with value `"123"`.
|
||||
StringConstants map[string]string
|
||||
// A map of variable names to their string list values. If a key "x" has value []string{"123","456"},
|
||||
// then it will be available to CEL expressions as the variable `strListConst.x` with value `["123","456"]`.
|
||||
StringListConstants map[string][]string
|
||||
}
|
||||
|
||||
// Valid identifiers in CEL expressions are defined by the CEL language spec as: [_a-zA-Z][_a-zA-Z0-9]*
|
||||
var validIdentifiersRegexp = regexp.MustCompile(`^[_a-zA-Z][_a-zA-Z0-9]*$`)
|
||||
|
||||
func (t *TransformationConstants) validateVariableNames() error {
|
||||
const errFormat = "%q is an invalid const variable name (must match [_a-zA-Z][_a-zA-Z0-9]*)"
|
||||
for k := range t.StringConstants {
|
||||
if !validIdentifiersRegexp.MatchString(k) {
|
||||
return fmt.Errorf(errFormat, k)
|
||||
}
|
||||
}
|
||||
for k := range t.StringListConstants {
|
||||
if !validIdentifiersRegexp.MatchString(k) {
|
||||
return fmt.Errorf(errFormat, k)
|
||||
}
|
||||
}
|
||||
return 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.
|
||||
// The caller must not modify the consts param struct after calling this function to allow
|
||||
// the returned IdentityTransformation to use it as a thread-safe read-only structure.
|
||||
func (c *CELTransformer) CompileTransformation(t CELTransformation, consts *TransformationConstants) (idtransform.IdentityTransformation, error) {
|
||||
if consts == nil {
|
||||
consts = &TransformationConstants{}
|
||||
}
|
||||
if err := consts.validateVariableNames(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.compile(c, consts)
|
||||
}
|
||||
|
||||
// CELTransformation can be compiled into an IdentityTransformation.
|
||||
type CELTransformation interface {
|
||||
compile(transformer *CELTransformer, consts *TransformationConstants) (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, consts *TransformationConstants) (idtransform.IdentityTransformation, error) {
|
||||
program, err := compileProgram(transformer, cel.StringType, t.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &compiledUsernameTransformation{
|
||||
baseCompiledTransformation: &baseCompiledTransformation{
|
||||
program: program,
|
||||
consts: consts,
|
||||
maxExpressionRuntime: transformer.maxExpressionRuntime,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *GroupsTransformation) compile(transformer *CELTransformer, consts *TransformationConstants) (idtransform.IdentityTransformation, error) {
|
||||
program, err := compileProgram(transformer, cel.ListType(cel.StringType), t.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &compiledGroupsTransformation{
|
||||
baseCompiledTransformation: &baseCompiledTransformation{
|
||||
program: program,
|
||||
consts: consts,
|
||||
maxExpressionRuntime: transformer.maxExpressionRuntime,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *AllowAuthenticationPolicy) compile(transformer *CELTransformer, consts *TransformationConstants) (idtransform.IdentityTransformation, error) {
|
||||
program, err := compileProgram(transformer, cel.BoolType, t.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &compiledAllowAuthenticationPolicy{
|
||||
baseCompiledTransformation: &baseCompiledTransformation{
|
||||
program: program,
|
||||
consts: consts,
|
||||
maxExpressionRuntime: transformer.maxExpressionRuntime,
|
||||
},
|
||||
rejectedAuthenticationMessage: t.RejectedAuthenticationMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Base type for common aspects of compiled transformations.
|
||||
type baseCompiledTransformation struct {
|
||||
program cel.Program
|
||||
consts *TransformationConstants
|
||||
maxExpressionRuntime time.Duration
|
||||
}
|
||||
|
||||
// Implements idtransform.IdentityTransformation.
|
||||
type compiledUsernameTransformation struct {
|
||||
*baseCompiledTransformation
|
||||
}
|
||||
|
||||
// Implements idtransform.IdentityTransformation.
|
||||
type compiledGroupsTransformation struct {
|
||||
*baseCompiledTransformation
|
||||
}
|
||||
|
||||
// Implements idtransform.IdentityTransformation.
|
||||
type compiledAllowAuthenticationPolicy struct {
|
||||
*baseCompiledTransformation
|
||||
rejectedAuthenticationMessage string
|
||||
}
|
||||
|
||||
func (c *baseCompiledTransformation) evalProgram(ctx context.Context, 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, c.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 := c.program.ContextEval(timeoutCtx, map[string]interface{}{
|
||||
usernameVariableName: username,
|
||||
groupsVariableName: groups,
|
||||
constStringVariableName: c.consts.StringConstants,
|
||||
constStringListVariableName: c.consts.StringListConstants,
|
||||
})
|
||||
return val, err
|
||||
}
|
||||
|
||||
func (c *compiledUsernameTransformation) Evaluate(ctx context.Context, username string, groups []string) (*idtransform.TransformationResult, error) {
|
||||
val, err := c.evalProgram(ctx, 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 := c.evalProgram(ctx, 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 := c.evalProgram(ctx, 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)),
|
||||
cel.Variable(constStringVariableName, cel.MapType(cel.StringType, cel.StringType)),
|
||||
cel.Variable(constStringListVariableName, cel.MapType(cel.StringType, 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),
|
||||
)
|
||||
}
|
834
internal/celtransformer/celformer_test.go
Normal file
834
internal/celtransformer/celformer_test.go
Normal file
@ -0,0 +1,834 @@
|
||||
// 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
|
||||
consts *TransformationConstants
|
||||
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: "any transformation can use the provided constants as variables",
|
||||
username: "ryan",
|
||||
groups: []string{"admins", "developers", "other"},
|
||||
consts: &TransformationConstants{
|
||||
StringConstants: map[string]string{
|
||||
"x": "abc",
|
||||
"y": "def",
|
||||
},
|
||||
StringListConstants: map[string][]string{
|
||||
"x": {"uvw", "xyz"},
|
||||
"y": {"123", "456"},
|
||||
},
|
||||
},
|
||||
transforms: []CELTransformation{
|
||||
&UsernameTransformation{Expression: `strConst.x + strListConst.x[0]`},
|
||||
&GroupsTransformation{Expression: `[strConst.x, strConst.y, strListConst.x[1], strListConst.y[0]]`},
|
||||
&AllowAuthenticationPolicy{Expression: `strConst.x == "abc"`},
|
||||
},
|
||||
wantUsername: "abcuvw",
|
||||
wantGroups: []string{"abc", "def", "xyz", "123"},
|
||||
},
|
||||
{
|
||||
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 an allow list provided as a const",
|
||||
username: "ryan",
|
||||
groups: []string{"admins", "developers", "other"},
|
||||
consts: &TransformationConstants{
|
||||
StringListConstants: map[string][]string{"allowedGroups": {"admins", "developers"}},
|
||||
},
|
||||
transforms: []CELTransformation{
|
||||
&GroupsTransformation{Expression: `groups.filter(g, g in strListConst.allowedGroups)`},
|
||||
},
|
||||
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 filter groups based on a disallowed prefixes provided as a const",
|
||||
username: "ryan",
|
||||
groups: []string{"disallowed1:admins", "disallowed2:developers", "other"},
|
||||
consts: &TransformationConstants{
|
||||
StringListConstants: map[string][]string{"disallowedPrefixes": {"disallowed1:", "disallowed2:"}},
|
||||
},
|
||||
transforms: []CELTransformation{
|
||||
&GroupsTransformation{Expression: `groups.filter(group, !(strListConst.disallowedPrefixes.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 from a const",
|
||||
username: "ryan",
|
||||
groups: []string{"admins", "developers", "other"},
|
||||
consts: &TransformationConstants{
|
||||
StringConstants: map[string]string{"groupToAlwaysAdd": "new-group"},
|
||||
},
|
||||
transforms: []CELTransformation{
|
||||
&GroupsTransformation{Expression: `groups + [strConst.groupToAlwaysAdd]`},
|
||||
},
|
||||
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)"`,
|
||||
},
|
||||
{
|
||||
name: "using string constants which were not were provided",
|
||||
username: "ryan",
|
||||
groups: []string{"admins", "developers", "other"},
|
||||
transforms: []CELTransformation{
|
||||
&UsernameTransformation{Expression: `strConst.x`},
|
||||
},
|
||||
wantEvaluationErr: `identity transformation at index 0: no such key: x`,
|
||||
},
|
||||
{
|
||||
name: "using string list constants which were not were provided",
|
||||
username: "ryan",
|
||||
groups: []string{"admins", "developers", "other"},
|
||||
transforms: []CELTransformation{
|
||||
&GroupsTransformation{Expression: `strListConst.x`},
|
||||
},
|
||||
wantEvaluationErr: `identity transformation at index 0: no such key: x`,
|
||||
},
|
||||
{
|
||||
name: "using an illegal name for a string constant",
|
||||
username: "ryan",
|
||||
groups: []string{"admins", "developers", "other"},
|
||||
consts: &TransformationConstants{StringConstants: map[string]string{" illegal": "a"}},
|
||||
transforms: []CELTransformation{
|
||||
&UsernameTransformation{Expression: `username`},
|
||||
},
|
||||
wantCompileErr: `" illegal" is an invalid const variable name (must match [_a-zA-Z][_a-zA-Z0-9]*)`,
|
||||
},
|
||||
{
|
||||
name: "using an illegal name for a stringList constant",
|
||||
username: "ryan",
|
||||
groups: []string{"admins", "developers", "other"},
|
||||
consts: &TransformationConstants{StringListConstants: map[string][]string{" illegal": {"a"}}},
|
||||
transforms: []CELTransformation{
|
||||
&UsernameTransformation{Expression: `username`},
|
||||
},
|
||||
wantCompileErr: `" illegal" is an invalid const variable name (must match [_a-zA-Z][_a-zA-Z0-9]*)`,
|
||||
},
|
||||
}
|
||||
|
||||
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, tt.consts)
|
||||
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`}, nil)
|
||||
require.NoError(t, err)
|
||||
pipeline.AppendTransformation(compiledTransform)
|
||||
compiledTransform, err = transformer.CompileTransformation(&GroupsTransformation{Expression: `groups.map(g, "group_prefix:" + g)`}, nil)
|
||||
require.NoError(t, err)
|
||||
pipeline.AppendTransformation(compiledTransform)
|
||||
compiledTransform, err = transformer.CompileTransformation(&AllowAuthenticationPolicy{Expression: `username == "username_prefix:ryan"`}, nil)
|
||||
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
|
||||
}
|
72
internal/idtransform/identity_transformations.go
Normal file
72
internal/idtransform/identity_transformations.go
Normal 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
|
||||
}
|
183
internal/starformer/starformer.go
Normal file
183
internal/starformer/starformer.go
Normal file
@ -0,0 +1,183 @@
|
||||
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package starformer is an implementation of UpstreamToDownstreamTransformer using Starlark scripts.
|
||||
// See Starlark dialect language documentation here: https://github.com/google/starlark-go/blob/master/doc/spec.md
|
||||
// A video introduction to Starlark and how to integrate it into projects is here: https://www.youtube.com/watch?v=9P_YKVhncWI
|
||||
package starformer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.starlark.net/lib/json"
|
||||
starlarkmath "go.starlark.net/lib/math"
|
||||
"go.starlark.net/lib/time"
|
||||
"go.starlark.net/resolve"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"go.pinniped.dev/internal/plog"
|
||||
)
|
||||
|
||||
const (
|
||||
maxExecutionSteps = 10000000
|
||||
transformFunctionName = "transform"
|
||||
)
|
||||
|
||||
// Configure some global variables in starlark-go.
|
||||
// nolint:gochecknoinits // wish these weren't globals but oh well
|
||||
func init() {
|
||||
// Allow the non-standard "set" data structure to be used.
|
||||
resolve.AllowSet = true
|
||||
|
||||
// Note that we could allow "while" statements and recursive functions, but the language already
|
||||
// has "for" loops so it seems unnecessary for our use case. This is currently the default
|
||||
// value in starlark-go but repeating it here as documentation.
|
||||
resolve.AllowRecursion = false
|
||||
}
|
||||
|
||||
type Transformer struct {
|
||||
hook *starlark.Function
|
||||
}
|
||||
|
||||
// New creates an instance of Transformer. Given some Starlark source code as a string, it loads the code.
|
||||
// If there is any error during loading, it will return the error. It expects the loaded code to define
|
||||
// a Starlark function called "transform" which should take two positional arguments. The returned
|
||||
// Transformer can be safely called from multiple threads simultaneously, no matter how the Starlark
|
||||
// source code was written, because the Starlark module has been frozen (made immutable).
|
||||
func New(starlarkSourceCode string) (*Transformer, error) {
|
||||
// Create a Starlark thread in which the source will be loaded.
|
||||
thread := &starlark.Thread{
|
||||
Name: "starlark script loader",
|
||||
Print: func(thread *starlark.Thread, msg string) {
|
||||
// When the script has a top-level print(), send it to the server log.
|
||||
plog.Debug("debug message while loading starlark transform script", "msg", msg)
|
||||
},
|
||||
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
|
||||
// Allow starlark-go's custom built-in modules to be loaded by scripts if they desire.
|
||||
switch module {
|
||||
case "json.star":
|
||||
return starlark.StringDict{"json": json.Module}, nil
|
||||
case "time.star":
|
||||
return starlark.StringDict{"time": time.Module}, nil
|
||||
case "math.star":
|
||||
return starlark.StringDict{"math": starlarkmath.Module}, nil
|
||||
default:
|
||||
// Don't allow any other file to be loaded.
|
||||
return nil, fmt.Errorf("only the following modules may be loaded: json.star, time.star, math.star")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Prevent the top-level statements of the Starlark script from accidentally running forever.
|
||||
thread.SetMaxExecutionSteps(maxExecutionSteps)
|
||||
|
||||
// Start with empty predeclared names, aside from the built-ins.
|
||||
predeclared := starlark.StringDict{}
|
||||
|
||||
// Load a Starlark script. Initialization of a script runs its top-level statements from top to bottom,
|
||||
// and then "freezes" all of the values making them immutable. The result can be used in multiple threads
|
||||
// simultaneously without interfering, communicating, or racing with each other. The filename given here
|
||||
// will appear in some Starlark error messages.
|
||||
globals, err := starlark.ExecFile(thread, "transform.star", starlarkSourceCode, predeclared)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while loading starlark transform script: %w", err)
|
||||
}
|
||||
|
||||
// Get the function called "transform" from the global state of the module that was just loaded.
|
||||
hook, _ := globals[transformFunctionName].(*starlark.Function)
|
||||
if hook == nil {
|
||||
return nil, fmt.Errorf("starlark script does not define %q function", transformFunctionName)
|
||||
}
|
||||
|
||||
// Check that the "transform" function takes the expected number of arguments so we can call it later.
|
||||
if hook.NumParams() != 2 {
|
||||
return nil, fmt.Errorf("starlark script's global %q function has %d parameters but should have 2", transformFunctionName, hook.NumParams())
|
||||
}
|
||||
|
||||
return &Transformer{hook: hook}, nil
|
||||
}
|
||||
|
||||
// Transform calls the Starlark "transform" function that was loaded by New. The username and groups params are
|
||||
// passed into the Starlark function, and the return values of the Starlark function are returned. If there is an error
|
||||
// during the call to the Starlark function (either a programming error, a runtime error, or an intentional call to
|
||||
// Starlark's `fail` built-in function) then Transform will return the error. This function is thread-safe.
|
||||
// The runtime of this function depends on the complexity of the Starlark source code, but for a typical Starlark
|
||||
// function will be something on the order of 50µs on a modern laptop.
|
||||
func (t *Transformer) Transform(username string, groups []string) (string, []string, error) {
|
||||
// TODO: maybe add a context param for cancellation, which is supported in starlark-go by
|
||||
// calling thread.Cancel() from any goroutine, or maybe this doesn't matter because there is
|
||||
// already a maxExecutionSteps so scripts are guaranteed to finish within a reasonable time.
|
||||
|
||||
// Create a Starlark thread in which the function will be called.
|
||||
thread := &starlark.Thread{
|
||||
Name: "starlark script executor",
|
||||
Print: func(thread *starlark.Thread, msg string) {
|
||||
// When the script's 'transform' function has a print(), send it to the server log.
|
||||
plog.Debug("debug message while running starlark transform script", "msg", msg)
|
||||
},
|
||||
}
|
||||
|
||||
// Prevent the Starlark function from accidentally running forever.
|
||||
thread.SetMaxExecutionSteps(maxExecutionSteps)
|
||||
|
||||
// Prepare the function arguments as Starlark values.
|
||||
groupsTuple := starlark.Tuple{}
|
||||
for _, group := range groups {
|
||||
groupsTuple = append(groupsTuple, starlark.String(group))
|
||||
}
|
||||
args := starlark.Tuple{starlark.String(username), groupsTuple}
|
||||
|
||||
// Call the Starlark hook function in the new thread and pass the arguments.
|
||||
// Get back the function's return value or an error.
|
||||
hookReturnValue, err := starlark.Call(thread, t.hook, args, nil)
|
||||
|
||||
// Errors could be programming mistakes in the script, or could be an intentional usage of the `fail` built-in.
|
||||
// Either way, return an error to reject the login.
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error while running starlark %q function: %w", transformFunctionName, err)
|
||||
}
|
||||
|
||||
// The special Starlark value 'None' is interpreted here as a shortcut to mean make no edits.
|
||||
if hookReturnValue == starlark.None {
|
||||
return username, groups, nil
|
||||
}
|
||||
|
||||
// TODO: maybe offer a way for the user to reject a login with a nice error message which we can distinguish from
|
||||
// an accidental coding error, for example by returning a single string from their 'transform' function instead
|
||||
// of a tuple, or by returning a special value that we set up in the module's state in advance like
|
||||
// `return rejectAuthentication(message)`
|
||||
|
||||
// Otherwise the function should have returned a tuple with two values.
|
||||
returnedTuple, ok := hookReturnValue.(starlark.Tuple)
|
||||
if !ok || returnedTuple.Len() != 2 {
|
||||
return "", nil, fmt.Errorf("expected starlark %q function to return None or a Tuple of length 2", transformFunctionName)
|
||||
}
|
||||
|
||||
// The first value in the returned tuple is the username. Turn it back into a golang string.
|
||||
transformedUsername, ok := starlark.AsString(returnedTuple.Index(0))
|
||||
if !ok || len(transformedUsername) == 0 {
|
||||
return "", nil, fmt.Errorf("expected starlark %q function's return tuple to have a non-empty string as the first value", transformFunctionName)
|
||||
}
|
||||
|
||||
// The second value in the returned tuple is an iterable of group names.
|
||||
returnedGroups, ok := returnedTuple.Index(1).(starlark.Iterable)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("expected starlark %q function's return tuple to have an iterable value as the second value", transformFunctionName)
|
||||
}
|
||||
|
||||
// Turn the returned iterable of group names back into a golang []string, including turning an empty iterable into an empty slice.
|
||||
transformedGroupNames := []string{}
|
||||
groupsIterator := returnedGroups.Iterate()
|
||||
defer groupsIterator.Done()
|
||||
var transformedGroup starlark.Value
|
||||
for groupsIterator.Next(&transformedGroup) {
|
||||
transformedGroupName, ok := starlark.AsString(transformedGroup)
|
||||
if !ok || len(transformedGroupName) == 0 {
|
||||
return "", nil, fmt.Errorf("expected starlark %q function's return tuple's second value to contain only non-empty strings", transformFunctionName)
|
||||
}
|
||||
transformedGroupNames = append(transformedGroupNames, transformedGroupName)
|
||||
}
|
||||
|
||||
// Got username and group names, so return them as the transformed values.
|
||||
return transformedUsername, transformedGroupNames, nil
|
||||
}
|
360
internal/starformer/starformer_test.go
Normal file
360
internal/starformer/starformer_test.go
Normal file
@ -0,0 +1,360 @@
|
||||
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package starformer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"go.pinniped.dev/internal/here"
|
||||
)
|
||||
|
||||
func TestTypicalPerformance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
starformer, err := New(here.Doc(`
|
||||
def transform(username, groups):
|
||||
prefixedGroups = []
|
||||
for g in groups:
|
||||
prefixedGroups.append("group_prefix:" + g)
|
||||
return "username_prefix:" + username, prefixedGroups
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, starformer)
|
||||
|
||||
groups := []string{}
|
||||
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.
|
||||
gotUsername, gotGroups, err := starformer.Transform("ryan", groups)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "username_prefix:ryan", gotUsername)
|
||||
require.Equal(t, wantGroups, gotGroups)
|
||||
// Calling it a second time should give the same results again for the same inputs.
|
||||
gotUsername, gotGroups, err = starformer.Transform("ryan", groups)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "username_prefix:ryan", gotUsername)
|
||||
require.Equal(t, wantGroups, gotGroups)
|
||||
|
||||
// This is meant to give a sense of typical runtime of a Starlark function 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++ {
|
||||
_, _, _ = starformer.Transform("ryan", groups)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
t.Logf("TestTypicalPerformance %d iteration of Transform took %s; average runtime %s", iterations, elapsed, elapsed/time.Duration(iterations))
|
||||
// On my laptop this prints: TestTypicalPerformance 1000 iteration of Transform took 299.109387ms; average runtime 299.109µs
|
||||
}
|
||||
|
||||
func TestTransformer(t *testing.T) {
|
||||
// See Starlark dialect language documentation here: https://github.com/google/starlark-go/blob/master/doc/spec.md
|
||||
tests := []struct {
|
||||
name string
|
||||
starlarkSrc string
|
||||
username string
|
||||
groups []string
|
||||
wantUsername string
|
||||
wantGroups []string
|
||||
wantNewErr string
|
||||
wantTransformErr string
|
||||
}{
|
||||
{
|
||||
name: "identity function makes no modification",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return username, groups
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "returning None is a shortcut for making no modification",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return None
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "prefixing the username",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return "foobar:" + username, groups
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "foobar:ryan",
|
||||
wantGroups: []string{"g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "down-casing the username",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return username.lower(), groups
|
||||
`),
|
||||
username: "RyAn",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "removing all groups",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return username, ()
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{},
|
||||
},
|
||||
{
|
||||
name: "modifying groups",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return username, ("new-g1", "new-g2")
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"new-g1", "new-g2"},
|
||||
},
|
||||
{
|
||||
name: "converting the groups param to a list type in the business logic is easy, and returning groups as a list works",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
groupsList = list(groups)
|
||||
groupsList.pop()
|
||||
return username, groupsList
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g3"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"g1"},
|
||||
},
|
||||
{
|
||||
name: "can print from the script",
|
||||
starlarkSrc: here.Doc(`
|
||||
print("this should get logged by Pinniped but it is not asserted here")
|
||||
def transform(username, groups):
|
||||
print("this should get logged by Pinniped but it is not asserted here:", username)
|
||||
return username, groups
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "rejecting a login by raising an error",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
if username == "ryan":
|
||||
fail("i don't like the username", username)
|
||||
else:
|
||||
return username, groups
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantTransformErr: `error while running starlark "transform" function: fail: i don't like the username ryan`,
|
||||
},
|
||||
{
|
||||
name: "using the non-standard 'set' type is allowed",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
groupsSet = set(groups)
|
||||
if "g2" in groupsSet:
|
||||
return username, groups
|
||||
else:
|
||||
fail("user", username, "does not belong to group g2")
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g3"},
|
||||
wantTransformErr: `error while running starlark "transform" function: fail: user ryan does not belong to group g2`,
|
||||
},
|
||||
{
|
||||
name: "using the non-standard 'set' type is allowed, and the groups can be returned as a set",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
groupsSet = set(groups)
|
||||
groupsSet = groupsSet.union(["g42"])
|
||||
if "g2" in groupsSet:
|
||||
return username, groupsSet
|
||||
else:
|
||||
fail("user", username, "does not belong to group g2")
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"g1", "g2", "g42"},
|
||||
},
|
||||
{
|
||||
name: "the math module may be loaded",
|
||||
starlarkSrc: here.Doc(`
|
||||
load('math.star', 'math')
|
||||
def transform(username, groups):
|
||||
if math.round(0.4) == 0.0:
|
||||
return username, groups
|
||||
else:
|
||||
fail("math module is supposed to work")
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{"g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "the json module may be loaded",
|
||||
starlarkSrc: here.Doc(`
|
||||
load('json.star', 'json')
|
||||
def transform(username, groups):
|
||||
return username, [json.encode({"hello": groups[0]})]
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "ryan",
|
||||
wantGroups: []string{`{"hello":"g1"}`},
|
||||
},
|
||||
{
|
||||
name: "the time module may be loaded",
|
||||
starlarkSrc: here.Doc(`
|
||||
load('time.star', 'time')
|
||||
def transform(username, groups):
|
||||
if time.now() > time.parse_time("2001-01-20T00:00:00Z"):
|
||||
return "someone", ["g3", "g4"]
|
||||
fail("huh?")
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantUsername: "someone",
|
||||
wantGroups: []string{"g3", "g4"},
|
||||
},
|
||||
{
|
||||
name: "loading other modules results in an error",
|
||||
starlarkSrc: here.Doc(`
|
||||
load('other.star', 'other')
|
||||
def transform(username, groups):
|
||||
return username, groups
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantNewErr: "error while loading starlark transform script: cannot load other.star: only the following modules may be loaded: json.star, time.star, math.star",
|
||||
},
|
||||
{
|
||||
name: "unexpected error during loading",
|
||||
starlarkSrc: here.Doc(`
|
||||
this is not valid starlark syntax
|
||||
`),
|
||||
wantNewErr: "error while loading starlark transform script: transform.star:1:8: got illegal token, want newline",
|
||||
},
|
||||
{
|
||||
name: "too many execution steps during loading",
|
||||
starlarkSrc: here.Doc(`
|
||||
def helper():
|
||||
a = 0
|
||||
for x in range(1000000):
|
||||
a += 1
|
||||
helper()
|
||||
`),
|
||||
wantNewErr: "error while loading starlark transform script: Starlark computation cancelled: too many steps",
|
||||
},
|
||||
{
|
||||
name: "too many execution steps during transform function",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
a = 0
|
||||
for x in range(1000000):
|
||||
a += 1
|
||||
return username, groups
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantTransformErr: `error while running starlark "transform" function: Starlark computation cancelled: too many steps`,
|
||||
},
|
||||
{
|
||||
name: "returning the wrong data type",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return 42
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantTransformErr: `expected starlark "transform" function to return None or a Tuple of length 2`,
|
||||
},
|
||||
{
|
||||
name: "returning the wrong data type inside the groups iterable return value",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return username, ("g1", 42)
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantTransformErr: `expected starlark "transform" function's return tuple's second value to contain only non-empty strings`,
|
||||
},
|
||||
{
|
||||
name: "returning an empty string inside the groups iterable return value",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username, groups):
|
||||
return username, ("g1", "", "g2")
|
||||
`),
|
||||
username: "ryan",
|
||||
groups: []string{"g1", "g2"},
|
||||
wantTransformErr: `expected starlark "transform" function's return tuple's second value to contain only non-empty strings`,
|
||||
},
|
||||
{
|
||||
name: "no transform function defined",
|
||||
starlarkSrc: here.Doc(`
|
||||
def otherFunction(username, groups):
|
||||
return None
|
||||
`),
|
||||
wantNewErr: `starlark script does not define "transform" function`,
|
||||
},
|
||||
{
|
||||
name: "transform function defined with wrong number of positional parameters",
|
||||
starlarkSrc: here.Doc(`
|
||||
def transform(username):
|
||||
return None
|
||||
`),
|
||||
wantNewErr: `starlark script's global "transform" function has 1 parameters but should have 2`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
starformer, err := New(tt.starlarkSrc)
|
||||
if tt.wantNewErr != "" {
|
||||
require.EqualError(t, err, tt.wantNewErr)
|
||||
require.Nil(t, starformer)
|
||||
return // wanted an error from New, so don't keep going
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, starformer)
|
||||
|
||||
gotUsername, gotGroups, err := starformer.Transform(tt.username, tt.groups)
|
||||
if tt.wantTransformErr != "" {
|
||||
require.EqualError(t, err, tt.wantTransformErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tt.wantUsername, gotUsername)
|
||||
require.Equal(t, tt.wantGroups, gotGroups)
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user