Oso(西班牙文的「熊」)是跨語言、跨框架的存取控制服務,它也有開源套件,本文主要介紹它的開源套件。

在進入 Oso 前,先簡單認識一下存取控制。

存取控制

「存取控制」英文叫 access control,市場上有許多不同的存取控制模型,他們的縮寫大多是 xAC,是 xxx access control 的意思。

存取控制是多人系統必備的要素之一,例如常見的 WordPress,它內建了幾個預先定義好的角色:

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 專案

這個專案與 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 會看到正確的內容:

Gmail repo

而打開 http://localhost:5000/repo/react 則會看到錯誤的內容:

React repo

為什麼會這樣呢?從 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 初始化
  • 把兩個類 UserRepository 註冊給 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 通過。

此處的ActorActionResource 如果是 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_permissionallow 也將為真,於是這整條邏輯鏈丟回給 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 之所以會看到錯誤的內容:

React repo

因為 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 還可以定義根據資源的階層賦予權限,如此即不用在每個子資源寫一堆重複的角色,但這部份目前沒有接觸,或許之後用到會再補充。

參考資料