OPAを使ったSPIREのAPIアクセス制御について


このエントリでは、SPIREに新しく実装されたOPAを使ったAPIのアクセス制御について、調査した結果をまとめています。

## 背景

現在、SPIRE Serverが提供するAPIはgRPC Serviceとして提供されており、Methodの処理前にリクエスト元のコンテキストによるアクセス制御が実装されています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15164/fa4fd679-03ab-feba-7185-70bdfcc18ca8.png" width="500px">

### SPIREでの主なRole

| Role   | 説明                                                            |
| ---------- | --------------------------------------------- |
| Admin      | TCP経由でのAPI操作などで必要。RegistrationEntryで定義される。                    |
| Local      | UNIX Domain Socket経由でのアクセスで割り当てられる                            |
| Agent      | Node Attestationに成功したSPIRE Agentに割り当てられる                      |
| Downstream | Nested構成の際のDownstream Serverに割り当てられる。RegistrationEntryで定義される。 |

### 各API毎のアクセス制御

| API(gRPC Method) | 許可されたRole |
|:-----------------|:----|
|`/spire.api.server.svid.v1.SVID/MintX509SVID`|                   Admin, Local,
|`/spire.api.server.svid.v1.SVID/MintJWTSVID`|                    Admin, Local,
|`/spire.api.server.svid.v1.SVID/BatchNewX509SVID`|               Agent,
|`/spire.api.server.svid.v1.SVID/NewJWTSVID`|                     Agent,
|`/spire.api.server.svid.v1.SVID/NewDownstreamX509CA`|            Downstream,
|`/spire.api.server.bundle.v1.Bundle/GetBundle`|                  Any,
|`/spire.api.server.bundle.v1.Bundle/AppendBundle`|               Admin, Local,
|`/spire.api.server.bundle.v1.Bundle/PublishJWTAuthority`|        Downstream,
|`/spire.api.server.bundle.v1.Bundle/CountBundles`|               Admin, Local,
|`/spire.api.server.bundle.v1.Bundle/ListFederatedBundles`|       Admin, Local,
|`/spire.api.server.bundle.v1.Bundle/GetFederatedBundle`|         Admin, Local, Agent
|`/spire.api.server.bundle.v1.Bundle/BatchCreateFederatedBundle`| Admin, Local,
|`/spire.api.server.bundle.v1.Bundle/BatchUpdateFederatedBundle`| Admin, Local,
|`/spire.api.server.bundle.v1.Bundle/BatchSetFederatedBundle`|    Admin, Local,
|`/spire.api.server.bundle.v1.Bundle/BatchDeleteFederatedBundle`| Admin, Local,
|`/spire.api.server.debug.v1.Debug/GetInfo`|                      Local,
|`/spire.api.server.entry.v1.Entry/CountEntries`|                 Admin, Local,
|`/spire.api.server.entry.v1.Entry/ListEntries`|                  Admin, Local,
|`/spire.api.server.entry.v1.Entry/GetEntry`|                     Admin, Local,
|`/spire.api.server.entry.v1.Entry/BatchCreateEntry`|             Admin, Local,
|`/spire.api.server.entry.v1.Entry/BatchUpdateEntry`|             Admin, Local,
|`/spire.api.server.entry.v1.Entry/BatchDeleteEntry`|             Admin, Local,
|`/spire.api.server.entry.v1.Entry/GetAuthorizedEntries`|         Agent,
|`/spire.api.server.agent.v1.Agent/CountAgents`|                  Admin, Local,
|`/spire.api.server.agent.v1.Agent/ListAgents`|                   Admin, Local,
|`/spire.api.server.agent.v1.Agent/GetAgent`|                     Admin, Local,
|`/spire.api.server.agent.v1.Agent/DeleteAgent`|                  Admin, Local,
|`/spire.api.server.agent.v1.Agent/BanAgent`|                     Admin, Local,
|`/spire.api.server.agent.v1.Agent/AttestAgent`|                  Any,
|`/spire.api.server.agent.v1.Agent/RenewAgent`|                   Agent,
|`/spire.api.server.agent.v1.Agent/CreateJoinToken`|              Admin, Local,
|`/grpc.health.v1.Health/Check`|                                  Local,
|`/grpc.health.v1.Health/Watch`|                                  Local,

ただしそれらのルールはソースコード内に静的に定義されており、柔軟な運用が難しいという課題がありました。

例えば、SPIRE Serverとは別のノードからRegistrationEntryの一覧のみを取得したいとなった場合(`/spire.api.server.entry.v1.Entry/ListEntries`)には、リクエスト元にはAdmin Roleが必要になってしまい、不必要な権限まで与えてないといけないといった問題が出てきます。
実運用を考えると、APIを操作するクライアント毎に管理者権限を渡す必要があることは、導入にあたっての大きな課題となる場合もあります。

この問題に対してコミュニティでは以下のIssueで検討が進めら、[OPA](https://www.openpolicyagent.org/)をベースにしたアクセス制御の実装案が採用されました。

Issue → [spiffe/spire#1975](https://github.com/spiffe/spire/issues/1975)

## OPAを使ったアクセス制御

2021/08 現在のSPIREの最新バージョンは `v1.0.1`ですが、次のパッチバージョンの`v1.0.2`からはOPAベースのアクセス制御になります。(アクセス制御の挙動に変更はないのでパッチバージョンでリリースされている？)

cf. https://github.com/spiffe/spire/blob/main/doc/authorization_policy_engine.md

アーキテクチャとしては、別途OPAサーバを用意するのではなくライブラリとして組み込むパターンとなります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15164/f24c76f9-29c7-73df-90c2-1d8dcdb2a57f.png" width="400px">

cf. https://www.openpolicyagent.org/docs/latest/extensions/#custom-built-in-functions-in-go

SPIREからOPAへの入力には以下のようなデータが渡されます。

- `Caller`: リクエスト元のSPIFFE ID
- `FullMethod`: gRPC Service Methodの完全修飾名(e.g. `/spire.api.server.svid.v1.SVID/MintX509SVID"`)
- `Req`: リクエストBodyから受け取ったデータ

```go
type Input struct {
	// Caller is the authenticated identity of the actor making a request.
	Caller string `json:"caller"`

	// FullMethod is the fully-qualified name of the proto rpc service method.
	FullMethod string `json:"full_method"`

	// Req represents data received from the request body. It MUST be a
	// protobuf request object with fields that are serializable as JSON,
	// since they will be used in policy definitions.
	Req interface{} `json:"req"`
}
```

SPIREは各APIリクエストに対して、上記のInputを引数としてOPAを呼び出します。

```go
	rs, err := e.rego.Rego(rego.Input(input)).Eval(ctx)
```

OPAではRegoで記述されたPolicyにしたがってInput情報とDataを評価し、SPIREはその結果を使ってアクセス制御を行います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15164/0e21adff-5d56-f77c-78e0-f8d949798bae.jpeg" width="600px">

SPIREに組み込まれているデフォルトの挙動は、DataにてgRPC Method毎に必要な権限が定義されており、PolicyではInputの値に含まれるMethod名からDataを参照して必要な権限を確認します。
つまり、これまでソースコード内で定義されていたAPIとRoleの紐付けのようなことを、OPAのDataを使って表現しています。

e.g. DataでのAPIと操作可能なRoleの紐付け例

```json
{
  "full_method": "/spire.api.server.svid.v1.SVID/MintX509SVID",
  "allow_admin": true,
  "allow_local": true
}
```

この場合、`/spire.api.server.svid.v1.SVID/MintX509SVID`のリクエストは、`Admin`または`Local`のRoleに対して許可されている旨の結果がSPIREに返されます。
SPIREはリクエスト元のコンテキストからRoleを確認し、アクセス制御を行います。

デフォルトのData → https://github.com/spiffe/spire/blob/f8d3d833366dd2fc454c4cf10683eb53db4b976b/pkg/server/authpolicy/policy_data.json

デフォルトのPolicy → https://github.com/spiffe/spire/blob/f8d3d833366dd2fc454c4cf10683eb53db4b976b/pkg/server/authpolicy/policy.rego

デフォルトのアクセス制御の挙動は冒頭の表と変わりありませんが、(`v1.0.2`時点ではexperimentalな)拡張機能を使ってポリシーを拡張することも可能となっています。

cf. https://github.com/spiffe/spire/blob/main/doc/authorization_policy_engine.md#extending-the-policy

## ポリシーの拡張

カスタムポリシーを使ってアクセス制御を行う場合には、自前でDataとPolicyを定義したファイルを用意する必要があります。

```hcl
server {
    experimental {
        auth_opa_policy_engine {
            local {
                rego_path = "./conf/server/policy.rego"
                policy_data_path = "./conf/server/policy_data.json"
            }
        }
    }
}
```
PolicyはRegoで記述しますが、SPIREの実装は評価のresultとして以下のフィールドを返すことを期待しており、trueがセットされたフィールドに応じて、それぞれの内容でリクエストのコンテキストを確認します。`allow`が`true`の場合、SPIREはリクエストを無条件に認可します。
また、SPIREではアクセス制御は各フィールドの論理和となっていることを理解しておく必要があります。resultに`false`のフィールドがあったとしても、他にリクエストが条件を満たしている`true`のフィールドが存在する場合には許可されます。

```rego
result = {
  "allow": true/false,
  "allow_if_admin": true/false,
  "allow_if_local": true/false,
  "allow_if_downstream": true/false,
  "allow_if_agent": true/false,
}
```

- `allow`: a boolean that if true, will authorize the call
- `allow_if_local`: a boolean that if true, will authorize the call only if the caller is a local UNIX socket call
- `allow_if_admin`: a boolean that if true, will authorize the call only if the caller is a SPIFFE ID with the Admin flag set
- `allow_if_downstream`: a boolean that if true, will authorize the call only if the caller is a SPIFFE ID that is downstream
- `allow_if_agent`: a boolean that is true, will authorize the call only if the caller is an agent.

cf. https://github.com/spiffe/spire/blob/main/doc/authorization_policy_engine.md#rego-policy

カスタムポリシーではresultが上記のルールに従ってさえいれば、他の部分は好きに定義できます。

例えばBatchCreateEntry APIに対してデフォルトのルールでは`Admin`または`Local`のコンテキストからの操作のみ許可されていますが、デフォルトのルールに以下のようなルールを追加すると、さらに特定のSPIFFE IDをもつユーザに対して特定のPathのEntryの登録を許可させることができます。

```json
{
  "apis": [
    {
      "full_method": "/spire.api.server.svid.v1.SVID/MintX509SVID",
      "allow_admin": true,
      "allow_local": true
    },
    ...
    {
      "full_method": "/spire.api.server.entry.v1.Entry/BatchCreateEntry",
      "allow_admin": true,
      "allow_local": true,
      "entry_create_namespaces": [ # 追加したルール
        {
          "user": "spiffe://example.org/schedulers/finance",
          "path_namespace": "^/finance"
        }
      ]
    },
    ...
}
```

Policyでは`allow`のブロックを使って追加した条件のチェックを行います。

```rego
result = {
  "allow": allow, 
  "allow_if_admin": allow_if_admin,
  "allow_if_local": allow_if_local,
  "allow_if_downstream": allow_if_downstream,
  "allow_if_agent": allow_if_agent,
}
   
# その他デフォルト部分は省略
...

# Any allow check
allow = true {
    r := data.apis[_]
    r.full_method == input.full_method

    r.allow_any
}

### ここから追加
allow = true {
    check_entry_create_namespace
}

check_entry_create_namespace {
​​    r := data.apis[_]
    r.full_method == input.full_method
    b = r.entry_create_namespaces[_]
    b.user == input.caller

    # spiffe id to be registered is in correct namespace
    re_match(b.path_namespace, input.req.entries[_].spiffe_id.path)
}
### ここまで
```

## カスタムポリシーの動作確認

上記のポリシーの確認をするために、APIにTCP経由で接続してEntryを作成する適当なアプリケーションを用意して検証してみました。
テストアプリケーション → https://gist.github.com/hiyosi/82bd11f64a9634bf6742124df35bf4a5

まずは、テストアプリケーションで利用するためのSPIFEF IDのSVIDを発行します。(Policy Dataで定義したSPIFFE IDのもの)

```bash
root@spire-dev:/spire# ./bin/spire x509 mint \
  -spiffeID spiffe://example.org/schedulers/finance \
  -write finance
    
root@spire-dev:/spire# openssl x509 -in finance/svid.pem  -noout -text |grep "URI:"
                URI:spiffe://example.org/schedulers/finance            
```

発行したSVIDでは、特定のPathのEntryのみ登録できます。

```bash
root@spire-dev:/spire# ./create-entry \
  --bundle-path finance/bundle.pem \
  --key-path finance/key.pem \
  --svid-path finance/svid.pem \
  --parent-id spiffe://example.org/test \
  --spiffe-id spiffe://example.org/finance/workload-00 \
  --selector unix:uid:0 
Entry ID: 636ced63-697f-4fd4-b80c-0aca773da722
SPIFFE ID: trust_domain:"example.org" path:"/finance/workload-00"
Parent ID: trust_domain:"example.org" path:"/test"
    ​​
root@spire-dev:/spire# ./create-entry \
  --bundle-path finance/bundle.pem \
  --key-path finance/key.pem \
  --svid-path finance/svid.pem \
  --parent-id spiffe://example.org/test \
  --spiffe-id spiffe://example.org/test/workload-00 \  # /testから始まるPathは許可されていない
  --selector unix:uid:0 
2021/08/30 06:51:40 Error from Entry API: rpc error: code = PermissionDenied desc = authorization denied for method /spire.api.server.entry.v1.Entry/BatchCreateEntry
```

UDS経由のアクセス(=`Local`)の場合は、引き続きどのようなエントリも登録できます。

```bash
root@spire-dev:/spire# ./bin/spire-server entry create \
  -parentID spiffe://example.org/test \
  -spiffeID spiffe://example.org/finance/workload-01 \
  -selector unix:uid:0
Entry ID         : 16a3b9e4-6bf2-4558-b06a-0f165d46a34f
SPIFFE ID        : spiffe://example.org/finance/workload-01
Parent ID        : spiffe://example.org/test
Revision         : 0
TTL              : default
Selector         : unix:uid:0
    
root@spire-dev:/spire# ./bin/spire-server entry create \
  -parentID spiffe://example.org/test \
  -spiffeID spiffe://example.org/test/workload-01 \
  -selector unix:uid:0
Entry ID         : fbffb9dc-e0be-44e6-a8c4-899dbdea4e29
SPIFFE ID        : spiffe://example.org/test/workload-01
Parent ID        : spiffe://example.org/test
Revision         : 0
TTL              : default
Selector         : unix:uid:0
```

その他に公式ドキュメントでは例が提示されています。

cf. https://github.com/spiffe/spire/blob/main/doc/authorization_policy_engine.md#extending-the-policy

## 所感

Admin, Local, Agent, Downstream, Any といったこれまでのRoleの考え方はそのまま引き継がれており、またOPAを使ったアクセス制御になったからといって独自でRoleを定義したりできるような感じでは無さそうでした。

SPIREの仕組みやRegoによる記述がある程度理解できると、基本のRoleをベースに完全に独自ルールのポリシーを定義することもできると思いますが、漏れがないようにテストするのは大変かなという印象です。。
いきなりゼロからポリシーを定義するよりは、デフォルトのポリシーをベースに不要なAPIへのアクセスを閉じたり、制限を強めたりしていくところから始めていくのが良さそうかなと思いました。