OpenFGAで認可を実装してみた。ScopeとRelationによる権限制御

OpenFGA + 自作認可サービスの実装を、公開している検証リポジトリの実物で追う。ネットワーク構成、JWT検証のコード、関係タプルのDDLとFGAモデル、Can/ListAccessibleの実装2種、401/403の実測、M2Mのトークン付与とトレース伝播を束ねたRoundTripperまで。

認証まわりの設計が崩れるときは、たいてい「ログインしているか」という if から 書き始めている。この1個の if に、実際には3つの別の質問が混ざっているからだ。

  1. 誰か(認証 / Authentication)
  2. この操作をしてよいか(認可 / Authorization)
  3. 何が見えるか(認可付きの一覧)

この3つを分けて組み、人間のログインと M2M(サービス間のマシン認証)を 同じ枠に載せる構成を、公開している検証リポジトリ で実装・実測した。概念の説明ではなく、実物のコード・スキーマ・測定結果で追う。

全体の構成

登場するのは、業務サービス2つ(photo: 写真投稿、gear: 撮影機材)、 認証を担う Keycloak(OIDC の OP)、認可を担う自作の authz サービス + OpenFGA。

 ブラウザ ──Cookie──▶ SSR ──┐
                            │ Bearer(人間のトークン)
     │ ログイン/トークン取得   ▼
     ▼                    photo :8080 external ─┐
 Keycloak :8180 ◀─────────photo :8082 admin  ──┤ 認可の質問
 (OIDC OP)   ▲            photo :8081 internal │ Can / ListAccessible
     ▲       │ JWKS取得        ▲               ▼
     │       │ (署名検証用)     │ Bearer      authz :8100 ──▶ OpenFGA :8280
     │       └────────────────│ (M2Mトークン │ (check /        (関係グラフ)
     │ client_credentials      │  + scope)    │  list-objects)
     └──────────────────── gear :8090-8092 ───┘
                                                     photo/gear/authz とも
                                                     ストレージは MySQL

読み取ってほしいのは3点。

認証: トークンの実物と、検証のコード

まず M2M トークンの実物。gear が client_credentials で取得したトークンの ペイロードはこうなっている(ローカル環境で実際に発行したもの)。

{
  "iss": "http://auth.localhost/realms/greenfield",
  "sub": "4881ce76-91c9-481a-b29f-c5b39ce0dec1",
  "exp": 1790699471,
  "azp": "svc-gear",
  "scope": "internal:photo email profile",
  "typ": "Bearer",
  "preferred_username": "service-account-svc-gear"
}

人間(alice)のトークンも構造は同じで、sub がユーザーの識別子になり、 scope に internal:photo が入らない。この差が後で 403 として効いてくる。

受け取る側の検証コード。外部の JWT ライブラリを使わず、stdlib の RSA 検証で 書いてある。検証対象を「既知の issuer が RS256 で署名したトークン」に固定すると、 JWT ライブラリが持つ危険な自由度(alg の取り違え)ごと消えるからだ。

// alg は RS256 に固定する。ここを可変にすると none / HS256 混同の類が全部戻ってくる
if header.Alg != "RS256" {
	return nil, fmt.Errorf("alg %q は受け付けない(RS256 のみ)", header.Alg)
}
key, err := a.keyFor(ctx, header.Kid)   // JWKS から kid で公開鍵を引く(キャッシュ済み)
if err != nil {
	return nil, err
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig); err != nil {
	return nil, errors.New("署名が一致しない")
}
// このあと exp と iss を検査して Principal{Subject, Kind, Scopes} に落とす

alg の検査が先頭にあるのが要点で、これが可変だと「alg: none を受け付ける」 「HS256 と混同して公開鍵を共有鍵として使う」という既知の攻撃系が全部復活する。

認証の出力は Principal 1個だけ。

type Principal struct {
	Subject string // OP が発行した識別子。解析しない・意味を持たせない
	Kind    PrincipalKind // PrincipalUser | PrincipalService
	Scopes  []string
}

人間と M2M はトークン取得の儀式が違うだけ(人間: Authorization Code + PKCE、 サービス: client_credentials)で、検証はこの1本の経路を通り、Kind が変わるだけ。 受信側に「人間用」「サービス用」の2系統を作らない。

認可のデータモデル: 権限は関係の行

認可の中身は「主体とリソースの関係」のグラフで、実体は3列のテーブルに落ちる。 開発用のローカル実装(本番は OpenFGA)の DDL がこれだ。

CREATE TABLE relation_tuples (
    subject  VARCHAR(255) NOT NULL COMMENT 'user:<sub> / service:<client_id>',
    relation VARCHAR(64)  NOT NULL COMMENT 'owner / operator / ...',
    object   VARCHAR(255) NOT NULL COMMENT 'photo:<id> / platform:main',
    PRIMARY KEY (subject, relation, object),
    KEY idx_tuples_object (object, relation)
);

中に入る行はこういうもの。

subjectrelationobject意味
user:aliceownerphoto:42alice は写真42の所有者
user:boboperatorplatform:mainbob は運営者

viewer / editor の行が無いことに注目してほしい。「見てよい」「編集してよい」は 行として保存せず、モデルの導出規則から計算する。OpenFGA に投入している モデル定義(実物の抜粋):

{
  "type": "photo",
  "relations": {
    "parent": {"this": {}},
    "owner":  {"this": {}},
    "viewer": {"union": {"child": [
      {"computedUserset": {"relation": "owner"}},
      {"tupleToUserset": {"tupleset": {"relation": "parent"},
                          "computedUserset": {"relation": "operator"}}}
    ]}}
  }
}

日本語にすると「photo の viewer とは、その photo の owner、または photo の parent(= platform)の operator」。導出を図にする:

owner

operator

parent

owner 経由で viewer

operator 経由で viewer

user:alice

photo:42

user:bob

platform:main

この設計の利点は2つ。剥奪が漏れない(「見せる」を配って回らず、 所有・運営という事実だけを書く。事実が消えれば権限も消える)。 運営者に専用機構が要らない(「admin なら通す」の if を書く代わりに、 parent → operator の導出で運営者の閲覧・編集が普通の認可判定になる)。

Can の実装: 同じ interface に2つの実体

プロダクト側が使う語彙は photo.view のような action で、 relation への対応表は認可サービスの中に閉じる(実物):

var DefaultMapping = map[string]string{
	"photo.view":       "viewer",
	"photo.edit":       "editor",
	"photo.publish":    "editor",   // 公開も編集権で判定する、という判断がここに残る
	"platform.operate": "operator",
}

開発用のローカル実装は、この導出を SQL で素朴にやる(実物を要約):

func (s *Store) Can(ctx context.Context, req authz.Request) (authz.Result, error) {
	relation := s.mapping[req.Action]          // photo.view → viewer
	object := authz.ObjectRef(req.ResourceType, req.ResourceID) // photo:42

	// viewer の直接根拠(owner)をタプルで探す
	for _, direct := range grants[relation] {  // viewer → [owner]
		if ok, _ := s.has(ctx, subject, direct, object); ok {
			return authz.Result{Allowed: true}, nil
		}
	}
	// 無ければ運営者導出: platform:main への operator タプルがあるか
	if fromParent[relation] {
		op, err := s.has(ctx, subject, "operator", PlatformObject)
		return authz.Result{Allowed: op}, err
	}
	return authz.Result{}, nil
}

本番アダプタは同じ質問を HTTP で authz サービスに投げるだけ:

func (c *Client) Can(ctx context.Context, req authz.Request) (authz.Result, error) {
	in := map[string]string{
		"subject": sub, "action": req.Action,
		"resource_type": req.ResourceType, "resource_id": req.ResourceID,
	}
	if v := consistency(req.Consistency); v != "" {
		in["consistency"] = v   // 「作成直後も見えてほしい」の一貫性ヒントを透過する
	}
	var out struct{ Allowed bool `json:"allowed"` }
	err := c.post(ctx, "/check", in, &out)
	return authz.Result{Allowed: out.Allowed}, err
}

usecase から見えるのは authz.Authorizer という interface だけなので、 ローカル実装と本番アダプタの差し替えは合成ルート1箇所で済む。実際に 差し替えたときの usecase / handler の diff はゼロだった(CI が 「差し替えの影響が配線部に閉じる」を毎回検証している)。

一覧は Can のループではなく WHERE IN

「何が見えるか」を Can の全件ループでやると N+1 になるうえ、 先に LIMIT してから認可で落とすと取りこぼす。正しい形は 「見える ID の集合を先にもらい、SQL で絞る」。

ID 集合を返す側(ローカル実装の実物):

// 運営者なら全件(owner タプルの存在をもってリソースの存在と見なす)
if op { return s.ids(ctx,
	"SELECT DISTINCT object FROM relation_tuples WHERE relation = 'owner' AND object LIKE ?",
	"photo:%") }
// 一般ユーザーは自分に根拠タプルがある object だけ
query := "SELECT DISTINCT object FROM relation_tuples WHERE subject = ? AND object LIKE ? AND relation IN (...)"

受け取った側の表示クエリ(実物)。公開範囲と件数の絞り込みを SQL 側でやるのが要点で、Go 側で絞ると同じ取りこぼしが起きる:

-- name: ListPhotosByIDs :many
SELECT * FROM photos
WHERE id IN (sqlc.slice('ids'))
  AND status = 'ready'
  AND (sqlc.arg('visibility') = '' OR visibility = sqlc.arg('visibility'))
ORDER BY created_at DESC
LIMIT ?;

scope は API 群の入り口を守る。実測の 401/403

サービスは呼び出し主体ごとに API 群を分けている(external / admin / internal)。 internal の API 群には scope internal:photo を要求する。CI が毎回実測している 結果がそのまま意味論の表になる:

1. 外部API
  ok: トークン無しは 401
  ok: alice のトークンで 200(認可付き一覧が authz サービス経由で返る)
  ok: 壊れたトークンは 401
2. 内部API
  ok: トークン無しは 401
  ok: 人間のトークン(internal:photo 無し)は 403
  ok: M2M でもスコープ無しは 403
  ok: svc-gear + internal:photo で 200

401 は「誰か分からない。トークンを取り直せ」、403 は「誰かは分かるが枠の外。 取り直しても無駄」。この使い分けが崩れると、クライアントは失敗時に 何をすべきか判断できない。

つまり認可判断は二段になっている。

scope 検査        … このトークンでこの API 群に入れるか(トークンだけを見る。粗い)
relation の判定   … この主体はこのリソースに何ができるか(主体×リソース。細かい)

scope で関係を代用すると photos:read:own のような擬似リソース権限が scope に増殖する。relation だけで入り口を守ろうとすると「内部 API に人間のトークンで 入れる」穴が開く。両方要る。とくにサービス間でユーザーの代理として動くとき、 scope を持っているからと細かい認可を省くと、内部 API が権限昇格の抜け道になる。

M2M の横断的関心事は RoundTripper に束ねる

M2M 側の実装で気に入っているのがここで、トークンの自動付与・冪等キー・ トレース伝播が1つの RoundTripper に束ねてある(実物):

func (tr *transport) RoundTrip(req *http.Request) (*http.Response, error) {
	tok, err := tr.ts.Token(req.Context())   // 取得 + 期限までキャッシュ
	if err != nil {
		return nil, err
	}
	clone := req.Clone(req.Context())        // RoundTripper は元リクエストを書き換えない規約
	clone.Header.Set("Authorization", "Bearer "+tok)

	// 冪等キー: 変更系は必ず付ける。呼び出し側が明示したキーは尊重する——
	// リトライで同じキーを送り直すのは呼び出し側の責任で、ここで毎回
	// 新しい UUID を振り直すと重複排除として機能しない
	if mutating(clone.Method) && clone.Header.Get("Idempotency-Key") == "" {
		clone.Header.Set("Idempotency-Key", randomKey())
	}

	// トレース伝播: 受信リクエストの相関があれば traceparent で運ぶ
	if c, ok := middleware.FromContext(clone.Context()); ok && c.TraceID != "" {
		clone.Header.Set("traceparent", "00-"+c.TraceID+"-"+c.SpanID+"-01")
	}
	return tr.base.RoundTrip(clone)
}

業務コードは http.Client を受け取るだけで、認証もトレースも冪等キーも 知らない。開発用トークンも本番の client_credentials も同じ transport を 通るので、開発と本番で通り道が変わらない。

まとめ

質問答える仕組み実体
誰かJWT 検証(人間も M2M も同じ1本)RS256 固定 + JWKS、出力は Principal のみ
この API 群に入れるかscope401/403 の意味論を実測で固定
この操作をしてよいかCan → relation からの導出3列のタプル + 導出モデル。行として権限を配らない
何が見えるかListAccessible → WHERE IN絞り込みは SQL 側。LIMIT 後に認可で落とさない
← Back to all posts