RBAC 概念簡介

在我們探討如何利用 Open Policy Agent (以下簡稱 OPA) 和 Golang 建立一個彈性的 RBAC 模組之前,先讓我們來了解一下 RBAC的基本概念。

RBAC(Role-Based Access Control,基於角色的訪問控制)是一種廣泛應用的訪問控制策略,在軟體安全性領域尤為重要。其核心思想是將系統訪問權限與用戶的角色(職位、責任或職務)關聯起來,而不是直接與個別用戶關聯。這意味著訪問權限被捆綁到角色上,然後將用戶分配給這些角色。舉個例子,一個「管理員」角色可能有權訪問系統的所有資源,而「員工」角色則只能訪問特定部分的資源。

RBAC 的主要優勢在於其靈活性和簡化的權限管理。當需要變更權限時,只需修改角色的訪問權限,而不需要為每個用戶單獨設定。這不僅使權限管理更為高效,也減少了錯誤配置的可能性,提高了整體的系統安全性。

在實踐中,RBAC 允許創建精細且靈活的策略,以滿足複雜的商業和安全需求。無論是大型企業還是小型團隊,RBAC 都提供了一個可靠的框架,來確保正確的用戶擁有適當的訪問權限,從而保護關鍵資源免受未授權訪問。

可以參考 Cloudflare 的文章,簡單來說就是什麼「角色」能夠對什麼「資源」做什麼「操作」

Open Policy Agent (OPA) 介紹

OPA 是一個「Strategy as Code」的開源專案,專門設計用於統一地管理和執行跨不同系統的策略。它不僅提供了一個高級的策略語言——Rego,還支援將策略作為代碼與應用程式的其他部分一同存儲、版本控制和部署。OPA 的這種設計使其能夠輕鬆集成到微服務、Kubernetes、CI/CD 管道、API 網關等多種環境中。顯著特點是其策略的編寫方式。Rego 是一種專門為策略和規則定制的查詢語言,它使開發者能夠以聲明式方式描述策略和規則,從而確保這些策略既容易理解又易於維護。這對於建立複雜的 RBAC 系統尤為重要,因為它允許策略的靈活性和可擴展性,同時又保持了清晰和易於審查的結構。

我們可以利用 OPA 提供的 API 來評估和執行這些策略。這意味著開發者可以在 Golang 程式碼中直接嵌入策略判斷的邏輯,從而實現動態、細粒度的訪問控制。這種方法的一個優點是,它支援在 runtime 動態更新策略,或是編譯進 binary,從而提供更大的靈活性和即時性。

整合 OPA 與 Golang

透過官方案例來了解如何使用

參考了 OPA 官方的 rbac 章節,並加以修改。使用最簡單的例子:

admin can read user
bob is admin
-------------------
bob can read user

轉化成 RBAC 模型即為:

  • role: admin
  • resource: user
  • action: read

於是我們使用 rego 來撰寫出這個模型,並綁定 bob 到 admin 這個 role 上

# user-role assignments
user_roles := {
    "bob": ["admin"]
}

# role-permissions assignments
role_permissions := {
    "admin": [{"action": "read",  "resource": "user"}],
}

並完成 allow 的判斷「策略」:

# logic that implements RBAC.
default allow := false
allow if {
    # lookup the list of roles for the user
    roles := user_roles[input.user]
    # for each role in that list
    r := roles[_]
    # lookup the permissions list for role r
    permissions := role_permissions[r]
    # for each permission
    p := permissions[_]
    # check if the permission granted to r matches the user's request
    p == {"action": input.action, "resource": input.resource}
}

我們可以在 playground 上查看結果

當我們 input 是

{
  "action": "read",
  "resource": "user",
  "user": "bob"
}

輸出即為

{
  "allow": true,
  "role_permissions": {
    "admin": [
      {
        "action": "read",
        "resource": "user"
      }
    ]
  },
  "user_roles": {
    "bob": ["admin"]
  }
}

最終我們能在 output 中的 allow 得到目標結果。

如何彈性的輸入角色

user_roles 的部份抽成外部輸入,並稍微進行一些程式碼最佳化

# rbac.rego
package rbac

import future.keywords.contains
import future.keywords.if
import future.keywords.in

# role-permissions assignments
role_permissions := {
  "admin": [
    {"resource": "user", "action": "edit"},
    {"resource": "user", "action": "read"}
  ]
}

default allow := false

allow if {
  some grant in grants

  input.action == grant.action
  input.resource == grant.resource
}

grants contains grant if {
  some role in input.role
  some grant in role_permissions[role]
}

接著我們可以對這個策略寫一些「測試」

# rbac_test.rego
package rbac_test

import data.rbac.allow
import data.rbac.grants

import future.keywords.in

test_admin_with_incomplete_param {
  not allow with input as {"role": ["admin"]}
}

test_admin {
  not {"action": "A", "resource": "B"} in grants with input as {"role": ["admin"]}
  {"action": "read", "resource": "user"} in grants with input as {"role": ["admin"]}
}

再來我們就能透過 opa 的 cli 來跑測試 opa test -v ./*.rego

❯ opa test -v ./*.rego
./rbac_test.rego:
data.rbac_test.test_admin_with_incomplete_param: PASS (230.288µs)
data.rbac_test.test_admin: PASS (143.406µs)
--------------------------------------------------------------------------------
PASS: 2/2

更詳細的可以參考 Policy Testing 章節,這篇文章著重在整合 Golang,詳細 opa 語法就不贅述。

透過 Golang 輸入 Role

我們可以在程式端來輸入 role 來查詢 grants,也可以輸入 role, resource, action 來查詢 allow,會用到 github.com/open-policy-agent/opa/rego 來實現。

定義一個 rbacService,並完成初始化,使用到 embed 來將 rbac 的檔案給嵌入到查詢裡,若要做成動態的策略,則可以由外部注入。

package rbac

import (
    "context"
    _ "embed"
    "sync"

    "github.com/open-policy-agent/opa/rego"
    "github.com/pkg/errors"
)


//go:embed rbac.rego
var policy []byte

type rbacService struct {
    once        sync.Once
    allowQuery  rego.PreparedEvalQuery
    grantsQuery rego.PreparedEvalQuery
}

// RBACService defines the rbac service interface.
var RBACService rbacService

// Init initializes the rbac service.
func Init(ctx context.Context) error {
    module := rego.Module("policy", string(policy))

    var err1, err2 error
    RBACService.once.Do(func() {
        RBACService.allowQuery, err1 = rego.New(
            rego.Query("data.rbac.allow"),
            module,
        ).PrepareForEval(ctx)

        RBACService.grantsQuery, err2 = rego.New(
            rego.Query("grants = data.rbac.grants"),
            module,
        ).PrepareForEval(ctx)
    })

    if err1 != nil || err2 != nil {
        err := errors.New("failed to prepare rbac policy")
        if err1 != nil {
            err = errors.Wrap(err, err1.Error())
        }
        if err2 != nil {
            err = errors.Wrap(err, err2.Error())
        }
        return err
    }

    return nil
}

接著完成查詢 allowgrants 的兩個 function

// IsGrantRequest defines the request for IsGrant.
type IsGrantRequest struct {
    Roles    []Role
    Action   Action
    Resource Resource
}

// IsGranted checks if the request is granted by the rbac policy.
func (s *rbacService) IsGranted(ctx context.Context, req IsGrantRequest) bool {
    results, err := s.allowQuery.Eval(ctx, rego.EvalInput(map[string]any{
        "role":     req.Roles,
        "action":   req.Action,
        "resource": req.Resource,
    }))
    if err != nil {
        log.Println(errors.Wrap(err, "failed to evaluate rbac policy"))
        return false
    } else if len(results) == 0 {
        log.Println("empty rbac policy result, we have wrong query string or policy")
    }

    return results.Allowed()
}

// Grant defines the grant of a role.
type Grant struct {
    Resource
    Action
}

// GetGrants returns the grants of the roles.
func (s *rbacService) GetGrants(ctx context.Context, roles ...Role) ([]Grant, error) {
    results, err := s.grantsQuery.Eval(ctx, rego.EvalInput(map[string]any{
        "role": roles,
    }))
    if err != nil {
        return nil, errors.Wrap(err, "failed to evaluate rbac policy")
    } else if len(results) == 0 {
        return nil, errors.New("empty rbac policy result, we have wrong query string or policy")
    }

    var grants []Grant
    for _, grantI := range results[0].Bindings["grants"].([]any) {
        grant := grantI.(map[string]any)
        grants = append(grants, Grant{
            Resource: Resource(grant["resource"].(string)),
            Action:   Action(grant["action"].(string)),
        })
    }

    return grants, nil
}

如此一來我們就完成了獨立於其他服務的 RBAC 模組,僅須輸入某個特定物件(可能是 user,也可能是某個 service)所擁有的 roles,就能查詢他是否擁有操作該資源的權限。

還可以更好

未來轉換到其他程式語言,仍可以沿用這套策略。若是將 embed 的部份給解耦,有額外的 role,僅須更改 policy 的 rego,並不需要重新編譯整個 binary。

透過導入 OPA,我們也能學習更現代的策略管理,不僅僅能運用在 RBAC,更可以用在一些 container service account 的權限控管。