Skip to content

RBAC

実装: orchestration/rbac/ / 実行: go test ./orchestration/rbac/

NetworkPolicy は Pod どうしの通信を絞った。RBAC は誰が API を叩いてよいかを絞る。似た形だが既定の向きが逆で、通信は何も書かなければ全通し、API は何も書かなければ何も通らない。仕組みは足し算の許可リストだけで、拒否は書けない。書けないほうが安全になるのは、拒否と許可が混ざると実際に何が通るのかを人が追えなくなるからだ。

この章で作るもの

NetworkPolicyは、Pod どうしが繋いでよいかを絞った。この章は、誰が API を叩いてよいかを絞る。似た話に見えるが、既定の向きが逆になっている。通信は何も書かなければ全通しだった。API は何も書かなければ何も通らない。

この差は、扱っているものの違いから来ている。通信を既定で塞ぐと何も動かない。Pod を置いた瞬間に互いに繋がらないと、まず動くところまで持っていけない。一方 API は、叩けないことが正常な初期状態になる。新しく作った利用者が、いきなりクラスタ全体を消せては困る。

そして仕組みは、単純な足し算の許可リストになっている。「この人はこれをしてよい」しか書けず、「これはしてはいけない」は書けない。一見すると不便だが、書けないほうが安全になる理由がある。

  役割(許可の束)                  付与

  viewer                         alice ← viewer
   pods, deployments に               ← deployer
   get, list を許す
                                 bob   ← viewer
  deployer
   deployments に                 ci-bot ← (何も無い)
   get, list, create を許す              → 何もできない

  admin
   * に * を許す                  何も書かなければ何も通らない
                                  書いたものだけが通る
役割(許可の束)と、それを誰に与えるかを分ける。役割を直せば持つ全員に一度に効く

順に見ていく。

  1. 既定は全拒否: 通信とは逆。書いたものだけが通る
  2. 役割と付与を分ける: 許可の束に名前をつけ、誰に与えるかを別に書く
  3. 足し算しかない: 拒否は書けない。だから何が通るかを列挙できる

① 役割と付与を分ける

まず、許可の形を作る:

go

// Verb は API に対する操作。
type Verb string

const (
	Get    Verb = "get"
	List   Verb = "list"
	Create Verb = "create"
	Update Verb = "update"
	Delete Verb = "delete"
)

// PolicyRule は「この資源にこの操作をしてよい」という許可1つ。
type PolicyRule struct {
	// Resources は対象の資源。"*" ならすべて。
	Resources []string
	// Verbs は許す操作。"*" ならすべて。
	Verbs []Verb
}

// allows は資源 res への操作 verb を許すかを返す。
func (r PolicyRule) allows(res string, verb Verb) bool {
	return contains(r.Resources, res) && containsVerb(r.Verbs, verb)
}

// Role は許可の束。名前をつけて、まとめて渡せるようにしたもの。
//
// 役割と、それを誰に与えるかを分けているのが肝になる。役割の中身を直せば、
// それを持つ全員に一度に効く。人ごとに許可を書くと、方針が変わったときに
// 全員ぶんを直すことになる。
type Role struct {
	Name  string
	Rules []PolicyRule
}

// Binding は「この役割を、この人たちに与える」という結びつけ。
type Binding struct {
	Role     string
	Subjects []string
}

Role が許可の束で、Binding がそれを誰に与えるかになる。この2つを分けているのが肝で、素朴には人ごとに許可を書きたくなるところを、一段挟んでいる。

分ける利点は、方針が変わったときに出る。「開発者は Pod を消してもよいことにする」と決めたとき、役割の中身を1箇所直せば、それを持つ全員に一度に効く。人ごとに書いていたら、全員ぶんを探して直すことになる。しかも、直し忘れた人が出ても気づきにくい。テストで、役割の中身を直すと、それを持つ全員の判定が同時に変わることを固定した。

もう1つ、役割を定義しただけでは誰にも効かない、という点も大事になる。定義と付与が別の操作なので、許可の束を用意しておいて、必要になったときに与えられる。逆に言えば、役割を作っただけで安心してはいけない。テストで、定義しただけでは通らず、与えて初めて通ることを固定した。

② 既定は全拒否

判定の本体を見る:

go

// Decision は1回の判定の結果と、その理由。
type Decision struct {
	Allowed bool
	Role    string // 効いた役割(拒否のときは空)
	Reason  string
}

// Authorizer は役割と結びつけを持ち、可否を判定する。
type Authorizer struct {
	roles    map[string]*Role
	bindings []Binding

	Allowed int
	Denied  int
	Log     []string
}

// New は何も許可されていない状態から始める。
// 何も書かなければ何も通らない、というのが既定になる。
func New() *Authorizer { return &Authorizer{roles: map[string]*Role{}} }

// AddRole は役割を1つ定義する。定義しただけでは誰にも効かない。
func (a *Authorizer) AddRole(r *Role) *Role {
	a.roles[r.Name] = r
	return r
}

// Bind は役割を人に与える。ここで初めて効く。
func (a *Authorizer) Bind(role string, subjects ...string) {
	a.bindings = append(a.bindings, Binding{Role: role, Subjects: subjects})
	a.logf(role + " を " + join(subjects) + " に与えた")
}

// Roles は名前順に役割を返す。
func (a *Authorizer) Roles() []*Role {
	names := make([]string, 0, len(a.roles))
	for n := range a.roles {
		names = append(names, n)
	}
	sort.Strings(names)
	out := make([]*Role, len(names))
	for i, n := range names {
		out[i] = a.roles[n]
	}
	return out
}

// RolesOf は subject が持つ役割の名前を返す。
func (a *Authorizer) RolesOf(subject string) []string {
	var out []string
	for _, b := range a.bindings {
		if contains(b.Subjects, subject) {
			out = append(out, b.Role)
		}
	}
	sort.Strings(out)
	return out
}

// Can は subject が資源 res に操作 verb をしてよいかを判定する。
//
// 判定は足し算で、持っている役割のどれか1つが許していれば通る。拒否は
// 書けないので、通らないものは「どこにも書いていない」だけになる。
// 何が通るかを知りたければ、書いてあるものを全部見ればよい。
func (a *Authorizer) Can(subject, res string, verb Verb) Decision {
	for _, name := range a.RolesOf(subject) {
		role := a.roles[name]
		if role == nil {
			continue
		}
		for _, rule := range role.Rules {
			if rule.allows(res, verb) {
				return Decision{Allowed: true, Role: name,
					Reason: name + " が " + res + " への " + string(verb) + " を許可している"}
			}
		}
	}
	return Decision{Reason: subject + " に " + res + " への " + string(verb) + " を許す役割が無い"}
}

// Do は判定したうえで数を記録する。
func (a *Authorizer) Do(subject, res string, verb Verb) Decision {
	d := a.Can(subject, res, verb)
	if d.Allowed {
		a.Allowed++
	} else {
		a.Denied++
		a.logf("拒否: " + d.Reason)
	}
	return d
}

Can は、持っている役割を順に見て、どれか1つでも許していれば通す。どれも許していなければ通さない。それだけになっている。役割を1つも持っていなければ、ループが1度も回らずに拒否になる。これが「何も書かなければ何も通らない」の実装で、特別な処理はどこにもない。

NetworkPolicyとの対比が効く。あちらは、方針が向いていない Pod は既定で通していた。方針を1つ向けた瞬間に既定が反転する、という仕掛けが要った。こちらには要らない。最初から全部拒否なので、反転させるものがない。

どちらが良いという話ではなく、扱うものの性質が違う。通信を既定で塞ぐと、まず何も動かない状態から始めることになり、現実的でない。API は逆で、既定で開けておくと、うっかり与えた利用者が全部を消せてしまう。危険の非対称が、既定の向きを決めている。テストで、何も与えていない状態では通らないことを固定した。

③ 拒否が書けないほうが安全になる

Can に「拒否」の分岐が無いことにも意味がある。RBAC は許可しか書けない。

一見すると不便だ。「admin を与えたが、secrets だけは触らせたくない」と思っても書けない。狭い役割を足しても打ち消せず、admin のほうを外すしかない。テストで、広い許可を持っているかぎり、狭い役割を足しても打ち消せないことを固定した。

だが、拒否を書けるようにすると別の問題が出る。許可と拒否が混ざったとき、どちらが勝つかの規則が要る。後に書いたほうか、より特定的なほうか、拒否が常に勝つのか。規則を決めても、役割が10個も重なると、実際に何が通るのかを人が追えなくなる。「なぜかこの操作だけ通らない」の原因が、どこか1つの拒否だったりする。

足し算だけなら、そうならない。通るものはどこかに書いてあり、書いていないものは通らない。「なぜ通るのか」を知りたければ、許可を探せばよい。「なぜ通らないのか」の答えは常に「どこにも書いていないから」になる。判定が追えることを、書ける表現力より優先している。

代わりに、広い許可を与えないことが運用の中心になる。ワイルドカードを1つ与えると、その人は全部できる。後から絞れないので、最初から必要なぶんだけ与える。テストで、ワイルドカード1つですべての資源とすべての操作が通ることを固定した。

動かす

下のデモは、3人に役割を与えながら判定表を見る。最初は誰も何も持っていないので、全部「拒否」になっている。役割を与えるとマスが開く。admin を与えるとその行が全部開き、そのあと狭い役割を足しても閉じない。

デモRBAC通る 0 / 9
役割を与える(クリックで付け外し)
alice
bob
ci-bot
判定する操作getlistcreatedelete
podsdeploymentssecrets
alice拒否拒否拒否
bob拒否拒否拒否
ci-bot拒否拒否拒否
誰にも役割を与えていないので、何も通らない。これが既定で、通信とは向きが逆になっている

最初は判定表が全部「拒否」になっている。通信は何も書かなければ全通しだったが、API は何も書かなければ 何も通らない。役割を与えて初めてマスが開く。admin は資源も操作もワイルドカードなので、1つ与えるだけで その行が全部開く。そして拒否は書けないので、admin を持ったまま狭い役割を足しても打ち消せない。 通らないものは禁止されているのではなく、どこにも書いていないだけになる。

設計の観点

  • 既定の向きは危険の非対称で決まる: 通信は塞ぐと動かない、API は開けると壊せる。同じ「絞る」でも、何が既定かは扱うものによって変わる
  • 一段挟むと方針が変えられる: 人に直接許可を書かず、役割を挟む。方針が変わったとき1箇所で済む
  • 表現力より追えることを優先する: 拒否を書けたほうが柔軟だが、混ざると人が追えなくなる。足し算だけにすると、判定が説明できる
  • 広い許可は後から絞れない: ワイルドカードは1つで全部を開ける。与える前に、本当に全部が要るかを見る
  • 定義と付与は別: 役割を作っただけでは効かない。逆に、誰も使っていない役割が溜まりやすい
  • NetworkPolicyとの対: あちらは通信、こちらは API。両方が足し算で、両方とも「後から塞げない」という同じ性質を持つ

対照と実例

NetworkPolicyRBAC
絞る対象Pod どうしの通信API の操作
既定全通し全拒否
反転の仕掛け方針を1つ向けると拒否に反転不要(最初から拒否)
拒否を書けるか書けない書けない
通らない理由守られていて許可が無いどこにも書いていない

裏どり:

  • RBAC: Role / ClusterRole が許可の束、RoleBinding / ClusterRoleBinding が付与。定義と付与が分かれている
  • 既定は拒否: 何のロールも持たない利用者は、何もできない
  • 拒否ルールは無い: RBAC は許可のみを表現する。複数のロールを持つ場合、許可は和になる
  • ワイルドカード: * は資源にも操作にも書ける。cluster-admin は実質これで、与えると絞れない

簡略化したこと

  • namespace の区別なし: 実物は Role(namespace 内)と ClusterRole(全体)を分ける
  • resourceNames なし: 特定の名前の資源だけを許す指定は扱わない
  • subresource なし: pods/log のような下位資源は扱わない
  • 集約なし: ラベルで複数の ClusterRole をまとめる仕組みは扱わない
  • 利用者の種類なし: 人とサービスアカウントの区別は扱わない

参考資料