この HTML は repo の docs/architecture/textbook/*.md と同じ内容を、 サイドバー・ステップ送りの図・用語アコーディオンで読みやすくしたもの。 正本は Markdown 側。手を動かす対の教材は tutorial/、応用は app/ (docshare)。 急いでいる場合は 00 → 02 → 04 の 3 章でスキーマを書いてテストできるようになる。 本番投入の前には 06 と 07 を必ず読む。

00なぜ認可は難しいのか

if 文で始まり、if 文で破綻する

文書共有サービスを作るとする。最初の認可はこの程度で済む。

if doc.OwnerID == user.ID { /* OK */ }

要求はすぐ増える。

#要求正体
1フォルダに入れた文書は、フォルダを見られる人も見られるように階層
2eng チームの全員に見せたい。メンバーの出入りで文書側は触りたくないグループ
3全社公開の文書公開
4社外の人に 1 時間だけ共有期限
5この人だけは何があっても見せないブロック
6「自分が見られる文書の一覧」を出したい逆引き

これを if 文で追うと、権限ロジックがハンドラ中に散らばり、「結局この文書を誰が見られるのか」に誰も答えられなくなる。特に 6 の逆引きは、判定ロジックが命令形コードの中にあると原理的に書けない。

認証と認可
認証 (authentication) は相手が誰かを確かめること (ログイン、OIDC、パスキー)。 認可 (authorization) はその人が何をしてよいか決めること。この教材の主題は後者で、 デモアプリは認証を意図的に省いて X-User ヘッダを信じる。 なお OAuth 2.0 の文脈にも「認可」という言葉が出てくるが、あれは別の問題を指している。章末のコラムで切り分ける。

Zanzibar — Google の答え

Google も同じ問題を抱えていた。Drive・Docs・YouTube・Photos などの認可を一手に引き受ける社内システムが Zanzibar で、2019 年に論文が公開された。要点は 3 つ。

  1. 認可を専用のサービスに集約する (各アプリの if 文をやめる)
  2. 権限は「関係」のグラフとして表す (01 章の ReBAC)
  3. 分散環境でもキャッシュが効き、かつ権限剥奪を取りこぼさない仕掛けを持つ (06 章の New Enemy 問題と ZedToken)
Zanzibar 論文
"Zanzibar: Google's Consistent, Global Authorization System" (USENIX ATC 2019)。 毎秒 1,000 万件超のチェックを 95%ile 10ms 以下で返す、という規模の話だが、 概念自体は小さなシステムにもそのまま使える。

SpiceDB — その OSS 実装

SpiceDB は Zanzibar にインスパイアされた OSS (Apache-2.0) の認可データベース。AuthZed 社が開発している。Zanzibar 系の中で実装が成熟していて、スキーマ言語とテスト道具 (zed) が学習に向く。アプリから見た SpiceDB は「権限の問いに答える gRPC サービス」で、持ち物は 2 つ。

持ち物例誰が書くか
スキーマ (型)「document には viewer がいて、view = viewer + …」開発者が設計時に
relationship (データ)「document:design の viewer は user:bob」アプリが実行時に

アプリは判定を自前でせず、Check(資源, 権限, 人) を SpiceDB に聞く。

ReBAC
Relationship-Based Access Control。「誰と何がどんな関係にあるか」のグラフから権限を導く方式。01 章で RBAC / ABAC と比べながら説明する。
OSS 版とマネージド版
セルフホストの SpiceDB (この教材はこれ) のほか、AuthZed 社のマネージド (AuthZed Dedicated / Cloud) がある。API とスキーマは同じなので、学んだことはそのまま持ち運べる。
gRPC
HTTP/2 上の RPC フレームワーク。SpiceDB の一次 API は gRPC で、各言語の公式クライアント (authzed-go など) から叩く。gRPC が使えない環境向けに HTTP (REST) ゲートウェイも同梱されている (05 章)。

この repo の歩き方

場所役割
tutorial/手。1 章 1 概念、書いたそばから zed validate で答え合わせ
この textbook頭。概念を順番に、体系立てて
app/応用。Go 製の文書共有 API に SpiceDB を組み込む

冒頭の要求 1〜6 は、この教材を終えると全部スキーマ数行ずつで表せるようになる (1=arrow、2=group、3=wildcard、4=caveat、5=exclusion、6=LookupResources)。

コラム — OAuth 2.0 の「認可」とこの本の「認可」

どちらも日本語では「認可」(英語でも authorization) だが、解いている問題が違う。OAuth 2.0 を知っていると SpiceDB の話が二重写しに見えるので、先に切り分けておく。

  • OAuth 2.0 の認可 (委譲) — 「ユーザーが、自分の代わりに動くアプリ (クライアント) に、自分のリソースへの限定されたアクセスを貸し出す」ための取り決め。「このアプリに Google カレンダーの読み取りを許可しますか?」の同意画面がそれで、成果物はアクセストークン
  • この本の認可 (アクセス制御) — 「この人はこの資源にこの操作をしてよいか」をサービスがリクエストのたびに判定する話。判定材料は所有・共有・所属といった関係。SpiceDB / Zanzibar はこちら
観点OAuth 2.0 (委譲)アクセス制御 (この本)
問いユーザーはこのアプリに何を貸すかこのユーザーはこの資源に何ができるか
許可を出すのはリソースオーナー (ユーザー本人) の同意サービス (スキーマ + relationship)
決まるタイミングトークン発行時に前払いリクエストごとに都度評価
粒度scope (calendar.readonly など API の範囲)資源 × 操作 × 主体 (document:design を bob が view)
取り消しトークンの期限切れ / revokerelationship の削除 (直後から確実に。06 章)

実際のシステムでは両方を重ねて使う。典型的な 1 リクエストはこう流れる。

  1. クライアントがアクセストークン付きでリクエストを送る
  2. サービスはトークンを検証し、「誰の代理か (認証)」と「アプリに貸された範囲 (scope)」を確認する — OAuth / OIDC の領分
  3. その上で「ではその人は document:design を view できるのか」を SpiceDB に聞く — この本の領分

scope を通過しても、資源単位の可否はまだ何も決まっていない。scope は「アプリに貸した API の範囲」であって「ユーザー自身の権限」ではないから。docshare の X-User ヘッダは、この 1〜2 が済んだ後の状態を素朴に代用したもの。

scope に資源を詰めたくなったら
document:123:read のような資源単位の scope を切りたくなったら、それはアクセス制御を OAuth 側でやろうとしているサイン。トークン発行時に権限が固定されるので剥奪が効かず (トークンが生きている限り読める)、資源 × 操作の組み合わせで scope が爆発する。scope は API の粗い範囲に留め、資源単位の可否はアクセス制御側 (SpiceDB) で都度判定するのが定石。
OIDC との三角関係
OpenID Connect (OIDC) は OAuth 2.0 の上に乗った認証の仕様で、「誰がログインしたか」(ID トークン) を扱う。整理すると、OIDC = 認証 (誰か) / OAuth 2.0 = 委譲 (アプリに何を貸すか) / SpiceDB = アクセス制御 (その人がこの資源に何をできるか)。3 つは競合ではなく、1 つのリクエストの中で順に登場する。

次章: 01 ReBAC という考え方 →

01ReBAC という考え方

権限モデルの系譜

モデル権限の根拠例
ACL資源ごとの許可リストこの文書の閲覧者: alice, bob
RBAC役割 (role)「編集者」ロールを持つ人は編集できる
ABAC属性の論理式部署 = 営業 かつ 等級 ≥ 3 なら閲覧可
ReBAC関係のグラフ文書 ← フォルダ ← owner という関係をたどれる人
ACL (Access Control List)
資源 1 つずつに「誰が何をできるか」を列挙する最古の形。単純だが、「eng チーム全員」のような集合や「フォルダの中は全部」のような構造を表せず、列挙の維持が破綻しやすい。
RBAC (Role-Based Access Control)
ユーザーに役割を割り当て、役割に権限を紐づける。組織の職務と相性がよく社内システムの定番。粒度は「システム全体 × 役割」になりがち。
ABAC (Attribute-Based Access Control)
ユーザー・資源・環境の属性を条件式で評価する。柔軟だが、条件式が増えると「なぜ許可されたのか」の説明が難しくなる。

RBAC が苦しくなる瞬間

RBAC の権限は「システム全体でその役割か」に寄っていく。Drive 的な要求はほぼ資源単位なので、00 章の要求リストを RBAC のまま真面目にテーブル設計すると、次の階段を 1 段ずつ降りることになる。

段階 1 — 全体ロールで始める

CREATE TABLE user_roles (
  user_id TEXT,
  role    TEXT   -- 'admin' | 'editor' | 'viewer'
);
if hasRole(user, "editor") { /* 編集 OK */ }

きれいに RBAC。しかしこの editor は「全文書の編集者」で、最初の要求「このフォルダだけ編集させたい」が既に表せない。

段階 2 — ロールに資源スコープを付ける

CREATE TABLE user_folder_roles (
  user_id   TEXT,
  folder_id TEXT,
  role      TEXT   -- そのフォルダでの 'editor' | 'viewer'
);

行数は ユーザー × フォルダ × ロール で増えていく。名前は「ロール」のままだが、実体はもう資源ごとの許可の割り当て表 (ACL) に変質している。さらに「eng チームの全員に」を配るには group_members と group_folder_roles を足し、グループの入れ子が要求された時点で、メンバー解決の再帰クエリを手書きする。

ロール爆発
資源単位の細かい制御を RBAC でやろうとして、資源 × 操作の数だけ役割 (またはロール割り当て行) が増殖する現象。段階 2 の表がまさにそれで、役割の棚卸しが不可能になった時点で統制も破綻する。
再帰クエリ (WITH RECURSIVE)
SQL で「グループのグループ」のような深さ不定の構造をたどる書き方 (再帰 CTE)。書けるが、認可のホットパス (全リクエストが通る) に手書きの再帰 SQL が入ることになり、性能とレビューの両方が重くなる。

段階 3 — 階層継承で、チェックが「表引き」でなくなる

「フォルダの中は親から継承」が入ると、check は 1 回の表引きでは済まなくなる。

func canEdit(user User, folder *Folder) bool {
    for f := folder; f != nil; f = f.Parent() { // 親フォルダへ 1 段ずつ
        if hasFolderRole(user, f, "editor") {
            return true
        }
        if anyGroupHasFolderRole(groupsOf(user), f, "editor") {
            return true
        }
    }
    return false
}

ループの形をよく見ると、これは関係の表をたどるグラフ探索そのもの。しかも文書を触るサービス全部が同じループを持つ (共有ライブラリにしても、言語が違うサービスが 1 つあればもう 1 本書く)。

段階 4 — 例外が積もり、仕様が分散する

  • 全社公開 → is_public カラムを足して check の頭に if を 1 個
  • 期限付き共有 → expires_at カラムを足して全クエリに WHERE を 1 個
  • 「この人だけは何があっても見せない」→ RBAC に 拒否 (deny) の概念は無いので、banned_users 表を足して「deny が allow に勝つ」評価順をアプリの規約として決める

この時点で「誰が何を見られるか」の正しさは、表 4〜5 枚 + if 文の並び順に分散している。仕様を一言で答えられる人はもういない。

deny (拒否) と評価順
許可の集合に「例外として除く」を混ぜると、必ず「どちらが勝つか」の順序規則が要る。RBAC 標準にはこれが無いためアプリ規約になりがち。SpiceDB では式の中の - (exclusion) として順序ごと宣言できる (tutorial/02)。

段階 5 — 逆引きで二重実装になる

「自分が見られる文書の一覧」は、段階 3 の Go のループでは書けない (全文書に対してループを回すわけにはいかない)。そこで同じ規則を SQL の JOIN に裏返してもう 1 回書く。check (Go のループ) と一覧 (SQL) は別実装なので、仕様変更のたびに両方を直す。どちらかの直し漏れが、そのまま権限事故になる。

降りきった先が ReBAC

気づけば作っているのは「関係を表に保存し、チェック時にたどる」自作エンジンで、構造はもう ReBAC になっている。ただしテスト・キャッシュ・一貫性の面倒は誰も見ていない。これを汎用化して専用サービスへ切り出し、磨き上げたものが Zanzibar であり SpiceDB。つまり「RBAC → ReBAC」は別物への乗り換えというより、自作しかけたものの完成品に移る話になる。

ReBAC: 権限 = グラフの到達可能性

ReBAC は発想を変える。世界を「オブジェクト」と「関係」のグラフとして記録し、権限の問いを「グラフをたどって届くか」に置き換える。応用アプリ docshare の世界はこうなっている (辺 1 本 = relationship 1 本)。

docshare の世界 — 認可はグラフ
document:design document:handbook folder:eng-docs folder:root group:eng user:alice user:bob 全員 (user:*) parent parent viewer (#member) owner member parent viewer

「bob は design を view できるか」は、

document:design --parent--> folder:eng-docs --viewer--> group:eng#member --member--> user:bob

という経路が存在するかの問いになる。冒頭の要求たちが素直に表せる理由は:

  • 階層 → 「parent」という辺をたどる規則を 1 行書くだけ (tutorial/03 の arrow)
  • グループ → 「グループの member 集合」を辺の先に置くだけ (tutorial/04)
  • 逆引き → グラフを逆向きにたどれば「見られる資源の列挙」になる (LookupResources)
到達可能性 (reachability)
グラフ理論の言葉で「ある頂点から別の頂点へ辺をたどって行けるか」。ReBAC の権限チェックは、規則 (スキーマ) に従った到達可能性判定と言い切れる。だから答えが「なぜ許可か」=「この経路があるから」と説明可能になる (zed permission check --explain がまさに経路を表示する)。

SpiceDB は ReBAC + 条件 (ABAC 味)

SpiceDB の本体は ReBAC だが、関係に条件式 (caveat、tutorial/06) を付けられるので、ABAC 的な「期限内なら」「この IP からなら」も混ぜられる。また RBAC は「role という関係」として ReBAC の上に自然に作れる (role を relation にすればよい)。実務では ReBAC を土台に、必要な場所にだけ属性条件を足す使い方になる。

コラム — GCP IAM は RBAC なのか

Google Cloud の IAM は「permission (storage.objects.get) を role に束ね、principal に role を割り当てる」ので、語彙は RBAC に見える。この章の言葉で分類すると、語彙は RBAC、構造は「リソース階層に接ぎ木した RBAC」になる。

GCP IAM正体この章の対応物
permission (storage.objects.get)capability そのもの—
role (roles/storage.objectViewer)permission の束に名前を付けたものRBAC の語彙 (role = 束)
role binding (IAM ポリシー)(principal, role, リソースノード) の 3 つ組。org / folder / project / 個別リソースに貼る段階 2 (ロールに資源スコープ)
階層継承 (org → folder → project → 資源)上位の binding が下に効く。評価は祖先をさかのぼる段階 3 (チェックがグラフ探索になる)
Google グループメンバー解決付きの主体集合subject relation (tutorial/04)
IAM Conditionsbinding に付く CEL の条件式 (時刻・リソース名など)caveat — 言語まで同じ CEL (tutorial/06)
Deny policiesallow に勝つ拒否。後年に別機構として追加されたexclusion (「RBAC に deny が無い」の傍証)

つまり GCP IAM は、上の段階 2〜4 を製品として磨き上げたもの。語彙は RBAC のまま、評価構造はすでに ReBAC 的なグラフ探索をしている。

それでも GCP IAM が汎用の認可基盤にならないのは、階層が Google のリソースツリー 1 本に固定されているから。アプリ独自の資源や関係 — 文書の owner、期限付き共有、全員公開 — は持ち込めない。Google 自身も、Cloud IAM とは別に Drive や YouTube の共有は Zanzibar で動かしている。「自分のアプリに GCP IAM のような権限管理が欲しい」と思ったときに作ることになるのが、次の SpiceDB モデルになる。

SpiceDB で GCP 風 IAM (role / role_binding) を書く

role を definition にし、capability ごとの relation に user:* を「この役割はこの capability を含む」というフラグとして書く。role_binding が「割り当てられた人 ∩ 役割がその capability を含む」を intersection で取る。

definition user {}

definition role {
    // 役割が capability を含むなら user:* を 1 本書く (フラグ扱い)
    relation storage_objects_get: user:*
}

definition role_binding {
    relation principal: user
    relation role: role
    permission storage_objects_get = principal & role->storage_objects_get
}

definition project {
    relation binding: role_binding
    permission storage_objects_get = binding->storage_objects_get
}

relationship は 4 本: role:object_viewer#storage_objects_get@user:* (ロールの中身)、role_binding:b1#principal@user:alice と role_binding:b1#role@role:object_viewer (割り当て)、project:demo#binding@role_binding:b1 (どこに効くか)。ロールの中身を変えると割り当て済み全員へ一斉に効くという RBAC の性質が、relationship 1 本の増減で再現できる。

なお role->storage_objects_get は relation を指す arrow なので、03 章の作法からは外れて lint 警告が出る。ここでは capability の relation 自体が role の公開インターフェースなので、承知の上でこの形が使われている (AuthZed 公式の例も同じ)。原典: https://authzed.com/blog/google-cloud-iam-modeling (ロールを実行時にユーザー定義させる発展形は https://authzed.com/blog/user-defined-roles)

次章: 02 スキーマ言語の全体系 →

02スキーマ言語の全体系

スキーマは世界の型を決める。1 つずつ手を動かすなら tutorial/01〜06 が対応する。

definition user {}

definition document {
    relation viewer: user            // 関係の宣言 (材料)
    permission view = viewer         // 権限の定義 (材料の合成)
}

3 つの宣言

宣言役割覚え方
definitionオブジェクトの型DB のテーブル定義に相当
relation持ちうる関係と、その相手の型名詞で命名 (viewer, owner, parent)
permission関係から導く「できること」動詞で命名 (view, edit, share)

relation は「データを入れる箱」、permission は「箱をつなぐ計算式」。実際に誰が viewer なのかは relationship (03 章) が持つ。

名詞 / 動詞の命名規約
authzed 公式の推奨。relation は状態 (〜である)、permission は行為 (〜できる)。この規約に沿うだけで check document:x view user:y が英文として読め、relation と permission の取り違えも起きにくい。

部品の一覧

部品書き方意味手を動かす
uniona + bどちらかに入っていればtutorial/02
intersectiona & b両方に入っている人だけtutorial/02
exclusiona - bb の人を除くtutorial/02
arrowparent->view関係の先のオブジェクトで view を評価tutorial/03
subject relationgroup#memberグループの member 集合を関係の相手にtutorial/04
wildcarduser:*「user 型の全員」という relationship を許すtutorial/05
caveatuser with not_expired条件式 (CEL) 付きの関係tutorial/06
arrow の Zanzibar 用語
Zanzibar 論文では tuple-to-userset (TTU) と呼ばれる操作。「タプル (parent 関係) をたどった先の userset (view を持つ集合)」の意。SpiceDB の -> はこれの読みやすい記法。
CEL
Common Expression Language。Google 製の副作用なし式言語で、Kubernetes のポリシー記述などでも使われる。caveat の中身はこれ。

設計の指針

API からは permission だけを見る

アプリの check は必ず permission に対して行い、relation を直接 check しない。relation は実装詳細で、permission はインターフェース。この分離を守ると、「reader にも編集させたい」のような変更がスキーマ 1 行で済み、アプリは動かない。同じ理由で arrow の右側も permission を指すのが作法 (tutorial/03)。

権限は資源側の語彙で切る

can_call_this_endpoint ではなく view / edit / share のように、資源にとって意味のある単位で permission を切る。エンドポイントが増えても permission の再利用で済むことが多い。

迷ったら「誰の集合か」を書き下す

permission の右辺は常に「ユーザーの集合を作る式」。日本語で「見られるのは: viewer と、編集できる人と、親フォルダを見られる人。ただし banned は除く」と言い切れれば、そのまま式になる。式だけ見ても掴めないので、材料 (relation) と参照先まで全部そろえた形で見る。

definition user {}

definition folder {
    relation viewer: user
    permission view = viewer  // 実物はさらに階層をたどる (下の「全部入りの実例」)
}

definition document {
    // 材料 (relation): 誰がこの文書とどう関わっているか
    relation parent: folder
    relation owner: user
    relation editor: user
    relation viewer: user
    relation banned: user

    // 「編集できるのは: owner か editor」
    permission edit = owner + editor

    // 「見られるのは: viewer と、編集できる人と、親フォルダを見られる人。
    //  ただし banned は除く」 ← この日本語がそのまま次の 1 行になる
    permission view = (viewer + edit + parent->view) - banned
}
日本語式の部品正体
viewerviewerこの文書に直接付けた閲覧者。すぐ上で宣言した relation
編集できる人edit同じ definition の permission の再利用。owner + editor を二度書きしない
親フォルダを見られる人parent->viewparent 関係の先 (folder) で view を評価する arrow
ただし banned は除く- bannedここまでの union 全体から banned の集合を引く

見どころは 2 つ。

  • 式の材料には relation だけでなく permission も置ける (edit の再利用)。「編集できる人は当然見られる」を一度だけ書いておけば、後で edit の定義が変わっても (editor をやめてグループ制にしても) view は勝手に追従する
  • - banned は括弧の外に一度だけ。直接の viewer でも、編集者でも、フォルダ経由でも、どの経路から届いた人も banned なら等しく除かれる。経路ごとに引き算を書いて回る必要はない

全部入りの実例

応用アプリのスキーマ app/schema/schema.zed が上の部品の全部入りで、コメントも添えてある。各部品がなぜその場所にあるかは 07 章で解剖する。

スキーマの型検査
relation viewer: user | group#member の型注釈は書き込み時に検査される。型に無い relationship (例: document を viewer に入れる) は書けない。「関係の相手が何者か」をスキーマが保証するので、データの荒れ方が一段減る。

次章: 03 relationship と Check の実体 →

03relationship と Check の実体

relationship は「データ」

スキーマが型なら、relationship は行。1 本がグラフの辺 1 つに当たる。

document:design#viewer@user:bob
<資源>:<id>#<relation>@<主体>:<id>

SpiceDB はこれを自分の datastore (05 章) に保存する。SpiceDB は「判定エンジン」ではなく「認可データを持つデータベース」。アプリの DB には文書の中身を、SpiceDB には「誰がどう関わるか」を置く、という分担になる (07 章)。

書き込み API

API役割補足
WriteRelationships複数の更新を 1 トランザクションで操作は CREATE / TOUCH / DELETE
DeleteRelationshipsフィルタ一致をまとめて削除一致 0 件でもエラーにならない
ImportBulk / ExportBulk大量投入・退避移行や初期投入用
CREATE と TOUCH の違い
CREATE は「既にあればエラー」、TOUCH は「あってもなくても最終的にある」(冪等)。アプリの再試行を考えると、通常は TOUCH のほうが扱いやすい。docshare のラッパも TOUCH に寄せている。

書き込みはどれも ZedToken (その書き込みを含む revision の栞) を返す。これが後で効いてくる (06 章)。

Check はグラフ探索 — 動かして見る

Check(document:design, view, user:bob) が中で何をしているか。まず前提を固定する。スキーマは app/schema/schema.zed の抜粋 (share / create_document は省略。この抜粋だけでも zed validate が通る形にしてある)。

definition user {}

definition group {
    relation member: user | group#member
}

caveat not_expired(current_time timestamp, expires_at timestamp) {
    current_time < expires_at
}

definition folder {
    relation parent: folder
    relation owner: user
    relation editor: user | group#member
    relation viewer: user | group#member

    permission edit = owner + editor + parent->edit
    permission view = viewer + edit + parent->view
}

definition document {
    relation parent: folder
    relation owner: user
    relation editor: user | group#member
    relation viewer: user | group#member | user:*
    relation shared_viewer: user with not_expired
    relation banned: user

    permission edit = (owner + editor + parent->edit) - banned
    permission view = (viewer + shared_viewer + edit + parent->view) - banned
}

世界を作る relationship は 5 本。下の図の辺と 1:1 で対応する。

group:eng#member@user:bob                  ← bob は eng のメンバー
folder:root#owner@user:alice               ← alice は root の owner
folder:eng-docs#parent@folder:root         ← eng-docs は root の中
folder:eng-docs#viewer@group:eng#member    ← eng メンバーは eng-docs の viewer
document:design#parent@folder:eng-docs     ← design は eng-docs の中

この世界で Check(document:design, view, user:bob) を評価する。「次へ」で 1 手ずつ進めてみる。

check の歩み — bob は design を view できるか?
document:design folder:eng-docs folder:root group:eng user:alice user:bob parent parent viewer (#member) owner member
サブ問題とキャッシュ

「folder:eng-docs を view できるか」のようなサブ問題の答えは revision 付きでキャッシュされ、別の check から再利用される。上の世界に document:roadmap#parent@folder:eng-docs をもう 1 本足し、bob が design → roadmap の順に check される場面を動かして見る。

サブ問題キャッシュ — 同じフォルダの 2 文書を続けて check する
document:design document:roadmap folder:eng-docs group:eng user:bob parent parent viewer (#member) member
サブ問題キャッシュ (revision 付き)
(まだ空)

同じフォルダの文書一覧で N 枚 check しても、フォルダ側の評価が 1 回で済むのはこれ。量子化 (06 章) が「同じ revision」を長持ちさせるのは、このキャッシュの命中率を保つためでもある。

dispatch
SpiceDB を複数ノードで動かすとき、サブ問題を担当ノードへ振り分ける仕組み。同じサブ問題が同じノードに集まるのでキャッシュ命中率が上がる。学習中は 1 プロセスなので気にしなくてよいが、「サブ問題に分解される」構造がスケールの根拠になっている。

問いは Check だけではない

API問い使いどころ
CheckPermissionこの人はこの資源に X できる?リクエストの認可判定
CheckBulkPermissions上をまとめて何件も画面の出し分け一括判定
LookupResourcesこの人が X できる資源は?一覧画面 (知らないと N+1 に落ちる)
LookupSubjectsこの資源に X できる人は?共有ダイアログ、監査
ExpandPermissionTree権限の木そのもの管理画面・可視化
Watchrelationship の変更ストリームキャッシュ無効化、監査ログ
N+1 問題
一覧を出すのに「全件取得 → 1 件ずつ check」とすると、件数ぶんの RPC が飛ぶアンチパターン。逆引きは LookupResources に任せ、アプリ DB から本体をまとめて引くのが正しい形 (docshare の GET /documents)。

アプリから見た 1 リクエストの一生

ここまでの部品を、アプリの 1 リクエストに並べるとこうなる。読みは「check → 本体を取りに行く」、書きは「check → relationship を書く → 返ってきた ZedToken を保存する」の形が基本。

1 リクエストの一生 — 「次へ」でメッセージが 1 本ずつ流れる
クライアント アプリ アプリ DB SpiceDB 読み: GET /documents/design (認証済み: bob) 書き: POST /documents/design/share (alice が carol に viewer) GET /documents/design CheckPermission(design, view, bob) + consistency / context HAS_PERMISSION 文書本体を SELECT title / body 200 (title / body) POST .../share {user:carol, viewer} CheckPermission(design, share, alice) HAS_PERMISSION WriteRelationships(TOUCH design#viewer@user:carol) ZedToken t1 文書の行に t1 を保存 200
  • check に添えている consistency と context の意味はそれぞれ 06 章と tutorial/06。書きで返る ZedToken を保存する理由も 06 章
  • 実装の lane 分け (httpapi / authz / store) は 07 章で解剖する

手を動かすなら tutorial/07 がこれらを一巡する。

次章: 04 zed とスキーマのテスト →

04zed とスキーマのテスト

zed = SpiceDB の公式 CLI

よく使う形何をする
zed validate <file>サーバ無しでスキーマ+データ+期待値を検証 (この教材の主役)
zed schema write / readスキーマの登録と取得
zed relationship create / touch / delete / readrelationship の CRUD
zed permission check / lookup-resources / lookup-subjects権限の問い
zed permission check --explain解決経路の木を表示 (デバッグの主力)
zed importvalidate ファイル形式をそのままサーバへ投入

サーバに繋ぐ系は接続先が要る。使い捨てなら --endpoint localhost:50051 --token <key> --insecure をその都度、常用なら zed context set (~/.zed に保存される点だけ注意)。

--insecure
TLS 無しの平文 gRPC で繋ぐフラグ。ローカル学習用。本番は TLS を張るので不要になる。

validate ファイル — スキーマの単体テスト

tutorial の各章に置いた validate.yaml の形式。SpiceDB Playground と同じもので、スキーマ・データ・期待値を 1 ファイルに閉じる。

schemaFile: schema.zed        # または schema: |- でインライン
relationships: |-
  document:doc1#reader@user:alice
assertions:
  assertTrue:
    - "document:doc1#view@user:alice"
    - 'document:doc1#view@user:bob with {"current_time": "2026-06-01T00:00:00Z"}'
  assertFalse:
    - "document:doc1#edit@user:alice"
  assertCaveated:
    - "document:doc1#view@user:bob"
validation:
  "document:doc1#view":
    - "[user:alice] is <document:doc1#reader>"
ブロック検証すること
assertions.assertTrue / assertFalse誰ができる / できない (caveat の context は with {...})
assertions.assertCaveatedcontext 不足なら「条件付き」になること
validationその権限がどの relationship 経由かまで固定する
assertCaveated
caveat の判定に必要な context が足りないとき、SpiceDB は true/false ではなく CONDITIONAL を返す (tutorial/06)。「必ず条件付きになるはず」を固定する assertion。
validation ブロックの書き方のコツ
期待する展開結果の文字列は手で当てるのが難しいが、間違えると zed validate が正しい内容を提示してくれるので、一度わざと空で回して出力を写すのが速い。展開が面白い章 (tutorial/03, 04) にだけ置いてある。

スキーマは必ずテストと共に

スキーマ 1 行の変更は、デプロイなしで全アプリの権限を変える。強力さは怖さでもあり、この repo では schema.zed の隣に必ず validate.yaml を置くことにしている (app/schema/ も同じ)。特に価値が高いのは assertFalse 側で、「公開しすぎ」「剥奪漏れ」という事故はこちらが検知する。

CI 相当の一括実行は scripts/check-all.sh。数十 ms/件なので、スキーマを触るたびに全部回してよい。

Playground
https://play.authzed.com — validate ファイルと同じ内容をブラウザで対話的に編集・可視化できる。共有リンクでスキーマ議論をするのに便利。この repo が Playground ではなくローカルの zed validate を主役にした理由は ADR 001。
composable schema
スキーマを複数ファイルに分割して合成する仕組み (zed preview schema compile)。定義が増えてきた実務向けで、学習中は 1 ファイルで十分。

次章: 05 SpiceDB を動かす →

05SpiceDB を動かす

最小の起動

spicedb serve --grpc-preshared-key "somekey"

これだけでメモリ datastore の SpiceDB が立つ。開くポートは 3 つ。

ポート何が居るか既定
gRPC一次 API (アプリ・zed はここ):50051
HTTPREST ゲートウェイ (--http-enabled のときだけ):8443
metricsPrometheus 形式のメトリクス:9090
preshared key
クライアントが Authorization: Bearer <key> として送る共有シークレット。key が合わないと API は一切応答しない。ローカルと CI 用の素朴な認証で、本番 (特にマネージド) はサービス認証や mTLS 側に寄せる。
HTTP ゲートウェイ
gRPC が使えない環境 (社内 proxy、ブラウザなど) のための REST 変換。POST /v1/permissions/check のような JSON API が gRPC と同じ意味で使える。公式クライアントがある言語なら gRPC を選ぶ。

datastore — relationship の置き場所

engine用途
memory (既定)学習・ローカル。プロセス終了で消える。複数レプリカ不可
postgres本番の第一候補。運用ノウハウが世の中に多い
cockroachdb / spannerマルチリージョンで書き込みも分散させたい規模
mysql既存 MySQL 資産に載せたい場合

永続 datastore を使うときは、起動前にマイグレーションを流す。

spicedb datastore migrate head --datastore-engine postgres --datastore-conn-uri "..."
spicedb serve --datastore-engine postgres --datastore-conn-uri "..." --grpc-preshared-key "..."
マイグレーション
SpiceDB 自身のテーブル定義の版上げ。migrate head で最新へ。SpiceDB 本体の更新時は release notes がマイグレーション要否を書いている。

スキーマや relationship はどの engine でも同じに見える。アプリコードを変えずに memory → postgres へ差し替えられるので、学習は memory で何も失わない。

テスト専用サーバ — serve-testing

spicedb serve-testing

preshared key ごとに独立した空の datastore を割り当てる特別なサーバ。テストごとにランダムな key を使えば、1 プロセスを共有しながら完全に隔離できる。docshare の統合テスト (server_test.go) がこの形で、テスト 1 本 ≒ 0.5 秒に収まっている。

運用の入口 (紹介だけ)

  • メトリクス: :9090 が Prometheus 形式。ダッシュボードは Grafana などで
  • Kubernetes: 公式の SpiceDB Operator が版上げ・マイグレーションまで面倒を見る
  • マネージド: AuthZed Dedicated / Cloud。API 互換なのでアプリはそのまま
  • 検証系のフラグは repo の実例が参考になる: tutorial/07 run.sh (ポート指定・metrics 無効化)、tutorial/08 run.sh (量子化間隔の変更)
SpiceDB Operator
k8s 上で SpiceDB クラスタを宣言的に管理する公式 operator。SpiceDBCluster リソースを書くと、ローリング更新とマイグレーションの順序を制御してくれる。

次章: 06 一貫性と New Enemy 問題 →

06一貫性と New Enemy 問題

アプリを書く人はこの章だけは飛ばさないこと。 権限「付与」の遅れは苦情で済むが、権限「剥奪」の取りこぼしは事故になる。

SpiceDB は少し古い世界で答えることがある

check のたびに最新データで評価すると、revision が動き続けてキャッシュ (03 章) がほぼ効かない。そこで SpiceDB は時間を一定幅 (既定 5 秒) に丸め、窓の中では同じ revision を使い回す。これが量子化 (quantization)。

revision
datastore の「その時点の全 relationship」を指す版番号。書き込みのたびに進む。check は必ずどれかの revision 上で評価される (スナップショット読み)。

代償として、既定の consistency (minimize_latency) では書き込み直後の check が、書き込み前の revision で評価されることがある。この repo でも構築中に実際に踏んだ: relationship を書いた数百 ms 後の check が false を返した (ADR 002 の背景)。

New Enemy 問題 — 動かして見る

「少し古い」が事故になる典型を Zanzibar 論文は New Enemy 問題と名付けた。下のスイッチを切り替えて、同じ操作列の結末がどう変わるかを見る。

剥奪 → 新文書 → bob のアクセス。結末は? (「次へ」でメッセージが流れる)
どちらも同じ 8 手。途中で切り替えて見比べられる
alice アプリ アプリ DB SpiceDB bob

順序が保存されないことが本質で、キャッシュを持つ認可システムはどれもこの問題と向き合うことになる。

ZedToken — 「この時点以降で評価して」という栞

SpiceDB のすべての書き込み (WriteRelationships / DeleteRelationships / WriteSchema) は ZedToken を返す。以後の読みにこれを添えると「少なくともこの書き込みを含む世界で評価せよ」と要求できる。定石は「資源の行に ZedToken を 1 列足す」。

  1. 文書の ACL を変えたら、返ってきた ZedToken を文書の行に保存
  2. その文書の check では at_least_as_fresh(保存した token) を渡す

これで「その資源について自分が知っている最新」より古い世界では評価されない。実装は docshare の internal/store がそのまま。

高水位トークン

「資源の行に保存」した token は、資源が特定できる読みにしか渡せない。一覧 (LookupResources) や、行を持たない資源の check (docshare のフォルダ) には取り出す行が無い。そこで docshare は「プロセスが最後に見た ZedToken」を 1 つだけ別に覚えている。これが高水位トークン (store.lastZedToken)。水位が上がっていく様子を動かして見る。

高水位トークン — 書き込みのたびに水位が上がる
r1seed (外部) r2起動 schema r3roadmap 作成 r5design 共有 r6design 剥奪 高水位 = r2 design の行 = r5 roadmap の行 = r3
  • 行の token は「その資源の最後の ACL 変更」で止まる。 roadmap の行が r3 のままでよいのは、その後の書き込み (r5, r6) が roadmap の見え方に関係ないから。資源ごとの必要最小限で、古い revision を許すぶんキャッシュにも優しい
  • 高水位は上がるだけ。 どの書き込みでも更新するので、「このプロセスが行った / 観測した書き込み全部」を覆う上限になる
読み渡す token保証されること
GET /documents/roadmaproadmap の行 (r3)roadmap の ACL 変更は必ず見える
GET /documents (一覧)高水位 (r6)プロセスが知る書き込み全部が見える

初期値は起動時 WriteSchema の返り値 (図の r2)。起動より前に外 (run.sh の seed) で書かれた分は r2 より古い revision なので、これで覆われる (store.ObserveZedToken、ADR 002)。

限界も知っておく。高水位は「自分が見たもの」の最大値なので、別プロセスが書いた relationship は覆えない (見ていない token は水位に入らない)。その分は量子化の窓ぶん遅れて見える (minimize_latency と同じ扱い)。複数プロセスの間でも確実にしたい資源は、token を共有 DB の行に置く — つまり定石 (資源の行に保存) に戻る。

consistency は 4 段階

指定意味使いどころ
minimize_latency (既定)量子化された revision でよい大半の読み。速くてキャッシュが効く
at_least_as_fresh(token)token 以上に新しい世界で定石。資源に保存した token を渡す
at_exact_snapshot(token)ちょうどその時点でページネーション整合・監査の再現
fully_consistent常に最新剥奪を 1 秒も漏らせない画面。高くつく
at_exact_snapshot の失効
datastore の GC 窓 (既定 24h 程度) を過ぎた revision を指すとエラーになる。ZedToken は「しばらく有効な栞」で、永久保存する類のものではない。at_least_as_fresh は古い token を渡しても「それ以上新しい」だけなので害がない。
fully_consistent の値段
常に最新 revision で評価する = revision キャッシュの再利用がほぼ効かない。1 回のコストではなくシステム全体のキャッシュ命中率を下げる点が高くつく。「全部 fully にすれば安全」は、SpiceDB を選んだ理由 (速さ) を捨てる選択。

体験する

  • tutorial/08 — 量子化を 8 秒に広げ、「剥奪直後でも at-exactly なら true / at-least なら必ず false」を実際に見る
  • docshare の統合テスト — 「unshare 直後の GET が 404」が決定的に通るのは ZedToken の保存があるから

次章: 07 アプリへの組み込み →

07アプリへの組み込み

総仕上げ。docshare を解剖しながら、SpiceDB をアプリに組み込むときの判断を一つずつ言葉にする。

責務の分担

持ち物置き場所
文書の中身 (title / body)アプリの DB (docshare ではインメモリ store)
誰がどう関わるかSpiceDB の relationship
誰が何をできるか (規則)SpiceDB のスキーマ
誰なのか (認証)認証基盤 (docshare では X-User で省略。OAuth / OIDC との切り分けは 00 章のコラム)

アプリのハンドラには権限の if 文を書かない。「この操作の可否は SpiceDB のどの permission か」だけを決める。共有 → 直後の閲覧、の流れはこう動く。

docshare — 共有から閲覧まで (ZedToken の保存が肝)
  1. alice POST /documents/design/share {user:carol, viewer}
  2. httpapi → SpiceDB: Check(design, share, alice, at_least_as_fresh t0) → HAS_PERMISSION
  3. httpapi → SpiceDB: WriteRelationships (TOUCH viewer carol) → ZedToken t1
  4. store: design の行に t1 を保存 ← ここが肝
  5. carol GET /documents/design (直後)
  6. httpapi: store から design を取り出し (ZedToken=t1)、Check(design, view, carol, at_least_as_fresh t1)
  7. t1 以上に新しい世界で評価 → 共有が必ず見える → 200。剥奪 (unshare) も同じ形で、直後から必ず 404

読み (check) の置き方

  • ハンドラの入口で 1 回 check が基本形。docshare は GET /documents/{id} → document#view、PUT → edit、共有系 → share
  • 403 と 404 を設計する: view の無い人には 404 (存在ごと隠す)、view はあるが edit が無い人には 403。この出し分け自体が認可の仕様
  • 画面の出し分けなどで check が束になるなら CheckBulkPermissions
ミドルウェアで check しない理由
check には資源 ID と permission が要り、それは大抵ルーティング後に決まる。「認証はミドルウェア、認可はハンドラ冒頭」が収まりがよい。資源 ID がパスから機械的に取れる設計なら、認可もミドルウェアに寄せられる。

一覧は逆引き API で

「自分が見られる文書一覧」は LookupResources に聞き、返ってきた ID でアプリ DB から本体をまとめて引く (docshare の GET /documents)。全件取得して 1 件ずつ check する N+1 に落ちないこと。件数が大きいなら OptionalLimit + カーソルでページングし、並び順や検索と混ぜたい場合は「ID 集合を WHERE IN に混ぜる」形になる。

書き込みの作法

資源の作成は「アプリ DB への保存」と「relationship の書き込み」の 2 つの書き込みになる。

1. 権限 check (folder#create_document)
2. SpiceDB へ WriteRelationships (owner, parent)   → ZedToken が返る
3. アプリ DB へ保存 (ZedToken も一緒に)
二重書き込み問題

2 と 3 はトランザクションで括れないので、間で落ちるとズレる。落ちたら何が残るかを動かして見る。

2 つの書き込みの間で落ちたら
アプリ SpiceDB アプリ DB ① WriteRelationships (owner / parent を TOUCH) ZedToken t ② 本体を保存 … の前にプロセスが落ちた✗ ①' 同じ TOUCH をもう一度 (冪等) ZedToken t' ②' 本体を保存 (成功)

対策は段階がある:

  1. 順序を決める: 先に SpiceDB、後にアプリ DB。孤児になるのは「参照されない relationship」で、逆 (権限の無い行) より安全
  2. 冪等にして再試行: TOUCH は冪等なので、作成 API 全体を再試行可能にする
  3. 突き合わせ: 定期ジョブで両者を突き合わせて掃除 (リコンサイル)、または DB のトランザクションに書き込み予定を積んで非同期反映 (outbox)
docshare は 1 と 2 の途中まで。教材なので「問題がある」と明示する側に倒した (該当コードのコメント参照)。
SoT (Source of Truth)
「その事実の正本はどこか」。関係の出どころがアプリ操作 (共有ボタン) なら SpiceDB を関係の SoT にしてよい。出どころが別システム (人事 DB のグループなど) ならそちらが SoT で、SpiceDB へは同期して写しを置くことになる。何が正本かを 1 つずつ決めておくと、ズレたときどちらへ寄せるかで迷わない。

剥奪を取りこぼさない (06 章の実装)

  • 書き込みが返す ZedToken を資源の行に保存、その資源の check で at_least_as_fresh — store.go の主題
  • 資源に紐づかない読み (一覧、フォルダ check) は高水位トークン
  • caveat の current_time は必ずアプリが渡す。ラッパ (authz.go) に閉じ込めて渡し忘れを防ぐ

スキーマ変更の運用

  • スキーマは repo で管理し、zed schema write は CI (マイグレーション) から流す。docshare は起動時に書いているが、これはデモ用の妥協 (コメントに明記)
  • 変更は後方互換の順序で: relation を消すときは、先に permission の式から外し、参照ゼロを確認してから relation とデータを消す
  • スキーマの隣の validate.yaml を必ず更新する (04 章)。「見られてはいけない」側の assertion が事故を検知する

テスト戦略

層道具この repo での実例
スキーマ単体zed validate各 tutorial 章、app/schema/validate.yaml
アプリ統合spicedb serve-testingserver_test.go

serve-testing は preshared key ごとに独立 datastore なので、テストの並列実行も後片付けも考えなくてよい。モックは作らない — 認可の正しさはスキーマと SpiceDB の意味論に依存していて、モックだとその部分ごと偽物になるから。

ここまで来たら

  • 公式ドキュメント (https://authzed.com/docs) がリファレンスとして読めるようになっている
  • スキーマ設計の引き出しを増やすなら公式の例 (Google Drive / GitHub / Slack 相当) を zed validate に写して崩してみるのが速い
  • 本番導入の残件は運用側: datastore 選定 (05 章)、監視、スキーマ CI、そして 06 章の一貫性方針をチームの言葉にすること
おつかれさまでした。手を動かす側の対 (tutorial/) と応用 (app/) が repo にある。