以下の内容はhttps://christina04.hatenablog.com/entry/authorize-api-with-openfgaより取得しました。


OpenFGA でAPIのアクセス制御を実装する

概要

前回

christina04.hatenablog.com

でOpenFGAについて紹介しました。

今回は実際にアプリケーションでどのように実装するかを説明します。

環境

  • OpenFGA Server 1.8.4
  • Go 1.24.0

全体イメージ

アプリケーションサーバ、認証サーバ、認可サーバ(OpenFGA)の関係図は次のようになります。

  • ユーザ登録時・更新時にユーザ属性情報をOpenFGAにも登録しておく
  • ログイン時にユーザ情報をJWTに含める
  • リソースAPIを呼ぶ際に↑のJWTからOpenFGAで権限があるかチェックする
    • 本番ではインメモリキャッシュするので都度通信は発生しない

というステップでAPIアクセスの可否を行います。

構築

今回は簡単のため認証サーバは用意しない(アプリケーション内で認証をしたと見做す)形で構築します。

docker-compose.yaml

docker composeでコンポーネントが立ち上がるようにします。

version: "3.8"

services:
  mysql:
    image: mysql:8
    container_name: mysql
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: openfga
    networks:
      - openfga

  # マイグレーション用
  openfga-migrate:
    image: openfga/openfga
    container_name: openfga-migrate
    command: >
      migrate
      --datastore-engine mysql
      --datastore-uri 'root:secret@tcp(mysql:3306)/openfga?parseTime=true'
    depends_on:
      - mysql
    networks:
      - openfga

  openfga:
    image: openfga/openfga
    container_name: openfga
    command: >
      run
      --datastore-engine mysql
      --datastore-uri 'root:secret@tcp(mysql:3306)/openfga?parseTime=true'
    depends_on:
      - mysql
    ports:
      - "3000:3000"
      - "8080:8080"
      - "8081:8081"
    networks:
      - openfga

networks:
  openfga:
    driver: bridge

MySQLをDBとしてOpenFGAを起動します。
OpenFGA用のテーブル等を用意する必要があるため、マイグレーション処理を行うコンテナも実行します。

OpenFGAの設定

次にOpenFGAに認可モデルやタプルを登録します。

http://localhost:3000/playground

にアクセスすれば管理画面になるのでそこから登録します。

※この管理面自体のアクセス制御はないので本番運用では避けましょう

認可モデルスキーマ

今回はシンプルにユーザに直接権限を付与するモデルにします。

model
  schema 1.1

type user

type api
  relations
    define owner: [user]
    define editor: [user] or owner
    define viewer: [user] or editor

タプル設定

次のようなタプルを登録します。

{
    "user": "user:alice",
    "relation": "viewer",
    "object": "api:article"
},
{
    "user": "user:alice",
    "relation": "viewer",
    "object": "api:item"
},
{
    "user": "user:bob",
    "relation": "editor",
    "object": "api:article"
}

イメージとしては以下です。

  • api:article があれば Article API にアクセス可能
  • api:item があれば Item API にアクセス可能
  • relationで権限の強さを表現

アプリケーション実装

アプリケーションサーバの実装です。

全体

次のようにミドルウェアで認可処理を行います。

func main() {
        r := chi.NewRouter()

        // 簡単のため、本来リクエストから取得すべきJWTオブジェクトを埋め込む
        r.Use(embedJWT)

        r.Route("/articles", func(r chi.Router) {
                r.Use(preauthorize("article"))
                r.Use(checkAuthorization)

                r.Get("/", list)
                r.Post("/", create)

                r.Route("/{id}", func(r chi.Router) {
                        r.Get("/", get)
                        r.Put("/", update)
                        r.Delete("/", del)
                })
        })

        r.Route("/items", func(r chi.Router) {
                r.Use(preauthorize("item"))
                r.Use(checkAuthorization)

                r.Get("/", list)
                r.Post("/", create)

                r.Route("/{id}", func(r chi.Router) {
                        r.Get("/", get)
                        r.Put("/", update)
                        r.Delete("/", del)
                })
        })

        err := http.ListenAndServe(":9000", r)
        if err != nil {
                fmt.Println(err)
        }
}

認証処理

簡単のため、ヘッダーで渡されたユーザ情報をを使うようにします。
通常はJWTの処理を行い、JWTのsubなどを使います。

func embedJWT(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                userName := r.Header.Get("X-User")
                claims := jwt.MapClaims{
                        "sub": userName,
                        "iss": "example.com",
                        "exp": time.Now().Add(time.Hour * 72).Unix(),
                }
                token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

                ctx := context.WithValue(r.Context(), "jwt", token)
                next.ServeHTTP(w, r.WithContext(ctx))
        })
}

権限マッピング

リソースと権限の強さのマッピングはシンプルに

  • viewerならGET
  • editorならGET、POST、PUT
  • ownerならGET、POST、PUT、DELETE

という形にしています。

func preauthorize(resource string) func(next http.Handler) http.Handler {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        token := r.Context().Value("jwt").(*jwt.Token)
                        claims := token.Claims.(jwt.MapClaims)
                        user := claims["sub"].(string)
                        ctx := context.WithValue(r.Context(), "user", user)

                        var relation string
                        switch r.Method {
                        case "GET":
                                relation = "viewer"
                        case "POST", "PUT":
                                relation = "editor"
                        case "DELETE":
                                relation = "owner"
                        default:
                                relation = "owner"
                        }

                        ctx = context.WithValue(ctx, "relation", relation)
                        object := "api:" + resource
                        ctx = context.WithValue(ctx, "object", object)

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

認可処理

認可ミドルウェアの実装は次のようにしています。

func checkAuthorization(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                fgaClient, err := NewSdkClient(&ClientConfiguration{
                        ApiUrl:  apiURL,
                        StoreId: storeID,
                })
                if err != nil {
                        http.Error(w, "Unable to build OpenFGA client", http.StatusServiceUnavailable)
                        return
                }

                user := r.Context().Value("user").(string)
                relation := r.Context().Value("relation").(string)
                object := r.Context().Value("object").(string)

                body := ClientCheckRequest{
                        User:     "user:" + user,
                        Relation: relation,
                        Object:   object,
                }

                data, err := fgaClient.Check(context.Background()).Body(body).Execute()
                if err != nil {
                        http.Error(w, "Unable to check for authorization", http.StatusServiceUnavailable)
                        return
                }

                if !(*data.Allowed) {
                        http.Error(w, "Forbidden to access document", http.StatusForbidden)
                        return
                }

                next.ServeHTTP(w, r)
        })
}

動作確認

  • alice
  • bob
  • charlie(未登録ユーザ)

で検証します。

alice

ユーザaliceはタプルで閲覧権限が登録されているのでarticleの取得が可能です。

$ curl -H "X-User:alice" http://localhost:9000/articles/100
got 100

item権限もあるので同様に取得できます。

$ curl -H "X-User:alice" http://localhost:9000/items/itemA
got itemA

一方でeditorではないので、PUTやPOSTはコケます。

$ curl -X PUT -H "X-User:alice" http://localhost:9000/items/itemA
Forbidden to access document

bob

bobはarticlesリソースのeditor権限があるので通ります。

$ curl -X PUT -H "X-User:bob" http://localhost:9000/articles/100
updated 100

モデル設計上viewerも含んでいるので、閲覧もできます。

$ curl -H "X-User:bob" http://localhost:9000/articles/100
got 100

一方itemはどんな権限も持っていないのでコケます。

$ curl -H "X-User:bob" http://localhost:9000/items/itemA
Forbidden to access document

charlie

charlieは何の権限も持ってないので、どのAPIリソースにもアクセスできません。

$ curl -H "X-User:charlie" http://localhost:9000/articles/100
Forbidden to access document

その他

サンプルコード

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

github.com

まとめ

OpenFGAを使ってAPIの認可処理を行う簡単な実装例を紹介しました。

管理画面など、権限設計に柔軟性が求められるユースケースで非常に有効なソリューションだと思います。




以上の内容はhttps://christina04.hatenablog.com/entry/authorize-api-with-openfgaより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14