CEL-Go 程式碼研究室:快速安全的嵌入式運算式

1. 簡介

什麼是 CEL?

CEL 是一種無法完整呈現、可攜性和安全執行,且不具完整表現的語言。CEL 可單獨使用,也可以嵌入大型產品中。

CEL 的設計是一種語言,可以安全地執行使用者程式碼。雖然在使用者的 Python 程式碼中呼叫 eval() 是危險的做法,但您可以放心執行使用者的 CEL 程式碼。此外,由於 CEL 可避免行為會降低效能,因此可放心評估奈秒至微秒的順序;非常適合著重效能的應用程式

CEL 會評估類似單行函式或 lambda 運算式的運算式,CEL 通常用於布林決策,但也可以用來建立較複雜的物件,例如 JSON 或 protobuf 訊息。

CEL 適合你的專案嗎?

由於 CEL 會從 AST 評估運算式 (以奈秒到微秒為單位),因此 CEL 最適合使用具有重要效能路徑的應用程式。不應在重要路徑中編譯 CEL 程式碼;理想的應用程式就是指經常執行設定,並且相對不常修改的應用程式。

舉例來說,在向服務每次發出 HTTP 要求時執行安全性政策,就很適合用於 CEL,因為安全性政策極少發生變化,而且 CEL 對回應時間的影響微乎其微。在這種情況下,如果要求允許或否,CEL 會傳回布林值,但可能會傳回較複雜的訊息。

本程式碼研究室涵蓋哪些內容?

本程式碼研究室的第一個步驟會逐步說明使用 CEL 及其核心概念的動機。接下來將專門用於編寫涵蓋常見用途的運動程式碼。如要深入瞭解語言、語意和功能,請參閱 GitHub 和 CEL Go 文件中的 CEL 語言定義

本程式碼研究室適用於想要瞭解 CEL,以便使用已支援 CEL 的服務的開發人員。本程式碼研究室並未涵蓋如何將 CEL 整合至自己的專案。

課程內容

  • CEL 的核心概念
  • Hello, World:使用 CEL 評估字串
  • 建立變數
  • 瞭解邏輯 AND/OR 運算中的 CEL 短路
  • 如何使用 CEL 建立 JSON
  • 如何使用 CEL 建構 Protobuffers
  • 建立巨集
  • 調整 CEL 運算式的方式

軟硬體需求

必要條件

本程式碼研究室是根據通訊協定緩衝區Go Lang 的基本知識為基礎。

如果您不熟悉通訊協定緩衝區,第一項練習可讓您瞭解 CEL 的運作方式,但由於更進階的範例使用通訊協定緩衝區做為 CEL 的輸入內容,可能較難理解。建議請先瀏覽這些教學課程。請注意,您不一定要使用通訊協定緩衝區,但本程式碼研究室會廣泛使用 CEL。

您可以執行下列指令,測試系統是否已安裝該介面:

go --help

2. 重要概念

應用程式

CEL 是一般用途,且用於各種應用程式,包括轉送遠端程序呼叫 (RPC) 和定義安全性政策。CEL 具有可擴充、各通用的應用程式,且針對一次編譯、評估多個工作流程進行最佳化。

許多服務和應用程式會評估宣告式設定。例如,角色型存取控制 (RBAC) 是一種宣告式設定,可針對某個角色和一組使用者產生存取決策。如果宣告式設定的用途為 80%,則 CEL 很適合用來在使用者需要更具表達力的程度時,將其餘 20% 的結果四捨五入。

編譯

運算式是依環境編譯而成。編譯步驟會產生 protobuf 形式的抽象語法樹狀結構 (AST)。編譯的運算式通常會儲存供日後使用,以盡可能加快評估速度。單一編譯運算式可以使用多個不同的輸入內容進行評估。

運算式

使用者定義運算式;或服務和應用程式定義了運作的環境函式簽章會宣告輸入內容,且會在 CEL 運算式之外寫入。CEL 可用的函式程式庫會自動匯入。

在下例中,運算式會接受要求物件,而要求會包含憑證附加資訊權杖。運算式會傳回布林值,指出憑證附加資訊是否仍然有效。

// Check whether a JSON Web Token has expired by inspecting the 'exp' claim.
//
// Args:
//   claims - authentication claims.
//   now    - timestamp indicating the current system time.
// Returns: true if the token has expired.
//
timestamp(claims["exp"]) < now

環境

環境是由服務定義。嵌入 CEL 的服務和應用程式會宣告運算式環境。環境是一組可在運算式中使用的變數和函式。

CEL 類型檢查工具會使用 proto 宣告的宣告,確保運算式中的所有 ID 和函式參照都已正確宣告及使用。

剖析運算式的三個階段

處理運算式有三個階段:剖析、檢查和評估。CEL 最常見的模式是控制層在設定時間剖析及檢查運算式,並儲存 AST。

c71fc08068759f81.png

在執行階段,資料層會重複擷取及評估 AST。CEL 已針對執行階段效率完成最佳化,但不應在關鍵延遲程式碼路徑中剖析及檢查。

49ab7d8517143b66.png

CEL 可透過 ANTLR 詞法 / 剖析器文法,從使用者可理解的運算式剖析為抽象語法樹狀結構。剖析階段會發出以 proto 為基礎的抽象語法樹狀結構,其中 AST 中的每個 Expr 節點都包含一個整數 ID,用於將剖析和檢查期間產生的中繼資料編入索引。故意剖析時產生的 syntax.proto 代表了以字串形式輸入的內容,這個抽象表示法。

剖析運算式之後,可能會根據環境檢查,確保運算式中的所有變數和函式 ID 皆已宣告且皆可正確使用。類型檢查工具會產生 checked.proto,其中包含類型、變數和函式解析中繼資料,可大幅提升評估效率。

CEL 評估人員需要 3 個項目:

  • 任何自訂擴充功能的函式繫結
  • 變數繫結
  • 要評估的 AST

函式和變數繫結應與用來編譯 AST 的項目相符。這些輸入內容都可以重複用於多個評估作業,例如用於多組變數繫結評估的 AST,或是用於許多 AST 的相同變數,或是程序生命週期內使用的函式繫結 (一種常見情況)。

3. 設定

本程式碼研究室的程式碼位於 cel-go 存放區的 codelab 資料夾中。解決方案位於相同存放區的 codelab/solution 資料夾中。

複製並複製 cd 到存放區:

git clone https://github.com/google/cel-go.git 
cd cel-go/codelab

使用 go run 執行程式碼:

go run .

您應該會看到以下的輸出內容:

=== Exercise 1: Hello World ===

=== Exercise 2: Variables ===

=== Exercise 3: Logical AND/OR ===

=== Exercise 4: Customization ===

=== Exercise 5: Building JSON ===

=== Exercise 6: Building Protos ===

=== Exercise 7: Macros ===

=== Exercise 8: Tuning ===

CEL 套件在哪裡?

在編輯器中開啟 codelab/codelab.go。您應該會看到推動本程式碼研究室執行運動的主要函式,後面接著三個輔助函式區塊。第一組輔助程式協助進行 CEL 評估的各個階段:

  • Compile 函式:根據環境剖析及檢查輸入運算式
  • Eval 函式:根據輸入評估已編譯的程式
  • Report 函式:精確列印評估結果

此外,您還可使用 requestauth 輔助程式,來協助各種練習的輸入結構。

這些練習會使用短套件名稱來參照套件。如要深入瞭解詳細資料,請查看 google/cel-go 存放區中從套件與原始碼位置的對應:

套件

來源位置

說明

cel

cel-go/cel

頂層介面

參考資料

cel-go/common/types/ref

參照介面

類型

cel-go/common/types

執行階段類型值

4. 向全世界問好!

從所有程式設計語言的傳統做法中,我們會先建立並評估「Hello World!」。

設定環境

在編輯器中找到 exercise1 的宣告,並填寫以下內容來設定環境:

// 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)
    }
    // Will add the parse and check steps here
}

CEL 應用程式會根據環境評估運算式。env, err := cel.NewEnv() 可設定標準環境。

提供呼叫 cel.EnvOption 選項即可自訂環境。這些選項可以停用巨集、宣告自訂變數和函式等等。

標準 CEL 環境支援在語言規格中定義的所有類型、運算子、函式和巨集。

剖析及檢查運算式

環境設定完成後,您就可以剖析及檢查運算式。請將以下內容新增到函式:

// 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 !reflect.DeepEqual(checked.OutputType(), cel.StringType) {
        glog.Exitf(
            "Got %v, wanted %v output type",
            checked.OutputType(), cel.StringType,
        )
    }
    // Will add the planning step here
}

ParseCheck 呼叫傳回的 iss 值,是一份可能發生錯誤的問題清單。如果 iss.Err() 為非 nil,表示語法或語意有誤,程式無法進一步繼續進行。如果運算式格式正確,因此這些呼叫的結果會是可執行的 cel.Ast

評估運算式

剖析運算式並將其簽入 cel.Ast 後,可將其轉換為評估程式,此程式可使用功能選項自訂函式繫結和評估模式。請注意,您也可以使用 cel.CheckedExprToAstcel.ParsedExprToAst 函式從 proto 讀取 cel.Ast

規劃 cel.Program 後,您可以呼叫 Eval 來根據輸入內容評估。Eval 的結果會包含結果、評估詳細資料和錯誤狀態。

新增規劃並致電 eval

// 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 !reflect.DeepEqual(checked.OutputType(), cel.StringType) {
        glog.Exitf(
            "Got %v, wanted %v output 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()
}

為求簡潔,我們將在未來的練習中省略上述錯誤案例。

執行程式碼

在指令列中重新執行程式碼:

go run .

您應該會看到下列輸出內容,以及未來練習的預留位置。

=== Exercise 1: Hello World ===

------ input ------
(interpreter.emptyActivation)

------ result ------
value: Hello, World! (types.String)

5. 在函式中使用變數

大部分 CEL 應用程式都會宣告可在運算式中參照的變數。變數宣告會指定名稱和類型。變數的類型可以是CEL 內建類型、通訊協定緩衝區知名類型,或任何 protobuf 訊息類型 (只要其描述元也提供給 CEL 即可)。

新增函式

在編輯器中找出 exercise2 宣告,然後新增以下內容:

// 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")
   env, err := cel.NewEnv(
    // Add cel.EnvOptions values here.
  )
  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.
  claims := map[string]string{"group": "admin"}
  eval(program, request(auth("user:me@acme.co", claims), time.Now()))
  fmt.Println()
}

重新執行並瞭解錯誤

重新執行程式:

go run .

您應該會看到以下的輸出內容:

ERROR: <input>:1:1: undeclared reference to 'request' (in container '')
 | request.auth.claims.group == 'admin'
 | ^

type-檢查工具會為要求物件產生錯誤,方便您加入發生錯誤的來源程式碼片段。

6. 宣告變數

新增EnvOptions

在編輯器中,讓我們以 google.rpc.context.AttributeContext.Request 類型訊息的形式為要求物件提供宣告,藉此修正產生的錯誤,如下所示:

// 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")
  env, err := cel.NewEnv(
    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.
  claims := map[string]string{"group": "admin"}
  eval(program, request(auth("user:me@acme.co", claims), time.Now()))
  fmt.Println()
}

重新執行並瞭解錯誤

再次執行程式:

go run .

您應該會看到下列錯誤訊息:

ERROR: <input>:1:8: [internal] unexpected failed resolution of 'google.rpc.context.AttributeContext.Request'
 | request.auth.claims.group == 'admin'
 | .......^

如要使用參照 protobuf 訊息的變數,型別檢查工具也必須知道類型描述元。

使用 cel.Types 決定函式中要求的描述元:

// 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")
   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.
  claims := map[string]string{"group": "admin"}
  eval(program, request(auth("user:me@acme.co", claims), time.Now()))
  fmt.Println()
}

已成功重新執行!

再次執行程式:

go run .

畫面應顯示如下:

=== Exercise 2: Variables ===

request.auth.claims.group == 'admin'

------ input ------
request = time: <
  seconds: 1569255569
>
auth: <
  principal: "user:me@acme.co"
  claims: <
    fields: <
      key: "group"
      value: <
        string_value: "admin"
      >
    >
  >
>

------ result ------
value: true (types.Bool)

為了進行審查,我們剛剛宣告錯誤的變數、為其指派類型描述元,然後在運算式評估中參照該變數。

7. 邏輯 AND/OR

CEL 最獨特的功能之一,就是使用組合邏輯運算子。條件分支的任一側都可短路評估,即使遇到錯誤或部分輸入也是如此。

換句話說,CEL 會找出評估順序並盡可能提供結果,忽略其他評估訂單中可能發生的錯誤,甚至遺漏資料。應用程式可以利用這項屬性,將評估成本降到最低,在結果沒有這類結果的情況下,延遲收集昂貴的輸入內容。

我們要新增一個 AND/OR 範例,然後使用不同的輸入內容來嘗試,瞭解 CEL 短路評估方式。

建立函式

透過編輯器,將下列內容新增至練習 3:

// exercise3 demonstrates how CEL's commutative logical operators work.
//
// Construct an expression which checks if the `request.auth.claims.group`
// value is equal to admin or the `request.auth.principal` is
// `user:me@acme.co`. Issue two requests, one that specifies the proper 
// user, and one that specifies an unexpected user.
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"),
    ),
  )
}

接著,加入這個 OR 陳述式,如果使用者是 admin 群組的成員,或擁有特定電子郵件 ID,系統會傳回 true:

// exercise3 demonstrates how CEL's commutative logical operators work.
//
// Construct an expression which checks if the `request.auth.claims.group`
// value is equal to admin or the `request.auth.principal` is
// `user:me@acme.co`. Issue two requests, one that specifies the proper 
// user, and one that specifies an unexpected user.
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)
}

最後,新增 eval 案例,以用於評估沒有空白聲明集的使用者:

// 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. Issue two requests, one that specifies the proper user,
// and one that specifies an unexpected user.
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)

  emptyClaims := map[string]string{}
  eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now()))
}

執行含有空白憑證附加資訊組合的程式碼

重新執行程式後,您應該會看到下列新的輸出內容:

=== Exercise 3: Logical AND/OR ===

request.auth.claims.group == 'admin'
    || request.auth.principal == 'user:me@acme.co'

------ input ------
request = time: <
  seconds: 1569302377
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: true (types.Bool)

更新評估案例

接著,請更新評估案例,傳入含有空白聲明集的另一個主體:

// 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. Issue two requests, one that specifies the proper user,
// and one that specifies an unexpected user.
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)

  emptyClaims := map[string]string{}
  eval(program, request(auth("other:me@acme.co", emptyClaims), time.Now()))
}

逐步執行程式碼

重新執行程式

go run .

您應該會看到下列錯誤訊息:

=== Exercise 3: Logical AND/OR ===

request.auth.claims.group == 'admin'
    || request.auth.principal == 'user:me@acme.co'

------ input ------
request = time: <
  seconds: 1588989833
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: true (types.Bool)

------ input ------
request = time: <
  seconds: 1588989833
>
auth: <
  principal: "other:me@acme.co"
  claims: <
  >
>

------ result ------
error: no such key: group

在 protobuf 中,我們可以瞭解預期的欄位和類型。在對應和 JSON 值中,我們不知道金鑰是否存在。由於缺少索引鍵沒有安全的預設值,因此 CEL 會預設為錯誤。

8. 自訂函式

雖然 CEL 包含許多內建函式,但在某些情況下,自訂函式可能相當實用。舉例來說,自訂函式可用來改善常見條件的使用者體驗,或顯示與情境相關的狀態

在本練習中,我們會探索如何公開函式,將常用的檢查套件封裝在一起。

呼叫自訂函式

首先,請建立程式碼來設定名為 contains 的覆寫值,用來判斷對應中是否有索引鍵,且具有特定值。保留函式定義和函式繫結的預留位置:

// 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
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    // Declare the request.
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
    // Add the custom function declaration and binding here with cel.Function()
  )
  ast := compile(env,
    `request.auth.claims.contains('group', 'admin')`,
    cel.BoolType)

  // Construct the program plan and provide the 'contains' function impl.
  // Output: false
  program, _ := env.Program(ast)
  emptyClaims := map[string]string{}
  eval(
    program,
    request(auth("user:me@acme.co", emptyClaims), time.Now()),
  )
  fmt.Println()
}  

執行程式碼並瞭解錯誤

重新執行程式碼,您應該會看到下列錯誤:

ERROR: <input>:1:29: found no matching overload for 'contains' applied to 'map(string, dyn).(string, string)'
 | request.auth.claims.contains('group', 'admin')
 | ............................^

如要修正錯誤,我們必須將 contains 函式加入目前宣告要求變數的宣告清單中。

加入下列 3 行以宣告參數化類型。(這跟複雜,因為任何函式超載都會用於 CEL)

// 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, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    // Declare the request.
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
    // Add the custom function declaration and binding here with cel.Function()
  )
  ast := compile(env,
    `request.auth.claims.contains('group', 'admin')`,
    cel.BoolType)

  // Construct the program plan.
  // Output: false
  program, _ := env.Program(ast)
  emptyClaims := map[string]string{}
  eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now()))
  fmt.Println()
}

新增自訂函式

接著,我們會新增一個 include 函式,將使用參數化類型:

// 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,
        // Provide the implementation using cel.FunctionBinding()
      ),
    ),
  )
  ast := compile(env,
    `request.auth.claims.contains('group', 'admin')`,
    cel.BoolType)

  // Construct the program plan and provide the 'contains' function impl.
  // Output: false
  program, _ := env.Program(ast)
  emptyClaims := map[string]string{}
  eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now()))
  fmt.Println()
}

執行程式以瞭解錯誤

跑步。您應該會看到下列關於缺少執行階段函式的錯誤:

------ result ------
error: no such overload: contains

使用 cel.FunctionBinding() 函式將函式實作提供給 NewEnv 宣告:

// 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()
}

現在應能順利執行程式:

=== Exercise 4: Custom Functions ===

request.auth.claims.contains('group', 'admin')

------ input ------
request = time: <
  seconds: 1569302377
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: false (types.Bool)

版權聲明存在後,會發生什麼情況?

關於額外抵免額,請嘗試針對輸入值設定管理員憑證附加資訊,驗證含有超載情形時,也在聲明存在時傳回 true。您應該會看到以下的輸出內容:

=== Exercise 4: Customization ===

request.auth.claims.contains('group','admin')

------ input ------
request = time: <
  seconds: 1588991010
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: false (types.Bool)

------ input ------
request = time: <
  seconds: 1588991010
>
auth: <
  principal: "user:me@acme.co"
  claims: <
    fields: <
      key: "group"
      value: <
        string_value: "admin"
      >
    >
  >
>

------ result ------
value: true (types.Bool)

在繼續下一步之前,建議您檢查 mapContainsKeyValue 函式本身:

// 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. Such an error might occur with
  // a map whose key-type is listed as 'dyn'.
  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])
}

為了盡可能簡化擴充功能,自訂函式的簽名需要 ref.Val 類型的引數。這樣做的缺點是,如果擴充功能的簡化,實作器就會增加負擔,確保所有值類型都能正確處理。如果輸入引數類型或計數與函式宣告不符,系統應傳回 no such overload 錯誤。

cel.FunctionBinding() 會新增執行階段類型保護設定,確保執行階段合約與環境內的類型檢查宣告相符。

9. 建立 JSON

CEL 也可以產生非布林值的輸出內容,例如 JSON。請將以下內容新增到函式:

// 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")
    env, _ := cel.NewEnv(
      // Declare the 'now' variable as a Timestamp.
      // cel.Variable("now", cel.TimestampType),
    )
    // Note the quoted keys in the CEL map literal. For proto messages the
    // field names are unquoted as they represent well-defined identifiers.
    ast := compile(env, `
        {'sub': 'serviceAccount:delegate@acme.co',
         'aud': 'my-project',
         'iss': 'auth.acme.com:12350',
         'iat': now,
         'nbf': now,
         'exp': now + duration('300s'),
         'extra_claims': {
             'group': 'admin'
         }}`,
        cel.MapType(cel.StringType, cel.DynType))

    program, _ := env.Program(ast)
    out, _, _ := eval(
        program,
        map[string]interface{}{
            "now": &tpb.Timestamp{Seconds: time.Now().Unix()},
        },
    )
    fmt.Printf("------ type conversion ------\n%v\n", out)
    fmt.Println()
}

執行程式碼

重新執行程式碼,您應該會看到下列錯誤:

ERROR: <input>:5:11: undeclared reference to 'now' (in container '')
 |    'iat': now,
 | ..........^
... and more ...

cel.TimestampType 類型的 now 變數宣告新增至 cel.NewEnv(),然後再次執行:

// 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")
    env, _ := cel.NewEnv(
      cel.Variable("now", cel.TimestampType),
    )
    // Note the quoted keys in the CEL map literal. For proto messages the
    // field names are unquoted as they represent well-defined identifiers.
    ast := compile(env, `
        {'sub': 'serviceAccount:delegate@acme.co',
         'aud': 'my-project',
         'iss': 'auth.acme.com:12350',
         'iat': now,
         'nbf': now,
         'exp': now + duration('300s'),
         'extra_claims': {
             'group': 'admin'
         }}`,
        cel.MapType(cel.StringType, cel.DynType))

     // Hint:
     // Convert `out` to JSON using the valueToJSON() helper method.
     // The valueToJSON() function calls ConvertToNative(&structpb.Value{})
     // to adapt the CEL value to a protobuf JSON representation and then
     // uses the jsonpb.Marshaler utilities on the output to render the JSON
     // string.
    program, _ := env.Program(ast)
    out, _, _ := eval(
        program,
        map[string]interface{}{
            "now": time.Now(),
        },
    )
    fmt.Printf("------ type conversion ------\n%v\n", out)
    fmt.Println()
}

重新執行程式碼,應該就會成功:

=== Exercise 5: Building JSON ===

  {'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'
   }

------ input ------
now = seconds: 1569302377

------ result ------
value: &{0xc0002eaf00 map[aud:my-project exp:{0xc0000973c0} extra_claims:0xc000097400 iat:{0xc000097300} iss:auth.acme.com:12350 nbf:{0xc000097300} sub:serviceAccount:delegate@acme.co] {0x8c8f60 0xc00040a6c0 21}} (*types.baseMap)

------ type conversion ------
&{0xc000313510 map[aud:my-project exp:{0xc000442510} extra_claims:0xc0004acdc0 iat:{0xc000442450} iss:auth.acme.com:12350 nbf:{0xc000442450} sub:serviceAccount:delegate@acme.co] {0x9d0ce0 0xc0004424b0 21}}

程式會執行,但輸出值 out 必須明確轉換為 JSON。在此案例中,內部 CEL 表示法為 JSON 可轉換,因為這只指 JSON 支援的類型,或有已知的 Proto JSON 對應

// 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")
...
    fmt.Printf("------ type conversion ------\n%v\n", valueToJSON(out))
    fmt.Println()
}

codelab.go 檔案中使用 valueToJSON 輔助函式轉換類型後,您應該會看到以下額外輸出內容:

------ type conversion ------
  {
   "aud": "my-project",
   "exp": "2019-10-13T05:54:29Z",
   "extra_claims": {
      "group": "admin"
     },
   "iat": "2019-10-13T05:49:29Z",
   "iss": "auth.acme.com:12350",
   "nbf": "2019-10-13T05:49:29Z",
   "sub": "serviceAccount:delegate@acme.co"
  }

10. 建構原型

CEL 可以針對編譯至應用程式的任何訊息類型建構 protobuf 訊息。新增函式,從輸入 jwt 建構 google.rpc.context.AttributeContext.Request

// 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(
      // Add cel.Container() option for 'google.rpc.context.AttributeContext'
      cel.Types(requestType),
      // Add cel.Variable() option for 'jwt' as a map(string, Dyn) type
      // and for 'now' as a timestamp.
  )

  // 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]interface{}{
          "jwt": map[string]interface{}{
              "sub": "serviceAccount:delegate@acme.co",
              "aud": "my-project",
              "iss": "auth.acme.com:12350",
              "extra_claims": map[string]string{
                  "group": "admin",
              },
          },
          "now": time.Now(),
      },
  )

  // Hint: Unwrap the CEL value to a proto. Make sure to use the
  // `ConvertToNative(reflect.TypeOf(requestType))` to convert the dynamic proto
  // message to the concrete proto message type expected.
  fmt.Printf("------ type unwrap ------\n%v\n", out)
  fmt.Println()
}

執行程式碼

重新執行程式碼,您應該會看到下列錯誤:

ERROR: <input>:2:10: undeclared reference to 'Request' (in container '')
 |   Request{
 | .........^

容器基本上等同於命名空間或套件,但精細程度可達 protobuf 訊息名稱。CEL 容器會使用與 Protobuf 和 C++ 相同的命名空間解析規則,來判斷特定變數、函式或類型名稱的宣告位置。

就容器 google.rpc.context.AttributeContext 而言,型別檢查工具和評估工具會嘗試針對所有變數、類型和函式嘗試下列 ID 名稱:

  • google.rpc.context.AttributeContext.<id>
  • google.rpc.context.<id>
  • google.rpc.<id>
  • google.<id>
  • <id>

如果是絕對名稱,請在變數、類型或函式參照前方加上半形句號。在此範例中,.<id> 運算式只會搜尋頂層 <id> ID,不會先在容器內查看。

請嘗試將 cel.Container("google.rpc.context.AttributeContext") 選項指定為 CEL 環境,然後再次執行:

// 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(
    // Add cel.Container() option for 'google.rpc.context.AttributeContext'
    cel.Container("google.rpc.context.AttributeContext.Request"),
    cel.Types(requestType),
    // Later, add cel.Variable() options for 'jwt' as a map(string, Dyn) type
    // and for 'now' as a timestamp.
    // cel.Variable("now", cel.TimestampType),
    // cel.Variable("jwt", cel.MapType(cel.StringType, cel.DynType)),
  )

 ...
}

您應該會看到以下輸出內容:

ERROR: <input>:4:16: undeclared reference to 'jwt' (in container 'google.rpc.context.AttributeContext')
 |     principal: jwt.iss + '/' + jwt.sub,
 | ...............^

... 還有更多錯誤...

接下來,請宣告 jwtnow 變數,程式應會正常執行:

=== Exercise 6: Building Protos ===

  Request{
   auth: Auth{
    principal: jwt.iss + '/' + jwt.sub,
    audiences: [jwt.aud],
    presenter: 'azp' in jwt ? jwt.azp : "",
    claims: jwt
   },
   time: now
  }

------ input ------
jwt = {
  "aud": "my-project",
  "extra_claims": {
    "group": "admin"
  },
  "iss": "auth.acme.com:12350",
  "sub": "serviceAccount:delegate@acme.co"
}
now = seconds: 1588993027

------ result ------
value: &{0xc000485270 0xc000274180 {0xa6ee80 0xc000274180 22} 0xc0004be140 0xc0002bf700 false} (*types.protoObj)

----- type unwrap ----
&{0xc000485270 0xc000274180 {0xa6ee80 0xc000274180 22} 0xc0004be140 0xc0002bf700 false}

如要獲得額外的功勞,您也應該在訊息上呼叫 out.Value() 來解除類型,看看結果如何變化。

11. 巨集

可使用巨集在剖析時操控 CEL 程式。巨集會比對呼叫簽章,並操控輸入呼叫及其引數,以產生新的子運算式 AST。

巨集可用來在 AST 中實作無法直接在 CEL 中編寫的複雜邏輯。舉例來說,has 巨集可用於測試欄位存在情形。如存在的理解巨集,則全部會取代輸入清單或對應上設有受限疊代的函式呼叫。這兩種概念在語法層級中都不可能,但可以透過巨集展開來實現。

新增並執行下一項運動:

// 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.ClearMacros(),
      cel.Variable("jwt", cel.MapType(cel.StringType, 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]interface{}{
            "jwt": map[string]interface{}{
                "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()
}

您應該會看到下列錯誤:

ERROR: <input>:1:25: undeclared reference to 'c' (in container '')
 | jwt.extra_claims.exists(c, c.startsWith('group'))
 | ........................^

... 還有更多 ...

由於巨集尚未啟用,發生這類錯誤。如要啟用巨集,請移除 cel.ClearMacros(),然後再次執行:

=== Exercise 7: Macros ===

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')))

------ input ------
jwt = {
  "aud": "my-project",
  "extra_claims": {
    "group1": [
      "admin@acme.co",
      "analyst@acme.co"
    ],
    "groupN": [
      "forever@acme.co"
    ],
    "labels": [
      "metadata",
      "prod",
      "pii"
    ]
  },
  "iss": "auth.acme.com:12350",
  "sub": "serviceAccount:delegate@acme.co"
}

------ result ------
value: true (types.Bool)

以下是目前支援的巨集:

巨集

簽名

說明

全部

r.all(var, Cond)

測試 Cond 對「所有」 範圍 r 的變數是否都為 true。

存在

r.exists(var, cond)

測試 Cond 對範圍 r 中的「任何」 變數是否屬實。

exists_one

r.exists_one(var, Cond)

測試 Cond 是否在範圍 r 中只針對一個 變數評估 true。

篩選器

r.filter(var, cond)

針對清單,建立新的清單,其中每個範圍 r 內的變數都符合條件。如果是對應,請建立新的清單,讓範圍 r 中的每個鍵變數滿足條件。

地圖

r.map(var, expr)

建立新的清單,其中範圍 r 中的每個變數都會由 expr 轉換。

r.map(var, cond, expr)

與雙引數對應相同,但在值轉換之前,有條件式 Cond 篩選器。

has(a.b)

針對值 a 的 b 存在測試:用於地圖的 JSON 測試定義。針對 proto,測試非預設原始值或 1 或一組訊息欄位。

當範圍 r 引數是 map 類型時,var 是對應鍵;對於 list 類型值,var 會是清單元素值。allexistsexists_onefiltermap 巨集會執行 AST 重寫作業,針對受到輸入大小限制的每次疊代執行。

極限的理解能力可確保 CEL 計畫不會觸發完整作業,但就輸入資料而言,是以超線性時間進行評估。請謹慎或完全不使用這些巨集。頻繁使用理解力,通常代表自訂函式有助於改善使用者體驗並提升效能。

12. 調整

目前有幾項 CEL-Go 專屬的功能,但重點在於未來其他 CEL 實作的計畫。下列練習展示了同一個 AST 的不同計畫:

// exercise8 covers features of CEL-Go which can be used to improve
// performance and debug evaluation behavior.
//
// Turn on the optimization, exhaustive eval, and state tracking
// 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)

    // Try the different cel.EvalOptions flags when evaluating this AST for
    // the following use cases:
    // - cel.OptOptimize: optimize the expression performance.
    // - cel.OptExhaustiveEval: turn off short-circuiting.
    // - cel.OptTrackState: track state and compute a residual using the
    //   interpreter.PruneAst function.
    program, _ := env.Program(ast)
    eval(program, cel.NoVars())

    fmt.Println()
}

最佳化工具

啟用最佳化標記後,CEL 會花更多時間預先建立清單和對應常值,並最佳化特定呼叫 (例如 in 運算子),成為真正的成員資格測試:

    // Turn on optimization.
    trueVars := map[string]interface{}{"x": int64(4), "y": uint64(2)}
    program, _ := env.Program(ast, cel.EvalOptions(cel.OptOptimize))
    // Try benchmarking 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]interface{}{"x": int64(6), "y": uint64(2)}
    program, _ = env.Program(ast, cel.EvalOptions(cel.OptExhaustiveEval))
    eval(program, falseVars)

您應該會看到每個運算式 ID 的運算式評估狀態清單:

------ eval states ------
1: 6 (types.Int)
2: false (types.Bool)
3: &{0xc0000336b0 [1 2 3 4 5] {0x89f020 0xc000434760 151}} (*types.baseList)
4: 1 (types.Int)
5: 2 (types.Int)
6: 3 (types.Int)
7: 4 (types.Int)
8: 5 (types.Int)
9: uint (*types.Type)
10: 2 (types.Uint)
11: true (types.Bool)
12: uint (*types.Type)
13: false (types.Bool)

運算式 ID 2 與第一個運算子 in 運算子的結果對應。分支,運算式 ID 11 則與第二個中的 == 運算子對應。在正常評估下,運算式在計算 2 後會發生短路。如果 y 未曾執行 uint,狀態就會顯示運算式失敗的兩個原因,而不僅僅是單一原因。

13. 涵蓋內容

如果您需要運算式引擎,建議您使用 CEL。如果專案需要在重視效能的情況下執行使用者設定,就很適合使用 CEL。

在先前的練習中,我們希望您能輕鬆將資料傳送至 CEL,並取回輸出結果或決策。

我們希望您可以對自己能執行的作業類型有所瞭解,無論是布林決策、產生 JSON 或 Protobuffer 訊息等。

我們希望您會瞭解如何運用表情及其作用。我們也瞭解拓展網路的常見做法。