天天看點

casbin權限模型推演

作者:鄧big胖

無論什麼項目隻要涉及到多個使用者的操作都會開始考慮權限控制, 權限管理是一個很常見部分,是以出現了單獨處理這個部分的開源項目,即本文要介紹的casbin項目。

casbin支援很多的程式設計語言, 本文選擇golang作為使用語言。

認證還是授權?

在大型項目中認證(Authentication)和授權(Authorization)一般是分開的,前者用于甄别使用者是誰, 而後者用于判斷使用者有什麼權限,授權不管認證, 認證也不管授權, 這點很重要, 但是很多時候為了簡單會将兩者放在一起,比如僅是判斷使用者是否認證,認證了就可以通路該資源,而本文主要讨論授權的問題, 是以不會關心認證的問題, 即沒有驗證使用者名密碼是否認證通過的業務邏輯。

準入

權限最開始總是準入,即隻有兩個選擇,允許或者拒絕。

比如這樣的場景,存在四個使用者,zhangsan, lisi, wangwu, zhaoliu, 我們允許前三人可以通路我們的網站。

是以我們将政策描述如下.

zhangsan
lisi
wangwu           

在這個清單中的使用者表示允許,反之不允許。

資源控制

随着項目的增長我們自然發現使用者可以操作的項目多起來了, 是以需要在之前的政策裡加上使用者對應的資源。

比如這樣的場景, 還是之前那四個使用者,但是我們的網站變成了2個, 我們允許張三通路兩者,後三人隻能通路第二個網站。

是以我們将政策描述如下.

zhangsan, web1
zhangsan, web2
lisi, web2
wangwu, web2
zhaoliu, web2           

還是一樣的邏輯,隻是多了一個字段

我們可以将第一列稱為user,第二列稱為website。

與此同時,我們還需要對使用者有更精細的控制,比如張三可以讀寫所有網站,但是其他三個人隻讀第二個網站,是以政策可以描述如下。

zhangsan, web1, read
zhangsan, web1, write
zhangsan, web2, read
zhangsan, web2, write
lisi, web2, read
wangwu, web2, read
zhaoliu, web2, read           
我們将第三列稱為action

至此我們基本上完成了權限控制,但是稍稍有點不完美, 這裡的不完美是政策檔案跟比對模型太耦合, 比如我們還是上面的政策檔案,但是網站變成了10個,并且我們希望隻要在清單中的使用者就能通路所有網站,又或者不管第二個字段,隻根據第三個字段來判斷使用者的可讀可寫權限,最簡單直接的辦法自然是直接重寫一遍政策檔案并相應的修改代碼,但是太無聊太枯燥了,是以我們需要将其模型提煉出來。

比如我們可以定義一個這樣的模型

# 政策定義的意思
[policy_definition]
p = user, website, action

# 比對邏輯
[matchers]
m = user == p.user && website == website && action == action           

這樣我們就可以解決之前的問題了

比如隻比對第一個字段, 那我們可以定義如下

# 政策定義的意思
[policy_definition]
p = user, website, action

# 比對邏輯
[matchers]
m = user == p.user           

又比如隻比對第一個和第三個字段

# 政策定義的意思
[policy_definition]
p = user, website, action

# 比對邏輯
[matchers]
m = user == p.user && action == p.action           

至此我們可以在不改動政策檔案的情況下僅僅改變比較小的内容就可以很快的完成比對模型的轉換,這樣就會靈活很多,但是現在的模型還有些不太嚴謹, 我們通過p = user, website, action定義了政策檔案的各個字段,卻沒有定義使用者請求的各個字段, 比如要求使用者請求應該填上哪些字段,是以我們需要再次改一下我們的比對模型,修改如下:

# 請求定義的意思
[request_definition]
r = user, website, action

# 政策定義的意思
[policy_definition]
p = user, website, action

# 比對邏輯
[matchers]
m = r.user == p.user && r.action == p.action           

這樣子我們的比對模型看起來要嚴謹許多了,但是模型中請求定義(request_definition), 政策定義(policy_definition)在toml中的文法其實都是清單, 即我們可以定義多個政策和請求定義,比如:

# 請求定義的意思
[request_definition]
r = user, website, action
r2 = user, action

# 政策定義的意思
[policy_definition]
p = user, website, action
p2 = user, action

# 比對邏輯
[matchers]
m = r.user == p.user && r.website == p.website && r.action == p.action
m2 = r2.user == p2.user && r2.action == p2.action
m3 = r.user == p.user && r.action == p.action           

這樣我們可以在一套模型中定義多個不同的組合,比如(r,p,m), (r2,p2,m2), (r,p,m3), 總的來說我們的比對模型靈活性大大提高,但是我們的政策模型可能出現了不嚴謹的地方,即政策檔案中的每一行是政策p, 還是政策p2? 我們無法判斷,是以為了解決這個問題,我們需要在政策檔案中多加一個字段.

政策檔案定義如下:

p, zhangsan, web1, read
p, zhangsan, web1, write
p, zhangsan, web2, read
p, zhangsan, web2, write
p, lisi, web2, read
p, wangwu, web2, read
p, zhaoliu, web2, read
p2, sunqi, read           

可以看到,我們增加了一個使用者sunqi, 他不需要定義website,因為政策p2不需要website這個字段。

現在我們稍稍将名稱再提煉一下,假設我們多了程式接口,也就是說使用者不是使用者而是終端,我們可以稱其為使用者,但是稍稍有些别扭,我們可以将其統稱為主體(subject),我們的項目也不可能總是網站,是以我們可以稱其為對象(object), 而操作權限我們可以歸納為動作(action).

是以僅僅是為了讓我們的模型的語言看起來更加的泛化,是以我們将其改成如下

# 請求定義的意思
[request_definition]
r = subject, object, action
r2 = subject, action

# 政策定義的意思
[policy_definition]
p = subject, object, action
p2 = subject, action

# 比對邏輯
[matchers]
m = r.subject == p.subject && r.object == p.object && r.action == p.action
m2 = r2.subject == p2.subject && r2.action == p2.action
m3 = r.subject == p.subject && r.action == p.action
m3 = r.subject == p.subject && r.action == p.action           

這個時候又來了新的需求,即再多加一個使用者(zhouba),隻需要這個使用者不能通路web10(假設已經有10個網站。)即可,一種做法是為這個使用者添加18條記錄,即web1,web2,...,we9, 分别對應read和write, 作為一個程式員自然是讨厭這些枯燥無聊的工作的。

是以我們可以繼續改進我們的比對模型, 我們發現我們的比對模型對于結果的判斷過于單一,即隻能允許,我們無法在已有的架構下擴充,這其實是因為我們沒有處理比對的結果,我們應該對比對的結果進一步做處理,我們可以将比對的結果稱之為result, 而result有允許和拒絕(deny)兩種, 在這個結果下,我們可以定義這樣的文法,比對到任意一個允許(allow)就放行,又或者沒有任何一個拒絕(deny)就放行,而後者就是我們想要的解決方案,這樣我們隻要寫一條拒絕通路web10的規則就可以達到目的。

是以模型定義如下:

# 請求定義的意思
[request_definition]
r = subject, object, action
r2 = subject, action

# 政策定義的意思
[policy_definition]
p = subject, object, action
p2 = subject, action
p3 = subject, object, result

# 政策結果的意思
[policy_result]
e = some(where (p.result == allow))
e2 = !some(where (p.result == deny))


# 比對邏輯
[matchers]
m = r.subject == p.subject && r.object == p.object && r.action == p.action
m2 = r2.subject == p2.subject && r2.action == p2.action
m3 = r.subject == p.subject && r.action == p.action
m3 = r.subject == p.subject && r.action == p.action
m4 = r.subject == p.subject && r.object == p3.object           

這裡我們多定義了一個段落policy_result, 這個段落有兩個執行邏輯,前者代表隻要有一行的政策比對結果是allow就放行,後者是沒有一行的政策比對結果是deny就放行。

為啥文法要定義成這樣? 因為這是casbin的文法, 我隻是拙劣的模仿,并且按照自己的了解來一步步推到casbin模型...文法隻是一套要記住的規則而已,如果我們不需要自己解析的話,死記硬背即可,當然了,它的這個文法也不是難以了解的那種。

而新的政策規則如下:

p, zhangsan, web1, read
p, zhangsan, web1, write
p, zhangsan, web2, read
p, zhangsan, web2, write
p, lisi, web2, read
p, wangwu, web2, read
p, zhaoliu, web2, read
p2, sunqi, read
p3, sunqi, web10, deny           

至此整個模型基本完成了,可以适配大多數的通路控制(ACL)情況了,但是對于RBAC還是有些問題,但是這裡就不繼續演進了。後面通過代碼來看看ACL, RBAC的是用。

當然了,你可能覺得模型的演進還是有很多問題,比如casbin使用的是簡寫sub,而這裡使用的是subject全稱,這裡政策效果寫的段落名是policy_result,而casbin寫的是policy_effect, 不過這些不同之處在我看來隻是小問題,隻需替換即可。

代碼示例

這一節直接使用golang來示範。

ACL

假設場景: 存在網站web1,web2, 張三可讀寫兩者,李四隻讀web1。

模型定義如下:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act           

政策定義如下:

p, zhangsan, web1, read
p, zhangsan, web1, read
p, zhangsan, web2, read
p, zhangsan, web2, write
p, lisi, web1, read           

代碼如下:

package acl1

import (
	"log"
	"testing"

	"github.com/stretchr/testify/assert"

	"github.com/casbin/casbin/v2"
)

func TestACL1(t *testing.T) {
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatal("建立政策引擎失敗: ", err)
	}
	tests := [][]interface{}{
		{"zhangsan", "web1", "read"},
		{"zhangsan", "web1", "write"},
		{"zhangsan", "web2", "read"},
		{"zhangsan", "web2", "write"},
		{"zhangsan", "webx", "write"},
		{"lisi", "web1", "read"},
		{"lisi", "web2", "read"},
		{"lisi", "webx", "read"},
	}
	expected := []bool{true, true, true, true, false, true, false, false}

	for i := 0; i < len(tests); i++ {
		ok, err := e.Enforce(tests[i]...)
		if err != nil {
			t.Fatalf("請求: %v 對應的期待是是: %t, 發生錯誤: %s", tests[i], expected[i], err)
		}
		assert.Equal(t, expected[i], ok)
	}
}
           

在此基礎上我們需要一個超級管理者,就稱其為root

是以模型如下:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
# 唯一的不同是是加了|| p.sub == "root", 隻要使用者名是root就允許
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act || p.sub == "root"           

政策檔案不變。

測試代碼如下

package acl1

import (
	"fmt"
	"log"
	"testing"

	"github.com/stretchr/testify/assert"

	"github.com/casbin/casbin/v2"
)

func TestACL2(t *testing.T) {
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatal("建立政策引擎失敗: ", err)
	}
	tests := [][]interface{}{
		{"zhangsan", "web1", "read"},
		{"zhangsan", "web1", "write"},
		{"zhangsan", "web2", "read"},
		{"zhangsan", "web2", "write"},
		{"zhangsan", "webx", "write"},
		{"lisi", "web1", "read"},
		{"lisi", "web2", "read"},
		{"lisi", "webx", "read"},
		{"root", "web1", "read"},
		{"root", "webx", "update"},
	}
	expected := []bool{true, true, true, true, false, true, false, false, true, true}

	for i := 0; i < len(tests); i++ {
		ok, err := e.Enforce(tests[i]...)
		fmt.Println(tests[i], expected[i])
		if err != nil {
			t.Fatalf("請求: %v 對應的期待是是: %t, 發生錯誤: %s", tests[i], expected[i], err)
		}
		assert.Equal(t, expected[i], ok)
	}
}           

測試結果也是通過的,可以看到root的測試用例中即使請求不存在的資源或者不存在的操作也是true, 因為模型中隻判斷使用者是否為root。

RBAC

假設場景: 存在網站web1,web2, 可讀角色(reader)可讀寫兩個web,可讀角色(writer)可寫兩個web,管理者角色(admin)可讀寫兩者, 張三屬于可讀角色,李四屬于可寫角色,王五屬于admin角色,趙六既屬于可讀角色也屬于可寫角色。

模型描述如下:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act           

RBAC與ACL的不同之處在于多了一個role_definition, 多了一層抽象自然需要一個新的定義,這沒什麼奇怪的,就像request_definition, policy_definition. 不過它的文法又稍稍不同,首先它不用sub, obj之類的對象名稱,僅用"_"作為所需參數的占位符, 兩個下劃線說明需要兩個參數。

比較難以了解的是g(r.sub, p.sub), g是role_definition定義的一個角色操作符, 但是這個需要對照政策檔案檢視。

政策檔案如下:

# 政策定義
p, reader, web1, read
p, reader, web2, read
p, writer, web1, write
p, writer, web2, write
p, admin, web1, read
p, admin, web1, write
p, admin, web2, read
p, admin, web2, write
# 定義使用者屬于哪些角色
g, zhangsan, reader
g, lisi, writer
g, wangwu, admin
g, zhaoliu, reader
g, zhaoliu, writer           

政策檔案中分為兩個部分,第一部分屬于常見政策定義,不過這裡定義的主體(sub)是後面定義的角色,即角色綁定到了具體的對象及操作,而g定義了使用者屬于哪些角色,比如g, zhangsan, reader代表zhangsan屬于可讀角色(reader),而可讀角色(reader)可以讀寫web1, web2, 從來可以推導出zhangsan可讀web1,web2。

在回過頭看g(r.sub, p.sub)我們可以了解為g操作符将r.sub映射成了對應的角色,再将其角色與p.sub比較。因為請求中沒有角色的資料,是以必然需要一個映射函數将其轉換成對應的角色,casbin使用的角色定義的g。

測試代碼如下:

package acl1

import (
	"fmt"
	"log"
	"testing"

	"github.com/stretchr/testify/assert"

	"github.com/casbin/casbin/v2"
)

func TestRBAC(t *testing.T) {
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatal("建立政策引擎失敗: ", err)
	}
	tests := [][]interface{}{
		{"zhangsan", "web1", "read", true},
		{"zhangsan", "web2", "read", true},
		{"zhangsan", "web1", "write", false},
		{"lisi", "web1", "write", true},
		{"lisi", "web2", "write", true},
		{"lisi", "web1", "read", false},
		{"wangwu", "web1", "read", true},
		{"wangwu", "web1", "read", true},
		{"zhaoliu", "web1", "read", true},
		{"zhaoliu", "web2", "write", true},
	}

	for i := 0; i < len(tests); i++ {
		ok, err := e.Enforce(tests[i][:3]...)
		fmt.Println(tests[i], tests[i][3])
		if err != nil {
			t.Fatalf("請求: %v 對應的期待是是: %t, 發生錯誤: %s", tests[i], tests[i][3], err)
		}
		assert.Equal(t, tests[i][3], ok)
	}
}           

測試自然是成功的。

值得注意的是: 雖然政策裡面定義了角色的權限,但是也可以定義使用者的權限,比如加一行p, zhangsan, web1, write, 可也是可以的,但是初學起來覺得奇怪。熟悉之後可以任意的測試群組合。

上下文切換

在之前的模型推導過程中,模型總是定義了不止一個政策,不止一個比對器,那麼怎麼在代碼中展現呢?

模型定義如下:

[request_definition]
r = sub, obj, act
r2 = sub, obj

[policy_definition]
p = sub, obj, act
p2 = sub, obj

[policy_effect]
e = some(where (p.eft == allow))
e2 = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
m2 = r2.sub == p2.sub && r2.obj == p2.obj           

會發現每個對象都多了一份, 比如r2的定義說明隻需要兩個參數,而r需要三個參數,其他意思差不多。

政策定義如下:

# 政策定義
p, zhangsan, web1, read
p, zhangsan, web2, read
p2, wangwu, web1
p2, wangwu, web2           

分别為政策p, p2定義不同的政策,需要的參數不同

測試代碼如下:

package acl1

import (
	"log"
	"testing"

	"github.com/stretchr/testify/assert"

	"github.com/casbin/casbin/v2"
)

func TestRBAC(t *testing.T) {
	e, err := casbin.NewEnforcer("model.conf", "policy.csv")
	if err != nil {
		log.Fatal("建立政策引擎失敗: ", err)
	}
	tests1 := [][]interface{}{
		{"zhangsan", "web1", "read", true},
		{"zhangsan", "web2", "read", true},
		{"zhangsan", "web1", "write", false},
		{"wangwu", "web1", "write", false},
		{"wangwu", "web2", "write", false},
	}

	ctx2 := casbin.NewEnforceContext("2")

	tests2 := [][]interface{}{
		{ctx2, "wangwu", "web1", true},
		{ctx2, "wangwu", "web2", true},
		{ctx2, "wangwu", "web3", false},
	}

	for i := 0; i < len(tests1); i++ {
		ok, err := e.Enforce(tests1[i][:3]...)
		// t.Log(tests1[i], tests1[i][3])
		if err != nil {
			t.Fatalf("請求: %v 對應的期待是是: %t, 發生錯誤: %s", tests1[i], tests1[i][3], err)
		}
		assert.Equal(t, tests1[i][3], ok)
	}

	for i := 0; i < len(tests2); i++ {
		ok, err := e.Enforce(tests2[i][:3]...)
		// t.Log(tests2[i], tests2[i][3])
		if err != nil {
			t.Fatalf("請求: %v 對應的期待是是: %t, 發生錯誤: %s", tests2[i], tests2[i][3], err)
		}
		assert.Equal(t, tests2[i][3], ok)
	}
}
           

這與之前的不同在于第一個參數是context,這裡為了簡單沒有單獨的設定各個部分的值,比如這裡的casbin.NewEnforceContext("2")說明使用(r2,p2,e2,m2), 但是e2跟e分明是一樣的,是以可以單獨設定context的EType為“e”, 這裡就不展開了。。。

一些額外的技巧

一些常使用的技巧

黑名單政策

本文全篇都是白名單政策,即允許才放行,但是有時候很名單更有效,比如網站的反爬政策,大多數連結都是允許的,隻有一部分是不允許的, 是以用白名單去放行所有資源顯然有點不現實及不高效,是以我們可以将政策結果進行如下設定

[policy_effect]
e = !some(where (p.eft == deny))           

該文法聲明的是,不存在任何決策結果為deny的比對規則,則最終決策結果為allow

但是什麼時候p.eft == deny呢? 其實政策定義中可配置eft這個屬性,定義如下

[policy_definition]
p = sub, obj, act, eft           

然後對應的政策定義如下:

p, zhangsan, web1, read, allow
p, zhangsan, web2, read, deny           

資料源适配

一般來說模型檔案是放在本地,并且很少更改,而政策檔案可以放在很多地方,比如資料庫,代碼如下。

import (
    "log"

    "github.com/casbin/casbin/v2"
    "github.com/casbin/casbin/v2/model"
    xormadapter "github.com/casbin/xorm-adapter/v2"
    _ "github.com/go-sql-driver/mysql"
)

// 使用MySQL資料庫初始化一個Xorm擴充卡
a, err := xormadapter.NewAdapter("mysql", "mysql_username:mysql_password@tcp(127.0.0.1:3306)/casbin")
if err != nil {
    log.Fatalf("error: adapter: %s", err)
}

m, err := model.NewModelFromString(`
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
`)
if err != nil {
    log.Fatalf("error: model: %s", err)
}

e, err := casbin.NewEnforcer(m, a)
if err != nil {
    log.Fatalf("error: enforcer: %s", err)
}           
代碼摘自: https://casbin.org/zh/docs/get-started

casbin編輯器

線上位址: https://casbin.org/zh/editor

線上編輯器雖然可以很友善的驗證想法,但是使用稍稍有些限制,隻不過對于大多數人不是問題,因為不需要上下文切換。

總結

自己寫一個ACL或者RBAC倒是不太複雜,但是枯燥無味就像寫CRUD一樣并且不夠靈活,而Casbin是比較強大的,它支援超級多的模型,ACL, RBAC, ABAC等多種政策模型。

繼續閱讀