OpenFGAで認可を実装してみた。ScopeとRelationによる権限制御
OpenFGA + 自作認可サービスの実装を、公開している検証リポジトリの実物で追う。ネットワーク構成、JWT検証のコード、関係タプルのDDLとFGAモデル、Can/ListAccessibleの実装2種、401/403の実測、M2Mのトークン付与とトレース伝播を束ねたRoundTripperまで。
認証まわりの設計が崩れるときは、たいてい「ログインしているか」という if から 書き始めている。この1個の if に、実際には3つの別の質問が混ざっているからだ。
- 誰か(認証 / Authentication)
- この操作をしてよいか(認可 / Authorization)
- 何が見えるか(認可付きの一覧)
この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点。
- トークンの発行元は Keycloak の1箇所。人間も M2M も同じ OP から出る
- 各サービスは Keycloak から公開鍵(JWKS)だけを取り、検証は自分でやる。 リクエストのたびに OP へ問い合わせない
- 認可は authz サービスに質問として投げる。関係データを各サービスが 直接読むことはない
認証: トークンの実物と、検証のコード
まず 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)
);
中に入る行はこういうもの。
| subject | relation | object | 意味 |
|---|---|---|---|
user:alice | owner | photo:42 | alice は写真42の所有者 |
user:bob | operator | platform:main | bob は運営者 |
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」。導出を図にする:
この設計の利点は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 群に入れるか | scope | 401/403 の意味論を実測で固定 |
| この操作をしてよいか | Can → relation からの導出 | 3列のタプル + 導出モデル。行として権限を配らない |
| 何が見えるか | ListAccessible → WHERE IN | 絞り込みは SQL 側。LIMIT 後に認可で落とさない |
- 認証は Principal を作るだけ。リソースを知らない
- 権限は配らず、事実(所有・運営)から導出する。運営者も専用機構を作らない
- scope は API 群の入り口を決め、権限は relation から導出する。片方でもう片方を代用しない
- M2M は取得の儀式が違うだけで、検証・認可の枠は人間と同じ。 横断的関心事(トークン・冪等キー・トレース)は RoundTripper に束ね、業務コードから隠す