| // Copyright 2020 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // This file contains code that demonstrates common CEL features. |
| // This code is intended for use with the CEL Codelab: http://go/cel-codelab-go |
| package main |
| |
| import ( |
| "encoding/json" |
| "fmt" |
| "reflect" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/google/cel-go/cel" |
| "github.com/google/cel-go/common/types" |
| "github.com/google/cel-go/common/types/ref" |
| "github.com/google/cel-go/common/types/traits" |
| |
| "github.com/golang/glog" |
| "google.golang.org/protobuf/encoding/protojson" |
| "google.golang.org/protobuf/encoding/prototext" |
| "google.golang.org/protobuf/proto" |
| |
| rpcpb "google.golang.org/genproto/googleapis/rpc/context/attribute_context" |
| structpb "google.golang.org/protobuf/types/known/structpb" |
| tpb "google.golang.org/protobuf/types/known/timestamppb" |
| ) |
| |
| func main() { |
| exercise1() |
| exercise2() |
| exercise3() |
| exercise4() |
| exercise5() |
| exercise6() |
| exercise7() |
| exercise8() |
| } |
| |
| // exercise1 evaluates a simple literal expression: "Hello, World!" |
| // |
| // Compile, eval, profit! |
| func exercise1() { |
| fmt.Println("=== Exercise 1: Hello World ===\n") |
| // Create the standard environment. |
| env, err := cel.NewEnv() |
| if err != nil { |
| glog.Exitf("env error: %v", err) |
| } |
| // Check that the expression compiles and returns a String. |
| ast, iss := env.Parse(`"Hello, World!"`) |
| // Report syntactic errors, if present. |
| if iss.Err() != nil { |
| glog.Exit(iss.Err()) |
| } |
| // Type-check the expression for correctness. |
| checked, iss := env.Check(ast) |
| // Report semantic errors, if present. |
| if iss.Err() != nil { |
| glog.Exit(iss.Err()) |
| } |
| // Check the output type is a string. |
| if checked.OutputType() != cel.StringType { |
| glog.Exitf( |
| "Got %v, wanted %v result type", |
| checked.OutputType(), cel.StringType) |
| } |
| // Plan the program. |
| program, err := env.Program(checked) |
| if err != nil { |
| glog.Exitf("program error: %v", err) |
| } |
| // Evaluate the program without any additional arguments. |
| eval(program, cel.NoVars()) |
| fmt.Println() |
| } |
| |
| // exercise2 shows how to declare and use variables in expressions. |
| // |
| // Given a `request` of type `google.rpc.context.AttributeContext.Request` |
| // determine whether a specific auth claim is set. |
| func exercise2() { |
| fmt.Println("=== Exercise 2: Variables ===\n") |
| // Construct a standard environment that accepts 'request' as input and uses |
| // the google.rpc.context.AttributeContext.Request type. |
| env, err := cel.NewEnv( |
| cel.Types(&rpcpb.AttributeContext_Request{}), |
| cel.Variable("request", |
| cel.ObjectType("google.rpc.context.AttributeContext.Request"), |
| ), |
| ) |
| if err != nil { |
| glog.Exit(err) |
| } |
| ast := compile(env, `request.auth.claims.group == 'admin'`, cel.BoolType) |
| program, _ := env.Program(ast) |
| |
| // Evaluate a request object that sets the proper group claim. |
| // Output: true |
| claims := map[string]string{"group": "admin"} |
| eval(program, request(auth("user:me@acme.co", claims), time.Now())) |
| fmt.Println() |
| } |
| |
| // exercise3 demonstrates how CEL's commutative logical operators work. |
| // |
| // Construct an expression which checks whether the `request.auth.claims.group` |
| // value is equal to `admin` or the `request.auth.principal` is |
| // `user:me@acme.co` and the `request.time` is during work hours (9:00 - 17:00) |
| // |
| // Evaluate the expression once with a request containing no claims but which |
| // sets the appropriate principal and occurs at 12:00 hours. Then evaluate the |
| // request a second time at midnight. Observe the difference in output. |
| func exercise3() { |
| fmt.Println("=== Exercise 3: Logical AND/OR ===\n") |
| env, _ := cel.NewEnv( |
| cel.Types(&rpcpb.AttributeContext_Request{}), |
| cel.Variable("request", |
| cel.ObjectType("google.rpc.context.AttributeContext.Request"), |
| ), |
| ) |
| ast := compile(env, |
| `request.auth.claims.group == 'admin' |
| || request.auth.principal == 'user:me@acme.co'`, |
| cel.BoolType) |
| program, _ := env.Program(ast) |
| |
| // Evaluate once with no claims and the proper user. |
| // Output: true |
| eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now())) |
| |
| // Evaluate again with no claims and an unexpected user. |
| // Output: error, no such key |
| eval(program, request(auth("other:me@acme.co", emptyClaims), time.Now())) |
| |
| fmt.Println() |
| } |
| |
| // exercise4 demonstrates how to extend CEL with custom functions. |
| // |
| // Declare a `contains` member function on map types that returns a boolean |
| // indicating whether the map contains the key-value pair. |
| func exercise4() { |
| fmt.Println("=== Exercise 4: Customization ===\n") |
| // Determine whether an optional claim is set to the proper value. The custom |
| // map.contains(key, value) function is used as an alternative to: |
| // key in map && map[key] == value |
| |
| // Useful components of the type-signature for 'contains'. |
| typeParamA := cel.TypeParamType("A") |
| typeParamB := cel.TypeParamType("B") |
| mapAB := cel.MapType(typeParamA, typeParamB) |
| |
| // Env declaration. |
| env, _ := cel.NewEnv( |
| cel.Types(&rpcpb.AttributeContext_Request{}), |
| // Declare the request. |
| cel.Variable("request", |
| cel.ObjectType("google.rpc.context.AttributeContext.Request"), |
| ), |
| // Declare the custom contains function and its implementation. |
| cel.Function("contains", |
| cel.MemberOverload("map_contains_key_value", |
| []*cel.Type{mapAB, typeParamA, typeParamB}, |
| cel.BoolType, |
| cel.FunctionBinding(mapContainsKeyValue)), |
| ), |
| ) |
| ast := compile(env, |
| `request.auth.claims.contains('group', 'admin')`, |
| cel.BoolType) |
| |
| // Construct the program plan. |
| // Output: false |
| program, err := env.Program(ast) |
| if err != nil { |
| glog.Exit(err) |
| } |
| |
| eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now())) |
| claims := map[string]string{"group": "admin"} |
| eval(program, request(auth("user:me@acme.co", claims), time.Now())) |
| fmt.Println() |
| } |
| |
| // exercise5 covers how to build complex objects as CEL literals. |
| // |
| // Given the input `now`, construct a JWT with an expiry of 5 minutes. |
| func exercise5() { |
| fmt.Println("=== Exercise 5: Building JSON ===\n") |
| // Note the quoted keys in the CEL map literal. For proto messages the |
| // field names are unquoted as they represent well-defined identifiers. |
| env, _ := cel.NewEnv( |
| cel.Variable("now", cel.TimestampType), |
| ) |
| ast := compile(env, ` |
| {'aud': 'my-project', |
| 'exp': now + duration('300s'), |
| 'extra_claims': { |
| 'group': 'admin' |
| }, |
| 'iat': now, |
| 'iss': 'auth.acme.com:12350', |
| 'nbf': now, |
| 'sub': 'serviceAccount:delegate@acme.co' |
| }`, |
| cel.MapType(cel.StringType, cel.DynType)) |
| |
| program, _ := env.Program(ast) |
| out, _, _ := eval( |
| program, |
| map[string]any{ |
| "now": time.Now(), |
| }, |
| ) |
| // The output of the program is a CEL map type, but it can be converted |
| // to a JSON representation using the `ConvertToNative` method. |
| fmt.Printf("------ type conversion ------\n%v\n", valueToJSON(out)) |
| fmt.Println() |
| } |
| |
| // exercise6 describes how to build proto message types within CEL. |
| // |
| // Given an input `jwt` and time `now` construct a |
| // `google.rpc.context.AttributeContext.Request` with the `time` and `auth` |
| // fields populated according to the go/api-attributes specification. |
| func exercise6() { |
| fmt.Println("=== Exercise 6: Building Protos ===\n") |
| |
| // Construct an environment and indicate that the container for all references |
| // within the expression is `google.rpc.context.AttributeContext`. |
| requestType := &rpcpb.AttributeContext_Request{} |
| env, _ := cel.NewEnv( |
| cel.Container("google.rpc.context.AttributeContext"), |
| cel.Types(requestType), |
| cel.Variable("jwt", cel.MapType(cel.StringType, cel.DynType)), |
| cel.Variable("now", cel.TimestampType), |
| ) |
| |
| // Compile the `Request` message construction expression and validate that the |
| // resulting expression type matches the fully qualified message name. |
| // |
| // Note: the field names within the proto message types are not quoted as they |
| // are well-defined names composed of valid identifier characters. Also, note |
| // that when building nested proto objects, the message name needs to prefix the |
| // object construction. |
| ast := compile(env, ` |
| Request{ |
| auth: Auth{ |
| principal: jwt.iss + '/' + jwt.sub, |
| audiences: [jwt.aud], |
| presenter: 'azp' in jwt ? jwt.azp : "", |
| claims: jwt |
| }, |
| time: now |
| }`, |
| cel.ObjectType("google.rpc.context.AttributeContext.Request")) |
| program, _ := env.Program(ast) |
| |
| // Construct the message. The result is a ref.Val that returns a dynamic proto message. |
| out, _, _ := eval( |
| program, |
| map[string]any{ |
| "jwt": map[string]any{ |
| "sub": "serviceAccount:delegate@acme.co", |
| "aud": "my-project", |
| "iss": "auth.acme.com:12350", |
| "extra_claims": map[string]string{ |
| "group": "admin", |
| }, |
| }, |
| "now": time.Now(), |
| }, |
| ) |
| // Unwrap the CEL value to a proto. Make sure to use the `ConvertToNative` to convert |
| // the dynamic proto message to the concrete type expected. |
| req, err := out.ConvertToNative(reflect.TypeOf(requestType)) |
| if err != nil { |
| glog.Exit(err) |
| } |
| bytes, err := prototext.Marshal(req.(proto.Message)) |
| if err != nil { |
| glog.Exitf("failed to marshal proto to text: %v", req) |
| } |
| fmt.Printf("------ type unwrap ------\n%v\n", string(bytes)) |
| fmt.Println() |
| } |
| |
| // exercise7 introduces macros for dealing with repeated fields and maps. |
| // |
| // Determine whether the `jwt.extra_claims` has at least one key that starts |
| // with the `group` prefix, and ensure that all group-like keys have list |
| // values containing only strings that end with '@acme.co`. |
| func exercise7() { |
| fmt.Println("=== Exercise 7: Macros ===\n") |
| env, _ := cel.NewEnv(cel.Variable("jwt", cel.DynType)) |
| ast := compile(env, |
| `jwt.extra_claims.exists(c, c.startsWith('group')) |
| && jwt.extra_claims |
| .filter(c, c.startsWith('group')) |
| .all(c, jwt.extra_claims[c] |
| .all(g, g.endsWith('@acme.co')))`, |
| cel.BoolType) |
| program, _ := env.Program(ast) |
| |
| // Evaluate a complex-ish JWT with two groups that satisfy the criteria. |
| // Output: true. |
| eval(program, |
| map[string]any{ |
| "jwt": map[string]any{ |
| "sub": "serviceAccount:delegate@acme.co", |
| "aud": "my-project", |
| "iss": "auth.acme.com:12350", |
| "extra_claims": map[string][]string{ |
| "group1": {"admin@acme.co", "analyst@acme.co"}, |
| "labels": {"metadata", "prod", "pii"}, |
| "groupN": {"forever@acme.co"}, |
| }, |
| }, |
| }) |
| |
| fmt.Println() |
| } |
| |
| // exercise8 covers some useful features of CEL-Go which can be used to |
| // improve performance and better understand evaluation behavior. |
| // |
| // Turn on the optimization, exhaustive eval, and state tracking |
| // `cel.ProgramOption` flags to see the impact on evaluation behavior. |
| func exercise8() { |
| fmt.Println("=== Exercise 8: Tuning ===\n") |
| // Declare the `x` and 'y' variables as input into the expression. |
| env, _ := cel.NewEnv( |
| cel.Variable("x", cel.IntType), |
| cel.Variable("y", cel.UintType), |
| ) |
| ast := compile(env, |
| `x in [1, 2, 3, 4, 5] && type(y) == uint`, |
| cel.BoolType) |
| // Turn on optimization. |
| trueVars := map[string]any{"x": int64(4), "y": uint64(2)} |
| program, _ := env.Program(ast, cel.EvalOptions(cel.OptOptimize)) |
| // Try benchmarking this evaluation with the optimization flag on and off. |
| eval(program, trueVars) |
| |
| // Turn on exhaustive eval to see what the evaluation state looks like. |
| // The input is structure to show a false on the first branch, and true |
| // on the second. |
| falseVars := map[string]any{"x": int64(6), "y": uint64(2)} |
| program, _ = env.Program(ast, cel.EvalOptions(cel.OptExhaustiveEval)) |
| eval(program, falseVars) |
| |
| // Turn on optimization and state tracking to see the typical eval |
| // behavior, but with partial input. |
| xVar := map[string]any{"x": int64(3)} |
| partialVars, _ := cel.PartialVars(xVar, cel.AttributePattern("y")) |
| program, _ = env.Program(ast, |
| cel.EvalOptions(cel.OptPartialEval, cel.OptOptimize, cel.OptTrackState)) |
| _, details, _ := eval(program, partialVars) |
| |
| // Convert the unknown parts of the expression to a new AST and format it back |
| // to a human-readable expression. |
| residualAst, _ := env.ResidualAst(ast, details) |
| residual, _ := cel.AstToString(residualAst) |
| fmt.Printf("------ residual ------\n%s\n", residual) |
| |
| fmt.Println() |
| } |
| |
| // Functions to assist with CEL execution. |
| |
| // compile will parse and check an expression `expr` against a given |
| // environment `env` and determine whether the resulting type of the expression |
| // matches the `exprType` provided as input. |
| func compile(env *cel.Env, expr string, celType *cel.Type) *cel.Ast { |
| ast, iss := env.Compile(expr) |
| if iss.Err() != nil { |
| glog.Exit(iss.Err()) |
| } |
| if !reflect.DeepEqual(ast.OutputType(), celType) { |
| glog.Exitf( |
| "Got %v, wanted %v result type", ast.OutputType(), celType) |
| } |
| fmt.Printf("%s\n\n", strings.ReplaceAll(expr, "\t", " ")) |
| return ast |
| } |
| |
| // eval will evaluate a given program `prg` against a set of variables `vars` |
| // and return the output, eval details (optional), or error that results from |
| // evaluation. |
| func eval(prg cel.Program, |
| vars any) (out ref.Val, det *cel.EvalDetails, err error) { |
| varMap, isMap := vars.(map[string]any) |
| fmt.Println("------ input ------") |
| if !isMap { |
| fmt.Printf("(%T)\n", vars) |
| } else { |
| for k, v := range varMap { |
| switch val := v.(type) { |
| case proto.Message: |
| bytes, err := prototext.Marshal(val) |
| if err != nil { |
| glog.Exitf("failed to marshal proto to text: %v", val) |
| } |
| fmt.Printf("%s = %s", k, string(bytes)) |
| case map[string]any: |
| b, _ := json.MarshalIndent(v, "", " ") |
| fmt.Printf("%s = %v\n", k, string(b)) |
| case uint64: |
| fmt.Printf("%s = %vu\n", k, v) |
| default: |
| fmt.Printf("%s = %v\n", k, v) |
| } |
| } |
| } |
| fmt.Println() |
| out, det, err = prg.Eval(vars) |
| report(out, det, err) |
| fmt.Println() |
| return |
| } |
| |
| // report prints out the result of evaluation in human-friendly terms. |
| func report(result ref.Val, details *cel.EvalDetails, err error) { |
| fmt.Println("------ result ------") |
| if err != nil { |
| fmt.Printf("error: %s\n", err) |
| } else { |
| fmt.Printf("value: %v (%T)\n", result, result) |
| } |
| if details != nil { |
| fmt.Printf("\n------ eval states ------\n") |
| state := details.State() |
| stateIDs := state.IDs() |
| ids := make([]int, len(stateIDs), len(stateIDs)) |
| for i, id := range stateIDs { |
| ids[i] = int(id) |
| } |
| sort.Ints(ids) |
| for _, id := range ids { |
| v, found := state.Value(int64(id)) |
| if !found { |
| continue |
| } |
| fmt.Printf("%d: %v (%T)\n", id, v, v) |
| } |
| } |
| } |
| |
| // mapContainsKeyValue implements the custom function: |
| // |
| // `map.contains(key, value) bool`. |
| func mapContainsKeyValue(args ...ref.Val) ref.Val { |
| // The declaration of the function ensures that only arguments which match |
| // the mapContainsKey signature will be provided to the function. |
| m := args[0].(traits.Mapper) |
| |
| // CEL has many interfaces for dealing with different type abstractions. |
| // The traits.Mapper interface unifies field presence testing on proto |
| // messages and maps. |
| key := args[1] |
| v, found := m.Find(key) |
| |
| // If not found and the value was non-nil, the value is an error per the |
| // `Find` contract. Propagate it accordingly. |
| if !found { |
| if v != nil { |
| return types.ValOrErr(v, "unsupported key type") |
| } |
| // Return CEL False if the key was not found. |
| return types.False |
| } |
| // Otherwise whether the value at the key equals the value provided. |
| return v.Equal(args[2]) |
| } |
| |
| // Functions for constructing CEL inputs. |
| |
| // auth constructs a `google.rpc.context.AttributeContext.Auth` message. |
| func auth(user string, claims map[string]string) *rpcpb.AttributeContext_Auth { |
| claimFields := make(map[string]*structpb.Value) |
| for k, v := range claims { |
| claimFields[k] = structpb.NewStringValue(v) |
| } |
| return &rpcpb.AttributeContext_Auth{ |
| Principal: user, |
| Claims: &structpb.Struct{Fields: claimFields}, |
| } |
| } |
| |
| // request constructs a `google.rpc.context.AttributeContext.Request` message. |
| func request(auth *rpcpb.AttributeContext_Auth, t time.Time) map[string]any { |
| req := &rpcpb.AttributeContext_Request{ |
| Auth: auth, |
| Time: &tpb.Timestamp{Seconds: t.Unix()}, |
| } |
| return map[string]any{"request": req} |
| } |
| |
| // valueToJSON converts the CEL type to a protobuf JSON representation and |
| // marshals the result to a string. |
| func valueToJSON(val ref.Val) string { |
| v, err := val.ConvertToNative(reflect.TypeOf(&structpb.Value{})) |
| if err != nil { |
| glog.Exit(err) |
| } |
| marshaller := protojson.MarshalOptions{Indent: " "} |
| bytes, err := marshaller.Marshal(v.(proto.Message)) |
| if err != nil { |
| glog.Exit(err) |
| } |
| return string(bytes) |
| } |
| |
| var ( |
| emptyClaims = make(map[string]string) |
| ) |