Casbin: Công cụ phân quyền linh hoạt và mạnh mẽ

Hỗ trợ nhiều loại mô hình phân quyền thông qua tập tin cấu hình, đa dạng adapter cho việc tích hợp, hỗ trợ watcher đảm bảo tính thống nhất… Casbin là một công cụ phân quyền rất mạnh mẽ và hiệu quả mà bạn nên thử nghiệm.

Upload image

Trong mỗi hệ thống, phân quyền (authorization) luôn là một yếu tố quan trọng và cũng là bài toán luôn có nhiều yêu cầu khắt khe. Vậy bạn thường chọn thư viện gì để thực hiện phân quyền?

Theo ngôn ngữ yêu thích: Golang, Java, C/C++, Node.js, Javascript, PHP, Python, .NET (C#)…?

Theo mô hình được yêu cầu: ACL (Access Control List), RBAC (Role-Based Access Control), ABAC (Attribute-based access control)…?

Sử dụng cơ sở dữ liệu “sở trường”: MySQL, PostgreSQL, CSV file…?

Tưởng như yêu cầu nhiều quá thì không có thư viện nào đáp ứng được, nhưng câu trả lời là có! Bài viết này sẽ giới thiệu đến bạn một công cụ vừa mạnh mẽ, vừa linh hoạt, dễ sử dụng để cài đặt hệ thống phân quyền mang tên Casbin.

Khái niệm và cách thức hoạt động

Trước khi chúng ta bắt tay vào cài đặt và sử dụng Casbin, hãy cùng tìm hiểu một số khái niệm và cách Casbin hoạt động. Trong Casbin, mô hình kiểm tra quyền được cấu hình bằng một tập tin có dạng PERM (Policy, Effect, Request, Matchers).

Dưới đây là cấu hình minh họa cho mô hình ACL (Access Control List):

Upload image

Request: Là phần định nghĩa các thông số được gửi đến trong request yêu cầu phân quyền. Như hình minh hoạ, ta có thể thấy phần request có 3 thông số là sub (subject – đối tượng truy cập), obj (object – tài nguyên truy cập), act (action – hoạt động truy cập).

Policy: Định nghĩa các thông số có trong policy - các “dữ liệu đầu vào”, mô tả các người dùng, vai trò, quyền hạn… trong Casbin. Ví dụ một policy như “p, admin, data2, read” có nghĩa là: admin (sub) có quyền read (act) trên tài nguyên data2 (obj). Tập hợp tất cả policy sẽ giúp Casbin hiểu đối tượng nào được truy cập tài nguyên nào với hoạt động gì. Các dữ liệu này có thể được lưu trữ dưới dạng tập tin CSV, PostgresDB, SQL Server, hay MySQL database.

Một policy thường có 4 phần: sub, obj, act, eft (effect – kết quả khi khớp policy, thường là allow hay deny). Trong trường hợp không có eft thì mặc định là allow.

Matcher: Định nghĩa “quy luật khớp” (matching rule) giữa request và các policy. Như hình minh hoạ, ta có thể thấy “khớp” được định nghĩa là các thông số sub, obj và act trong request và policy đều giống nhau. Trong thực tế, ta có thể định nghĩa các quy luật phức tạp hơn bằng việc sử dụng Function trong Casbin như keyMatch cho URL, regexMatch, ipMatch cho dãy IP… Xem thêm tại đây.

Khi một policy được khớp, eft tương ứng sẽ trả về và xử lý tiếp với policy effect để đưa ra kết quả cuối cùng.

Policy effect: Định nghĩa quy luật để đưa ra kết quả cuối cùng sau khi tổng hợp tất cả eft của các policy khớp. Như hình minh họa, some(where(p.eft == allow)) nghĩa là chỉ cần một policy được match thì kết quả trả về sẽ là true. Danh sách các policy effect có thể xem thêm tại đây. Hiện có 5 policy effect được hỗ trợ.

Khi Casbin khởi tạo, danh sách các policy sẽ được nạp vào bộ nhớ. Khi nhận được request phân quyền, Casbin sẽ dựa vào tập tin cấu hình để kiểm tra policy theo matcher và policy effect để đưa ra kết quả cuối cùng. Hiện tại Casbin có sẵn rất nhiều tập tin cấu hình theo các mô hình phân quyền thông dụng mà bạn có thể tìm thấy tại đây.

Demo

Để hiểu hơn về Casbin, chúng ta sẽ dựng một API Server đơn giản sử dụng Casbin để thực hiện kiểm tra phân quyền. Trong demo này, API server được dựng sẽ sử dụng ngôn ngữ Golang cùng giao thức gRPC, và dùng PostgreSQL làm database cho Casbin policy (bạn có thể tìm thấy danh sách adapter mà Casbin hỗ trợ tại đây). Vì mục đích demo đơn giản, các đoạn code sẽ bỏ qua các thao tác kiểm tra lỗi và ghi log.

Đầu tiên, ta sẽ tạo tập tin proto. Trong đó, ta tạo CheckPermission là API nhận input gồm sub, obj và act (để đơn giản hóa cho demo, ta define các input này tương ứng với các thành phần trong Casbin). API này sẽ kiểm tra phân quyền theo các policy có trong database và trả lại kết quả true (allow) hoặc false (deny).

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.proto";
option java_outer_classname = "CasbinProto";
option go_package = "./;proto";
option csharp_namespace = "CasbinOrg.Grpc";

package proto;

// The Casbin service definition.
service Casbin {
    rpc CheckPermission (AuthorizeRequest) returns (BoolReply) {}
}

message AuthorizeRequest {
  string sub = 1;
  string obj = 2;
  string act = 3;
}

message EmptyRequest {
  int32 handler = 1;
}

message BoolReply {
  bool res = 1;
}

message EmptyReply {
}

Sau đó, ta tạo các file go tương ứng từ file proto bằng câu lệnh:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative casbin.proto

Tiếp theo, ta tạo file api.go và hiện thực hóa API tương ứng với file proto. Trong đó:

  • Hàm NewServer khởi tạo Casbin Enforcer từ model phân quyền và adapter của cơ sở dữ liệu chứa các policy. Thao tác kiểm tra phân quyền sẽ được thực hiện bằng hàm Enforce() của Enforcer này.
import (
    "context"
    pb "demo-casbin/proto"
    "log"

    "github.com/casbin/casbin/v2"

    gormadapter "github.com/casbin/gorm-adapter/v3"
)

type Server struct {
    pb.UnimplementedCasbinServer

    enforcer *casbin.Enforcer
}

// Init Casbin server
func NewServer(dbDriverName string, dbConnectString string, modelPath string) *Server {
    s := Server{}

    // Init adapter to database
    a, _ := gormadapter.NewAdapter(dbDriverName, dbConnectString)
    // Init enforcer using model and adapter (to database)
    e, _ := casbin.NewEnforcer(modelPath, a)

    s.enforcer = e

    return &s
}
  • Hàm CheckPermission (hiện thực hóa interface CheckPermission đã define trong tập tin proto) thực hiện việc kiểm tra phân quyền bằng cách gọi hàm Enforce() của Casbin Enforcer và trả lại kết quả tương ứng.
// Implement CheckPermission API
func (s *Server) CheckPermission(c context.Context, in *pb.AuthorizeRequest) (*pb.BoolReply, error) {
    log.Println(in)

    // Call Enforce() method to check permission
    r, err := s.enforcer.Enforce(in.Sub, in.Obj, in.Act)
    if err != nil {
        return nil, err
    } else {
        if r {
            return &pb.BoolReply{Res: true}, nil
        } else {
            return &pb.BoolReply{Res: false}, nil
        }
    }
}

Tiếp theo, ta tạo tập tin model. Trong demo này ta sẽ sử dụng RBAC model.

[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

Cuối cùng, ta tạo main.go và khởi tạo grpc server.

func main() {
    // Update configure with environment variable
    utils.ReadConfig(&conf)

    // Check port
    log.Println("Listening on", conf.GRPCPort)
    lis, err := net.Listen("tcp", fmt.Sprintf(":%s", conf.GRPCPort))
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    // Create new server
    s := grpc.NewServer()
    pb.RegisterCasbinServer(s, server.NewServer(conf.DbDriverName, conf.DbConnectString, conf.ModelPath))
    reflection.Register(s)
    log.Println("Listening on", conf.GRPCPort)
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

Kết quả khi run:

Upload image

Khi kết nối vào postgreSQL database, ta có thể thấy một bảng casbin_rule được tạo ra trong database mà ta đã cấu hình cho Casbin server. Ta tạo thêm các policy theo định dạng trong tập tin model.

Upload image

Với dữ liệu như trong hình, ta sẽ test thử API CheckPermission bằng công cụ BloomGrpc. (Nếu các bạn chỉnh lại data trực tiếp trong database thì hãy restart lại server nhé).

Upload image

Upload image

Vậy là chúng ta đã xem qua demo Casbin thông qua API sever đơn giản. Trong thực tế, API server thường được thiết lập với nhiều hơn một instance để đảm bảo tính High Availability. Khi host nhiều hơn một Casbin instance, cần đảm bảo nhất quán dữ liệu giữa các instance với nhau. Để thực hiện điều này, Casbin đưa ra Watcher – các thư viện sử dụng Key-Value store/Messaging system để giúp các instance trao đổi thông tin mỗi khi có cập nhật.

Tiếp theo, chúng ta sẽ cập nhật demo: thêm một API AddPolicy và thiếp lập watcher, sau đó ta sẽ run 2 instance Casbin và xem cách các instance này “nói chuyện” với nhau khi policy được cập nhật.

Đầu tiên, ta thêm AddPolicy API vào file proto sẵn có, và chạy command để tạo lại các file protobuf mới.

// The Casbin service definition.
service Casbin {
    rpc CheckPermission (AuthorizeRequest) returns (BoolReply) {}
    rpc AddPolicy (AddPolicyRequest) returns (EmptyReply) {}
}

Tiếp theo, ta hiện thực hóa API AddPolicy. Trong API này, ta sử dụng hàm AddPolicy() của Casbin Enforcer để thêm policy vào danh sách policy hiện có của instance, và sử dụng hàm SavePolicy() để lưu tất cả policy của instance xuống cơ sở dữ liệu.

func (s *Server) AddPolicy(c context.Context, in *pb.AddPolicyRequest) (*pb.EmptyReply, error) {
    s.enforcer.AddPolicy(in.Sub, in.Obj, in.Act)
    s.enforcer.SavePolicy()
    return &pb.EmptyReply{}, nil
}

Sau đó, ta cập nhật thêm phần thiếp lập Watcher trong hàm NewServer của tập tin api.go. Demo này sẽ dùng Redis Cache làm Watcher cho Casbin. Trong đoạn cập nhật này, ta sẽ thiết lập một hàm làm callback cho Watcher với hàm SetUpdateCallBack(), hàm này sẽ được gọi mỗi khi một instance cập nhật policy. Mặc định, Watcher sẽ gọi hàm LoadPolicy của Enforcer khi nhận được message để cập nhật lại danh sách policy trong database. Trong demo này, ta thêm một dòng log message trước khi gọi LoadPolicy của Enforcer.

import (
    …
    rediswatcher "github.com/casbin/redis-watcher/v2"
)

type Server struct {// Add Watcher
    watcher persist.Watcher
}

func NewServer(dbDriverName string, dbConnectString string, modelPath string, cacheConnectString string, cachePassword string, cacheChannel string) *Server {
    s := Server{}

    a, _ := gormadapter.NewAdapter(dbDriverName, dbConnectString)
    e, _ := casbin.NewEnforcer(modelPath, a)

    // Setup watcher
    w, _ := rediswatcher.NewWatcher(cacheConnectString, rediswatcher.WatcherOptions{
        Options: redis.Options{
            Network:  "tcp",
            Password: cachePassword,
        },
        Channel: cacheChannel,
        // Only exists in test, generally be true
        IgnoreSelf: false,
    })
    _ = e.SetWatcher(w)
    _ = w.SetUpdateCallback(func(msg string) {
        log.Println(msg)
        e.LoadPolicy()
    })

    s.enforcer = e
    s.watcher = w

    return &s
}

Cuối cùng, ta cập nhật tập tin main.go để truyền các tham số cần thiết cho hàm NewServer.

// Create new server
    s := grpc.NewServer()
    pb.RegisterCasbinServer(s, server.NewServer(conf.DbDriverName, conf.DbConnectString, conf.ModelPath, conf.CacheConnectString, conf.CachePassword, conf.CacheChannel))
    reflection.Register(s)

Sau khi đã cập nhật, ta sẽ chạy 2 Casbin instance: instance 1 với port 50051 và instance 2 với port 50052. Ở instance 1, ta gọi API AddPolicy.

Upload image

Xem console log của instance 2, ta có thể thấy 2 dòng log của Watcher: một khi Enforcer ở instance 1 gọi hàm AddPolicy(), và một khi gọi hàm SavePolicy().

Upload image

Ở instance 2, ta gọi API CheckPermission của policy vừa add trong instance 1.

Upload image

Ta thấy instance 2 có thể check quyền bằng policy vừa add mà không cần phải restart server.

Tuy nhiên, ta thấy rằng với hàm SavePolicy và AddPolicy của Enforcer, callback của Watcher đều nhận được cùng message với nội dung như nhau. Điều này sẽ gây khó khăn trong việc phân biệt các thao tác cập nhật khác nhau để tối ưu hóa xử lý cho từng thao tác. Để giải quyết điều này, Casbin đưa ra interface mới WatcherEx cung cấp khả năng gửi các dạng message khác nhau cho từng thao tác cập nhật.

Tại thời điểm viết bài này, Casbin chưa có thư viện nào cài đặt WatcherEx sẵn dùng. Nhưng để demo, ta có thể sử dụng Open-source của Redis Watcher và tùy chỉnh lại source code một chút để hiện thực hóa WatcherEx.

Trong tập tin watcher.go của source code trên, ta thay kiểu trả về của hàm NewWatcher từ persist.Watcher thành persist.WatcherEx.

//      Example:
//              w, err := rediswatcher.NewWatcher("127.0.0.1:6379",WatcherOptions{}, nil)
//
func NewWatcher(addr string, option WatcherOptions) (persist.WatcherEx, error) {
    option.Addr = addr
    initConfig(&option)

Tiếp theo, ta thêm implementation tạm cho 2 hàm UpdateForAddPolicies() và UpdateForRemovePolicies() như bên dưới để đảm bảo đầy đủ các hàm mà WatcherEx yêu cầu.

// Dump method
func (w *Watcher) UpdateForAddPolicies(sec, ptype string, params ...[]string) error {
    return nil
}

// Dump method
func (w *Watcher) UpdateForRemovePolicies(sec, ptype string, params ...[]string) error {
    return nil
}

Trong tập tin api.go, ta thay thư viện đang dùng từ redis-watcher sang thư viện watcher trong source code.

// rediswatcher "github.com/casbin/redis-watcher/v2"
    rediswatcher "demo-casbin/watcher"

Cuối cùng, ta thay kiểu persist.Watcher sang persist.WatcherEx. Khi các hàm cập nhật policy của thư viện Casbin thực thi, nếu Watcher ta cài đặt cho Enforcer hiện thực hóa interface WatcherEx thì các hàm cập nhật sẽ gửi message callback với kiểu message tương ứng với thao tác cập nhật được thực thi.

type Server struct {
    …
    watcher  persist.WatcherEx
}

Ta thiết lập 2 instance để demo như đã làm. Ta có thể thấy lúc này khi thực hiện thao tác AddPolicy(), các message callback mà instance 2 nhận được sẽ có nhiều chi tiết hơn.

Upload image

Upload image

Điều này giúp ta có thể tối ưu hóa thao tác khi nhận được callback.

Chúng ta đã tìm hiểu về các khái niệm trong Casbin, thử cách sử dụng Casbin qua demo về grpc server và thiết lập Watcher. Các thông tin về Casbin đều có trên tài liệu chính thức của công cụ này. Bên dưới là trích dẫn nhanh một số thông tin hữu ích cho các bạn bắt đầu dùng Casbin:

Mang trong mình tiềm năng ứng dụng to lớn, Casbin là một công cụ phân quyền mới mà bạn nên thử nghiệm và sử dụng.

Atekco - Home for Authentic Technical Consultants