Carpe Diem

備忘録

OPAでREST APIのAuthorizationを行う

概要

REST APIのAuthorizationをOPAに任せ、ミドルウェアなどで統一した処理にすることで認可処理の運用や柔軟性を向上させることができます。

今回はその実装方法を紹介します。

環境

  • opa v0.32.0
  • go v1.17

アーキテクチャ

アーキテクチャのイメージとしては以下です。
アクセスしたクライアントがその権限を持っているかOPAでチェックし、権限を持っていればアプリケーションサーバ側の処理④を実行します。

f:id:quoll00:20210913065102p:plain

ポイント

REST APIのAuthorizationとして利用する際に考慮しておくと良いポイントを挙げます。

API仕様の変更に依存しない

認可システムを分離したのにAPIの些細な変更によって認可側も変更が必要になる(密結合)のはよろしくないです。なのでなるべく変更しない要素に対して依存させます。

APIの中で安定しているのは

  • HTTP method
  • URL path

です。
なぜならこれらが変更されるとクライアント側の互換性が失われるため、多くの場合一度決めたら変わることはありません

なのでルールはそれらをベースにするのが良いです。

# GET /articles/:id
allow {
    some id
    input.method = "GET"
    input.path = ["articles", id]
}

変更が多いデータはOPA側に持たせない

RBACを用いた権限管理を行う場合、データ構造は

f:id:quoll00:20210911075701p:plain:w300

f:id:quoll00:20210911075747p:plain:w300

のような形になるでしょう。

基本的に権限(パーミッション)はAPIの機能自体が変わったりしない限りは変更されません。
その権限を束ねるロールも変更頻度は少ないでしょう。

一方でユーザは変更が多い要素であるため、Input側で管理し、送る形が良いでしょう。

f:id:quoll00:20210911081532p:plain

ref: https://www.openpolicyagent.org/docs/latest/external-data/#option-2-overload-input

もしくはPush型にしてデータをリアルタイムに同期できる仕組みを用意するのが良いです。

f:id:quoll00:20210911081602p:plain

ref: https://www.openpolicyagent.org/docs/latest/external-data/#option-4-push-data

同一ホストに置くもしくはサイドカーとして置く

ライブラリとしてではなく、別サーバとしてOPAを用意する場合は

  • 可用性
  • パフォーマンス

の観点から同一ホスト上に置くかサイドカーとして用意するのが良いです。

f:id:quoll00:20210911082623p:plain

ref: The distributed authorization system: A Netflix case study: Distributed Systems & DevOps Conference | O’Reilly Velocity

実装

それでは具体的な実装方法について説明します。

API

以下の5つのAPIがあるとします。

func main() {
    r := chi.NewRouter()
    r.Use(opaMiddleware)
    r.Route("/articles", func(r chi.Router) {
        r.Get("/", listArticles)
        r.Post("/", createArticle)

        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getArticle)
            r.Put("/", updateArticle)
            r.Delete("/", deleteArticle)
        })
    })
    http.ListenAndServe(":3000", r)
}

各ハンドラにルーティングされる前にOPAで権限チェックをするミドルウェアを挟んでいます。

func opaMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        trimed := strings.Trim(r.URL.Path, "/")
        p := strings.Split(trimed, "/")
        // NOTE: コンテキストなどからユーザ情報の取得→ロールの設定
        userRoles := []string{"article.editor"}
        input := data{
            Input: input{
                Method: r.Method,
                Path:   p,
                Roles:  userRoles,
            },
        }
        body, _ := json.Marshal(input)
        resp, err := http.Post(opaEndpoint, "application/json", bytes.NewBuffer(body))
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        defer resp.Body.Close()

        res := result{}
        err = json.NewDecoder(resp.Body).Decode(&res)
        if !res.Result {
            w.WriteHeader(http.StatusForbidden)
            return
        }

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

今回のコードではそのユーザがどのロールを持っているかはベタ書き(article.editor)してます。

実際にはDBで保持し、ログイン後にJWTに持たせたりすると良いです。

OPAで権限チェックに失敗した場合は403を返すようにしています。

OPA

次のOPA側のポリシーとデータの用意をします。

Data

以下のような権限と、それを束ねるロールを定義しています。

{
    "role_permissions": {
        "article.admin": [
            "article.create",
            "article.update",
            "article.list",
            "article.get",
            "article.delete"
        ],
        "article.editor": [
            "article.create",
            "article.update",
            "article.list",
            "article.get"
        ],
        "article.viewer": [
            "article.list",
            "article.get"
        ]
    }
}

Policy

policy.regoでは以下のようなルールを用意しています。
先程定義した権限を持っているかをチェックしています。

default allow = false

allow {
    user_is_admin
}

user_is_admin {
    input.roles[_] = "admin"
}

# GET /articles
allow {
    input.method = "GET"
    input.path = ["articles"]
    has_permission(input.roles, "article.list")
}

# GET /articles/:id
allow {
    some id
    input.method = "GET"
    input.path = ["articles", id]
    has_permission(input.roles, "article.get")
}

# POST /articles
allow {
    input.method = "POST"
    input.path = ["articles"]
    has_permission(input.roles, "article.create")
}

# PUT /articles/:id
allow {
    some id
    input.method = "PUT"
    input.path = ["articles", id]
    has_permission(input.roles, "article.update")
}

# DELETE /articles/:id
allow {
    some id
    input.method = "DELETE"
    input.path = ["articles", id]
    has_permission(input.roles, "article.delete")
}

has_permission(roles, p) {
    r := roles[_]
    data.role_permissions[r][_] == p
}

APIが増えるたびにルールが増えるのでそこは微妙ですが、URL pathをベースにチェックする形だと変数が入ってしまうのでこうしています。

gRPCのような固定のメソッド名でチェックできる場合は↓のようにルールを1つにまとめ、権限がルールに含まれないように(=Data側で)管理するのが良いでしょう。

allow = true {
    op = allowed_operations[_]
    input.method = op.method
}

allowed_operations = [
    {"method": "myservice.ListArticles"},
    {"method": "myservice.GetArticle"},
    {"method": "myservice.CreateArticle"},
    {"method": "myservice.UpdateArticle"},
    {"method": "myservice.DeleteArticle"}
]

サンプルコード

今回のコードはこちらです。

github.com

動作確認

article.editorなので取得・作成・更新ができます。

$ curl -i -XGET localhost:3000/articles
HTTP/1.1 200 OK

$ curl -i -XGET localhost:3000/articles/hoge
HTTP/1.1 200 OK

$ curl -i -XPOST localhost:3000/articles
HTTP/1.1 200 OK

$ curl -i -XPUT localhost:3000/articles/hoge
HTTP/1.1 200 OK

一方で削除はできません。

$ curl -i -XDELETE localhost:3000/articles/hoge
HTTP/1.1 403 Forbidden

期待通りの挙動ですね。

まとめ

REST APIのAuthorizationをOPAに委譲した場合の具体的な実装方法について説明しました。

参考