Oso(西班牙文的「熊」)是跨語言、跨框架的存取控制服務,它也有開源套件,本文主要介紹它的開源套件。
在進入 Oso 前,先簡單認識一下存取控制。
存取控制
「存取控制」英文叫 access control,市場上有許多不同的存取控制模型,他們的縮寫大多是 xAC,是 xxx access control 的意思。
存取控制是多人系統必備的要素之一,例如常見的 WordPress,它內建了幾個預先定義好的角色:
如圖所見,每個角色的能執行的權限不同,而每個帳號必定要賦予一個角色,形成「帳號—角色」這樣的關係,每個角色能執行的項目不同,例如編輯、管理員才能刪除文章,但除了這種系統面的權限外,每篇文章也有自身的權限,例如A作者不能改B作者的文章。
像 WordPress 這樣,以角色為基礎的存取控制模型,我們稱為「RBAC(role-based access control)」。
RBAC 的概念是以角色為中心的,也就是批假單的權限是綁在店長這個角色身上,而不是綁在王小臻身上,王小臻之所以能批假單,是因為她被賦予了店長的角色。
除了 RBAC 外,視系統的應用場景和複雜度不同還有其他的 xAC,未來有遇到再說,畫面先交還給棚內的 Oso。
Oso 簡介
一般要做一個 RBAC 機制,有可能自幹,但秉持著不要重新發明輪子的原則,還是交給專業的來吧!
快速帶過 Oso 的特點:
- 跨平台,Python、Rust、Node.js、Ruby、Go、Java,這算跨很大了吧。
- Oso 開發了一種專門用於制定安全政策的語言 Polar,加上 Oso 本身又是跨平台的,因此 Oso 這層存取控制可以與系統語言、框架本身作一定程度的分離,避免炒出義大利麵。
- 角色與權限應用的對象可以細至「資源」,可以做到像 WP 那樣A不能改B的文章。
- 背後有商業化服務,不用擔心哪天作者跑路或被腰斬。(請不要舉冨樫當反例謝謝)
Oso 與 Flask
Oso 好不好強不強不知道,先用一波感受一下。
下面是一個極簡的 Flask + Oso 專案:
這個專案與 Oso 文件中的示範專案大致相同,但略作修改:
- 用 Poetry 取代 requirements.txt
- 相容於 Python 3.7 以上版本
進入 Poetry 虛擬環境後把這 Flask 跑起來:
FLASK_APP=app.server python -m flask run
就會看到下面的訊息:
* Serving Flask app 'app.server' (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
瀏覽器打開 http://localhost:5000/repo/gmail 會看到正確的內容:
而打開 http://localhost:5000/repo/react 則會看到錯誤的內容:
為什麼會這樣呢?從 routing 開始找原因,打開 app/server.py 可以看到負責處理上面路由的函式 repo_show()
:
@app.route("/repo/<name>")
def repo_show(name):
repo = Repository.get_by_name(name)
try:
oso.authorize(
actor=User.get_current_user(),
action="read",
resource=repo
)
return f"<h1>A Repo</h1><p>Welcome to repo {repo.name}</p>", 200
except NotFoundError:
return f"<h1>Whoops!</h1><p>Repo named {name} was not found</p>", 404
其中的 try
區塊負責做簡單的判斷,而其中呼叫的 oso.authorize()
又是主要關鍵,它的參數相當直白,它為我們回答「actor 能否對 resource 作 action?」,在這裡就是「user 能否對 repo 作 read?」,如果通過,該函式回傳 None
,程式繼續往下走,如果不通過,它會發起一個例外,由 except
區塊接手處理。
根據現況,action 和 resource 都是已知的,那 User.get_current_user()
又是誰呢?根據 app/server.py 的匯入模組來找,跑去翻 app/models.py 就會發現,目前的 user 是 Larry 大大:
@dataclass
class Repository:
name: str
is_public: bool = False
@staticmethod
def get_by_name(name):
return repos_db.get(name)
@dataclass
class Role:
name: str
repository: Repository
@dataclass
class User:
roles: List[Role]
@staticmethod
def get_current_user():
return users_db["larry"]
repos_db = {
"gmail": Repository("gmail"),
"react": Repository("react", is_public=True),
"oso": Repository("oso"),
}
users_db = {
"larry": User([Role(name="admin", repository=repos_db["gmail"])]),
"anne": User([Role(name="maintainer", repository=repos_db["react"])]),
"graham": User([Role(name="contributor", repository=repos_db["oso"])]),
}
此外我們還看到了 Larry 大大所有的 repo 只有 gmail repo,而那 react repo 屬於另外一位 Anne 大大所有。
另外還可以注意到,那 react repo 有一個 is_public=True
屬性,這晚點會再提到。
截至目前為止,我們看的都是 Flask 或 Python 原生的邏輯,剩下的環節就是那 oso.authorize()
到底是依據什麼規則判斷權限的,react repo 不給看的邏輯在哪裡?我知道 Larry 沒有 react repo,但沒有歸沒有,中間具體判斷的邏輯在哪裡?
鏡頭轉向 Polar。
Oso 的 Polar 規則定義文件
在 Oso 的世界,安全政策或安全規則是在 Polar 文件制定,app/server.py 中有這幾行:
# Initialize the Oso object. This object is usually used globally throughout
# an application.
oso = Oso()
# Tell Oso about the data you will authorize. These types can be referenced
# in the policy.
oso.register_class(User)
oso.register_class(Repository)
# Load your policy files.
oso.load_files(["app/main.polar"])
這幾行分別負責:
- Oso 初始化
- 把兩個類
User
和Repository
註冊給 Oso 待制定規則之用 - 從 app/main.polar 載入安全規則
把那 app/main.polar 開來看看:
actor User {}
resource Repository {
permissions = ["read", "push", "delete"];
roles = ["contributor", "maintainer", "admin"];
# A user has the "read" permission if they have the
# "contributor" role.
"read" if "contributor";
# A user has the "push" permission if they have the
# "maintainer" role.
"push" if "maintainer";
# A user has the "delete" permission if they have the
# "admin" role.
"delete" if "admin";
# A user has the "maintainer" role if they have
# the "admin" role.
"maintainer" if "admin";
# A user has the "contributor" role if they have
# the "maintainer" role.
"contributor" if "maintainer";
}
# This rule tells Oso how to fetch roles for a repository
has_role(actor: User, role_name: String, repository: Repository) if
role in actor.roles and
role_name = role.name and
repository = role.repository;
allow(actor, action, resource) if
has_permission(actor, action, resource);
這個 Polar 規則可以解構為幾個部份:
- 前面註冊的
User
類在此以actor User {}
被聲明為 Polar 的actor
,在此應該將其理解為行為的發動者,不要和下面的roles
搞混。 - 而
Repository
類則被聲明為 Polar 的resource
,並且被賦予了一些屬性和規則,分別有三種permissions
和三種roles
,兩者混搭出一些存取規則。 - 一個
has_role
區塊。 - 一個
allow
區塊。
在 Oso 外面,我們是這樣呼叫它的:
oso.authorize(
actor=User.get_current_user(),
action="read",
resource=repo,
)
在 Oso 裡面,它的呼叫鏈是這樣的:
allow
呼叫 has_permisssion
呼叫 has_role
。
實際上他們不是函式,所以用「呼叫」不太正確,但懂意思就好。
一個個來翻弄吧。
allow
下面這塊:
allow(actor, action, resource) if
has_permission(actor, action, resource);
意思顯而易見的,只要 has_permission
為真,則 allow
也為真,意即該 Actor 對該 Resource 的該 Action 通過。
此處的Actor、Action、Resource 如果是 Larry 大大請求 gmail repo 的話,則分別為:
- Actor:就是 Larry 大大,他是 gmail repo 的 admin。
- Action:就是讀取,
read
。 - Resource:就是 gmail repo。
把這三個值再代入 has_permission
,它才能決定 allow
的是與非。
has_permissioin
上面的 Polar 文件中沒有看到 has_permission
,不過實際上是有的,只不過被簡化了。
資源內的每條規則都是簡化的形式,例如:
"read" if "contributor";
展開成完整的形式會是:
has_permission(actor: Actor, "read", resource: Repository) if
has_role(actor, "contributor", resource);
那些簡化的規則那麼多,表示 has_permisssion
也就有那麼多,那上一層 allow
會傳給哪個?答案是根據敘述的順序分別代入求真值,每個 has_permisssion
都有各自的真與假,如果一輪跑完無一為真,那 allow
也不會為真,但只要中間有一個 has_permission
為真,那 allow
即為真。
但同樣的,has_permission
的真值又要 has_role
來決定。
has_role
回來看 app/main.polar 裡面還有個區塊:
# This rule tells Oso how to fetch roles for a repository
has_role(actor: User, role_name: String, repository: Repository) if
role in actor.roles and
role_name = role.name and
repository = role.repository;
這個區塊定義了 has_role
背後的邏輯,「如果 if
後面的敘述為真,則 has_role
也會返回真」,如果 has_role
為真,則上層的 has_permission
、allow
也將為真,於是這整條邏輯鏈丟回給 Python 的 oso.authorize()
也將認可為通過。
此處的 if
後的敘述稍微陌生一些,在此我們把上面 user_db
部份的內容拿下來比較好對照:
users_db = {
"larry": User([Role(name="admin", repository=repos_db["gmail"])]),
"anne": User([Role(name="maintainer", repository=repos_db["react"])]),
"graham": User([Role(name="contributor", repository=repos_db["oso"])]),
}
對照上面 Larry 大的屬性和下面的邏輯。
role in actor.roles
:
actor.roles
是 Python 的List[Role]
型態的物件,也就是陣列,目前該陣列內只有一個Role
物件。- 而 Polar 的
in
運算子是拿來跑迭代的,不是拿來判斷A有沒有在B裡面的。 - 因為 Larry 大大體內的那個
roles
陣列內的Role
物件只有一個,所以這in
敘述就只會跑一輪。
role_name = role.name
:
- 這句是用來比較等號左右兩邊的值,相同即返回真。
role.name
已知為admin
。role_name
則為contributor
這是從上層has_permission
傳來的。
repository = role.repository
:
- 也是是用來比較等號左右兩邊的值,相同即返回真,此例兩者皆為
gmail
,故為真。
上面這區塊很顯然的不可能為真,因為 role.name
值是 admin
,而 role_name
值則是 contributor
,故必然不為真。
但若倒帶回去看前面的規則敘述,會注意到上面的規則有兩種:
- 第一種是
xxxPermission if xxxRole
,即前面講過的"read" if "contributor"
,如果是誰誰誰就能如此這般。 - 第二種是
RoleA if RoleB
,即如果是某某A,那他也有如某某B的權限。
這兩種都是簡寫的規則,其中的第一種前面已經展開過,而第二種也可以展開成另一種形式,例如:
"maintainer" if "admin";
"contributor" if "maintainer";
兩者分別展開就是:
has_role(actor: Actor, "maintainer", resource: Repository) if
has_role(actor, "admin", resource);
has_role(actor: Actor, "contributor", resource: Repository) if
has_role(actor, "maintainer", resource);
其中的 has_role(actor, "admin", resource)
代入上面的 has_role if
區塊推導一下,結果為真,因此 "maintainer" if "admin"
也為真,以此類推,"contributor" if "maintainer"
也為真,於是於是這整條邏輯鏈丟回給 Python 的 oso.authorize()
,也將認可為通過。
反之,網址 http://localhost:5000/repo/react 之所以會看到錯誤的內容:
因為 Larry 大大身上沒有 react repo 的角色,所以在 has_role
區塊的 repository = role.repository
必然不為真,所以 oso.authorize()
也就吐 False
囉。
開放任何人讀公開的 Repo
先前在 app/models.py 有看到 react repo 有一個 is_public=True
屬性,在此為它設計一條新規則,讓任何人皆可讀取公開的 repo。
可以在 app/main.polar 加入以下敘述:
has_permission(_actor: User, "read", repository: Repository) if
repository.is_public;
如此當 Larry 嘗試開 react repo 時,這條新的規則就能給他一個「真」,而 oso.authorize()
通過囉!
最後完整的規則如下:
actor User {}
resource Repository {
permissions = ["read", "push", "delete"];
roles = ["contributor", "maintainer", "admin"];
# A user has the "read" permission if they have the
# "contributor" role.
"read" if "contributor";
# A user has the "push" permission if they have the
# "maintainer" role.
"push" if "maintainer";
# A user has the "delete" permission if they have the
# "admin" role.
"delete" if "admin";
# A user has the "maintainer" role if they have
# the "admin" role.
"maintainer" if "admin";
# A user has the "contributor" role if they have
# the "maintainer" role.
"contributor" if "maintainer";
}
# This rule tells Oso how to fetch roles for a repository
has_role(actor: User, role_name: String, repository: Repository) if
role in actor.roles and
role_name = role.name and
repository = role.repository;
has_permission(_actor: User, "read", repository: Repository) if
repository.is_public;
allow(actor, action, resource) if
has_permission(actor, action, resource);
結語
本篇介紹 Oso,它是跨平台的存取控制系統,可以與各語言、框架整合,讓專案具有 RBAC 機制,它的存取規則以自有的 Polar 格式撰寫,Polar 語法可以賦予規則一些邏輯性,而非單純的 role - action 這樣的靜態規則,可以讓存取控制的粒度細到單一資源,然而這當然是用相對複雜的語法換來的。
本文僅對 Oso 和 Polar 做基本介紹,細心的朋友可能會注意到,光一個 Repository
就這麼多角色、權限了,那資源一多豈不寫到手軟,實際上 Polar 還可以定義根據資源的階層賦予權限,如此即不用在每個子資源寫一堆重複的角色,但這部份目前沒有接觸,或許之後用到會再補充。