docs/architecture/textbook/*.md と同じ内容を、
サイドバー・ステップ送りの図・用語アコーディオンで読みやすくしたもの。
正本は Markdown 側。手を動かす対の教材は tutorial/、応用は app/ (docshare)。
急いでいる場合は 00 → 02 → 04 の 3 章でスキーマを書いてテストできるようになる。
本番投入の前には 06 と 07 を必ず読む。
00なぜ認可は難しいのか
if 文で始まり、if 文で破綻する
文書共有サービスを作るとする。最初の認可はこの程度で済む。
if doc.OwnerID == user.ID { /* OK */ }
要求はすぐ増える。
| # | 要求 | 正体 |
|---|---|---|
| 1 | フォルダに入れた文書は、フォルダを見られる人も見られるように | 階層 |
| 2 | eng チームの全員に見せたい。メンバーの出入りで文書側は触りたくない | グループ |
| 3 | 全社公開の文書 | 公開 |
| 4 | 社外の人に 1 時間だけ共有 | 期限 |
| 5 | この人だけは何があっても見せない | ブロック |
| 6 | 「自分が見られる文書の一覧」を出したい | 逆引き |
これを if 文で追うと、権限ロジックがハンドラ中に散らばり、「結局この文書を誰が見られるのか」に誰も答えられなくなる。特に 6 の逆引きは、判定ロジックが命令形コードの中にあると原理的に書けない。
認証と認可
X-User ヘッダを信じる。
なお OAuth 2.0 の文脈にも「認可」という言葉が出てくるが、あれは別の問題を指している。章末のコラムで切り分ける。
Zanzibar — Google の答え
Google も同じ問題を抱えていた。Drive・Docs・YouTube・Photos などの認可を一手に引き受ける社内システムが Zanzibar で、2019 年に論文が公開された。要点は 3 つ。
- 認可を専用のサービスに集約する (各アプリの if 文をやめる)
- 権限は「関係」のグラフとして表す (01 章の ReBAC)
- 分散環境でもキャッシュが効き、かつ権限剥奪を取りこぼさない仕掛けを持つ (06 章の New Enemy 問題と ZedToken)
Zanzibar 論文
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
OSS 版とマネージド版
gRPC
この 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) |
| 取り消し | トークンの期限切れ / revoke | relationship の削除 (直後から確実に。06 章) |
実際のシステムでは両方を重ねて使う。典型的な 1 リクエストはこう流れる。
- クライアントがアクセストークン付きでリクエストを送る
- サービスはトークンを検証し、「誰の代理か (認証)」と「アプリに貸された範囲 (scope)」を確認する — OAuth / OIDC の領分
- その上で「ではその人は document:design を view できるのか」を SpiceDB に聞く — この本の領分
scope を通過しても、資源単位の可否はまだ何も決まっていない。scope は「アプリに貸した API の範囲」であって「ユーザー自身の権限」ではないから。docshare の X-User ヘッダは、この 1〜2 が済んだ後の状態を素朴に代用したもの。
scope に資源を詰めたくなったら
document:123:read のような資源単位の scope を切りたくなったら、それはアクセス制御を OAuth 側でやろうとしているサイン。トークン発行時に権限が固定されるので剥奪が効かず (トークンが生きている限り読める)、資源 × 操作の組み合わせで scope が爆発する。scope は API の粗い範囲に留め、資源単位の可否はアクセス制御側 (SpiceDB) で都度判定するのが定石。
OIDC との三角関係
01ReBAC という考え方
権限モデルの系譜
| モデル | 権限の根拠 | 例 |
|---|---|---|
| ACL | 資源ごとの許可リスト | この文書の閲覧者: alice, bob |
| RBAC | 役割 (role) | 「編集者」ロールを持つ人は編集できる |
| ABAC | 属性の論理式 | 部署 = 営業 かつ 等級 ≥ 3 なら閲覧可 |
| ReBAC | 関係のグラフ | 文書 ← フォルダ ← owner という関係をたどれる人 |
ACL (Access Control List)
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 を足し、グループの入れ子が要求された時点で、メンバー解決の再帰クエリを手書きする。
ロール爆発
再帰クエリ (WITH RECURSIVE)
段階 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 (拒否) と評価順
- (exclusion) として順序ごと宣言できる (tutorial/02)。
段階 5 — 逆引きで二重実装になる
「自分が見られる文書の一覧」は、段階 3 の Go のループでは書けない (全文書に対してループを回すわけにはいかない)。そこで同じ規則を SQL の JOIN に裏返してもう 1 回書く。check (Go のループ) と一覧 (SQL) は別実装なので、仕様変更のたびに両方を直す。どちらかの直し漏れが、そのまま権限事故になる。
降りきった先が ReBAC
気づけば作っているのは「関係を表に保存し、チェック時にたどる」自作エンジンで、構造はもう ReBAC になっている。ただしテスト・キャッシュ・一貫性の面倒は誰も見ていない。これを汎用化して専用サービスへ切り出し、磨き上げたものが Zanzibar であり SpiceDB。つまり「RBAC → ReBAC」は別物への乗り換えというより、自作しかけたものの完成品に移る話になる。
ReBAC: 権限 = グラフの到達可能性
ReBAC は発想を変える。世界を「オブジェクト」と「関係」のグラフとして記録し、権限の問いを「グラフをたどって届くか」に置き換える。応用アプリ docshare の世界はこうなっている (辺 1 本 = relationship 1 本)。
「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)
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 Conditions | binding に付く CEL の条件式 (時刻・リソース名など) | caveat — 言語まで同じ CEL (tutorial/06) |
| Deny policies | allow に勝つ拒否。後年に別機構として追加された | 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スキーマ言語の全体系
スキーマは世界の型を決める。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 章) が持つ。
名詞 / 動詞の命名規約
check document:x view user:y が英文として読め、relation と permission の取り違えも起きにくい。
部品の一覧
| 部品 | 書き方 | 意味 | 手を動かす |
|---|---|---|---|
| union | a + b | どちらかに入っていれば | tutorial/02 |
| intersection | a & b | 両方に入っている人だけ | tutorial/02 |
| exclusion | a - b | b の人を除く | tutorial/02 |
| arrow | parent->view | 関係の先のオブジェクトで view を評価 | tutorial/03 |
| subject relation | group#member | グループの member 集合を関係の相手に | tutorial/04 |
| wildcard | user:* | 「user 型の全員」という relationship を許す | tutorial/05 |
| caveat | user with not_expired | 条件式 (CEL) 付きの関係 | tutorial/06 |
arrow の Zanzibar 用語
-> はこれの読みやすい記法。
CEL
設計の指針
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
}
| 日本語 | 式の部品 | 正体 |
|---|---|---|
| viewer | viewer | この文書に直接付けた閲覧者。すぐ上で宣言した relation |
| 編集できる人 | edit | 同じ definition の permission の再利用。owner + editor を二度書きしない |
| 親フォルダを見られる人 | parent->view | parent 関係の先 (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 の違い
書き込みはどれも 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 手ずつ進めてみる。
サブ問題とキャッシュ
「folder:eng-docs を view できるか」のようなサブ問題の答えは revision 付きでキャッシュされ、別の check から再利用される。上の世界に document:roadmap#parent@folder:eng-docs をもう 1 本足し、bob が design → roadmap の順に check される場面を動かして見る。
同じフォルダの文書一覧で N 枚 check しても、フォルダ側の評価が 1 回で済むのはこれ。量子化 (06 章) が「同じ revision」を長持ちさせるのは、このキャッシュの命中率を保つためでもある。
dispatch
問いは Check だけではない
| API | 問い | 使いどころ |
|---|---|---|
CheckPermission | この人はこの資源に X できる? | リクエストの認可判定 |
CheckBulkPermissions | 上をまとめて何件も | 画面の出し分け一括判定 |
LookupResources | この人が X できる資源は? | 一覧画面 (知らないと N+1 に落ちる) |
LookupSubjects | この資源に X できる人は? | 共有ダイアログ、監査 |
ExpandPermissionTree | 権限の木そのもの | 管理画面・可視化 |
Watch | relationship の変更ストリーム | キャッシュ無効化、監査ログ |
N+1 問題
GET /documents)。
アプリから見た 1 リクエストの一生
ここまでの部品を、アプリの 1 リクエストに並べるとこうなる。読みは「check → 本体を取りに行く」、書きは「check → relationship を書く → 返ってきた ZedToken を保存する」の形が基本。
- check に添えている consistency と context の意味はそれぞれ 06 章と tutorial/06。書きで返る ZedToken を保存する理由も 06 章
- 実装の lane 分け (httpapi / authz / store) は 07 章で解剖する
手を動かすなら tutorial/07 がこれらを一巡する。
04zed とスキーマのテスト
zed = SpiceDB の公式 CLI
| よく使う形 | 何をする |
|---|---|
zed validate <file> | サーバ無しでスキーマ+データ+期待値を検証 (この教材の主役) |
zed schema write / read | スキーマの登録と取得 |
zed relationship create / touch / delete / read | relationship の CRUD |
zed permission check / lookup-resources / lookup-subjects | 権限の問い |
zed permission check --explain | 解決経路の木を表示 (デバッグの主力) |
zed import | validate ファイル形式をそのままサーバへ投入 |
サーバに繋ぐ系は接続先が要る。使い捨てなら --endpoint localhost:50051 --token <key> --insecure をその都度、常用なら zed context set (~/.zed に保存される点だけ注意)。
--insecure
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.assertCaveated | context 不足なら「条件付き」になること |
validation | その権限がどの relationship 経由かまで固定する |
assertCaveated
validation ブロックの書き方のコツ
スキーマは必ずテストと共に
スキーマ 1 行の変更は、デプロイなしで全アプリの権限を変える。強力さは怖さでもあり、この repo では schema.zed の隣に必ず validate.yaml を置くことにしている (app/schema/ も同じ)。特に価値が高いのは assertFalse 側で、「公開しすぎ」「剥奪漏れ」という事故はこちらが検知する。
CI 相当の一括実行は scripts/check-all.sh。数十 ms/件なので、スキーマを触るたびに全部回してよい。
Playground
composable schema
zed preview schema compile)。定義が増えてきた実務向けで、学習中は 1 ファイルで十分。
05SpiceDB を動かす
最小の起動
spicedb serve --grpc-preshared-key "somekey"
これだけでメモリ datastore の SpiceDB が立つ。開くポートは 3 つ。
| ポート | 何が居るか | 既定 |
|---|---|---|
| gRPC | 一次 API (アプリ・zed はここ) | :50051 |
| HTTP | REST ゲートウェイ (--http-enabled のときだけ) | :8443 |
| metrics | Prometheus 形式のメトリクス | :9090 |
preshared key
Authorization: Bearer <key> として送る共有シークレット。key が合わないと API は一切応答しない。ローカルと CI 用の素朴な認証で、本番 (特にマネージド) はサービス認証や mTLS 側に寄せる。
HTTP ゲートウェイ
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 "..."
マイグレーション
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
SpiceDBCluster リソースを書くと、ローリング更新とマイグレーションの順序を制御してくれる。
06一貫性と New Enemy 問題
SpiceDB は少し古い世界で答えることがある
check のたびに最新データで評価すると、revision が動き続けてキャッシュ (03 章) がほぼ効かない。そこで SpiceDB は時間を一定幅 (既定 5 秒) に丸め、窓の中では同じ revision を使い回す。これが量子化 (quantization)。
revision
代償として、既定の consistency (minimize_latency) では書き込み直後の check が、書き込み前の revision で評価されることがある。この repo でも構築中に実際に踏んだ: relationship を書いた数百 ms 後の check が false を返した (ADR 002 の背景)。
New Enemy 問題 — 動かして見る
「少し古い」が事故になる典型を Zanzibar 論文は New Enemy 問題と名付けた。下のスイッチを切り替えて、同じ操作列の結末がどう変わるかを見る。
順序が保存されないことが本質で、キャッシュを持つ認可システムはどれもこの問題と向き合うことになる。
ZedToken — 「この時点以降で評価して」という栞
SpiceDB のすべての書き込み (WriteRelationships / DeleteRelationships / WriteSchema) は ZedToken を返す。以後の読みにこれを添えると「少なくともこの書き込みを含む世界で評価せよ」と要求できる。定石は「資源の行に ZedToken を 1 列足す」。
- 文書の ACL を変えたら、返ってきた ZedToken を文書の行に保存
- その文書の check では
at_least_as_fresh(保存した token)を渡す
これで「その資源について自分が知っている最新」より古い世界では評価されない。実装は docshare の internal/store がそのまま。
高水位トークン
「資源の行に保存」した token は、資源が特定できる読みにしか渡せない。一覧 (LookupResources) や、行を持たない資源の check (docshare のフォルダ) には取り出す行が無い。そこで docshare は「プロセスが最後に見た ZedToken」を 1 つだけ別に覚えている。これが高水位トークン (store.lastZedToken)。水位が上がっていく様子を動かして見る。
- 行の token は「その資源の最後の ACL 変更」で止まる。 roadmap の行が r3 のままでよいのは、その後の書き込み (r5, r6) が roadmap の見え方に関係ないから。資源ごとの必要最小限で、古い revision を許すぶんキャッシュにも優しい
- 高水位は上がるだけ。 どの書き込みでも更新するので、「このプロセスが行った / 観測した書き込み全部」を覆う上限になる
| 読み | 渡す token | 保証されること |
|---|---|---|
GET /documents/roadmap | roadmap の行 (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 の失効
fully_consistent の値段
体験する
tutorial/08— 量子化を 8 秒に広げ、「剥奪直後でも at-exactly なら true / at-least なら必ず false」を実際に見る- docshare の統合テスト — 「unshare 直後の GET が 404」が決定的に通るのは ZedToken の保存があるから
07アプリへの組み込み
総仕上げ。docshare を解剖しながら、SpiceDB をアプリに組み込むときの判断を一つずつ言葉にする。
責務の分担
| 持ち物 | 置き場所 |
|---|---|
| 文書の中身 (title / body) | アプリの DB (docshare ではインメモリ store) |
| 誰がどう関わるか | SpiceDB の relationship |
| 誰が何をできるか (規則) | SpiceDB のスキーマ |
| 誰なのか (認証) | 認証基盤 (docshare では X-User で省略。OAuth / OIDC との切り分けは 00 章のコラム) |
アプリのハンドラには権限の if 文を書かない。「この操作の可否は SpiceDB のどの permission か」だけを決める。共有 → 直後の閲覧、の流れはこう動く。
読み (check) の置き方
- ハンドラの入口で 1 回 check が基本形。docshare は
GET /documents/{id}→document#view、PUT→edit、共有系 →share - 403 と 404 を設計する: view の無い人には 404 (存在ごと隠す)、view はあるが edit が無い人には 403。この出し分け自体が認可の仕様
- 画面の出し分けなどで check が束になるなら
CheckBulkPermissions
ミドルウェアで check しない理由
一覧は逆引き 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 はトランザクションで括れないので、間で落ちるとズレる。落ちたら何が残るかを動かして見る。
対策は段階がある:
- 順序を決める: 先に SpiceDB、後にアプリ DB。孤児になるのは「参照されない relationship」で、逆 (権限の無い行) より安全
- 冪等にして再試行: TOUCH は冪等なので、作成 API 全体を再試行可能にする
- 突き合わせ: 定期ジョブで両者を突き合わせて掃除 (リコンサイル)、または DB のトランザクションに書き込み予定を積んで非同期反映 (outbox)
SoT (Source of Truth)
剥奪を取りこぼさない (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-testing | server_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 にある。