Docs Vault

在企业应用中,保障服务安全的另一个重要手段是给服务添加授权功能。


本节课将详细介绍如何实现服务的授权功能。


如何实现服务授权


要实现服务授权,首先需要根据业务需求选择合适的授权模式。不同的权限模型具有各自的特点,可以满足不同场景的需求。常见的权限模型包括以下五种:

  1. 权限控制列表(ACL,Access Control List);
  2. 自主访问控制(DAC,Discretionary Access Control);
  3. 强制访问控制(MAC,Mandatory Access Control);
  4. 基于角色的访问控制(RBAC,Role-Based Access Control);
  5. 基于属性的权限验证(ABAC,Attribute-Based Access Control)。


目前使用较多的是 RBAC 模式。关于 RBAC 模式的介绍,网上已有大量相关文章,这里不再赘述。RBAC 能够满足绝大多数企业应用的授权场景,例如 Kubernetes 采用了 RBAC 授权模式,miniblog 也同样使用了 RBAC 授权模式。


要为 miniblog 添加 RBAC 授权功能,首先需要在 GitHub 上搜索相关的成熟包、框架或项目以供参考。通过搜索发现,casbin 拥有 18.2k 的 Star,这一量级充分说明了 casbin 可能是一个非常成熟且优秀的 RBAC 授权框架。在进一步搜索类似项目后,发现 casbin 是最适合的选择。因此,接下来需要认真阅读 casbin 的官方文档。通过阅读文档,可以获取以下关键信息:

  1. casbin 提供一个在线编辑器,用于权限验证;
  2. casbin 的授权依赖于 Model 文件描述授权模式,依赖 Policy 文件指定权限。官网提供了不同授权模式的 Model 文件和 Policy 文件示例;
  3. casbin 的 Model 文件和 Policy 文件可以从不同位置加载;
  4. 针对不同 Web 框架,casbin 已实现相应的中间件。


以上内容是通过阅读官方文档整理出的可能对实现授权功能有用的信息,你也可以根据自己的理解整理出不同的文档内容,并最终梳理出可能的实现方法。


提示:
Casbin 有个官方的 casbin-server 项目,用来实现 Casbin as a Service (CaaS)。有个 Casdoor 门户网站用于模型管理和策略管理。


尽管阅读了大量文档,对 casbin 已有一定理解,但如果没有实际编码实践,可能仍然无法完全掌握其使用方法。因此,接下来你需要从官方文档或 GitHub 上寻找一些短小精炼的 RBAC 授权示例,以快速上手。在充分学习了 casbin 及其使用方法后,便可以着手实现 miniblog 的授权功能。


miniblog 授权功能设计


miniblog 选择了 RBAC 模型,因为 RBAC 模型目前使用最为广泛,适配的场景也非常丰富,能够满足大多数企业应用的要求。


此外,对于企业级应用,权限策略通常需要保存在持久化存储中。根据之前的学习,我们了解到 casbin 可以通过各种适配器将权限策略存储在 MariaDB、PostgreSQL、SQLite3 等后端数据库中。因此,在这里选择可读性和维护性较好的 MariaDB 数据库进行存储。由于项目中使用了 GORM 作为 ORM 框架,可以直接使用官方提供的 gorm-adapter 适配器。casbin/gorm-adapter 包中提供了代码示例可供开发者进行学习和测试。


在 miniblog 项目中,为了演示如何开发授权功能,并避免引入过于复杂的授权场景,miniblog 选择对 API 接口的操作进行授权:只有具有管理员角色的用户才能访问 DeleteUser 和 ListUser 接口。


由于需要对每一个 gRPC/HTTP 请求进行授权,因此授权功能非常适合通过 Web 中间件来实现。


miniblog 授权功能开发


本节来详细介绍下 miniblog 具体如何实现授权功能。


miniblog 授权模型


miniblog 期望的授权模型是:通过授权策略来指定允许或禁止指定角色访问指定的 API 接口,默认可以访问所有 API 接口。


Casbin 项目提供了多种访问控制模型,详见 Casbin 官方文档。能够满足 miniblog 授权场景的授权模式是拒绝覆盖模式,其模型配置如下:

[request_definition]
r = sub, obj, act

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

[role_definition]
g = _, _

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

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


上述 Casbin 授权模型基于 RBAC 授权机制,描述了权限验证的逻辑。模型定义了请求由主体(sub)、资源(obj)和操作(act)组成,通过策略规则(p)确定主体对资源和操作的权限,支持角色继承关系(g)以简化权限管理。在权限决策时,如果没有匹配的 deny 策略规则,最终的效果是 allow(也称为 deny-override)。匹配器(m)通过用户-角色映射、资源匹配和操作一致性来验证请求与权限规则是否对应。


提示:
miniblog 项目为了区分角色主体和非角色主体,统一将角色主体命名为 role::<角色名> 格式。

Casbin 模型结构解读


Casbin 模型配置至少有四个部分:[request_definition],[policy_definition],[policy_effect] 和 [matchers]。如果模型使用基于角色的访问控制,还会包括 [role_definition] 部分。模型配置可以包含注释,注释以#符号开始,# 符号后的所有内容都将被注释掉。


(1)请求定义


[request_definition] 部分定义了 e.Enforce(...) 函数中的参数,例如:

[request_definition]
r = sub, obj, act


sub,obj 和 act 代表了经典的访问三元组:主体(访问实体),对象(被访问资源)和动作(访问方法)。Casbin 支持自定义请求格式。例如,如果不需要指定特定的资源,可以使用 sub, act,或者如果有两个访问实体,你可以使用 sub, sub2, obj, act。


(2)策略定义


[policy_definition] 是策略的定义。它定义了策略的含义。例如:

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

假设,有以下策略配置:

p,role::user,/v1.MiniBlog/DeleteUser,CALL,deny

策略中的每一行都被称为策略规则。每个策略规则都以策略类型开始,例如 p。上述策略显示了以下绑定,绑定可以在匹配器中使用:

(role::user,/v1.MiniBlog/DeleteUser,CALL,deny) -> (p.sub,p.obj,p.act,p.eft)


(3)策略效果


[policy_effect] 是策略效果的定义。如果多个策略规则匹配请求,它决定是否应批准访问请求。例如,一条规则允许,另一条规则拒绝。以下是一个策略效果配置:

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

上述策略效果配置,意味着如果没有匹配的 deny 策略规则,最终的效果是 allow。`some` 意味着存在一个匹配的策略规则。


(4)匹配器


[matchers] 是策略匹配器的定义。匹配器是定义如何根据请求评估策略规则的表达式。以下是一个策略匹配器配置:

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

g(r.sub, p.sub) 用来判断 r.sub 是否与 p.sub 匹配,通过角色继承关系进行验证。g 是角色关系函数,用于检查主体(r.sub)是否关联到特定的角色(p.sub)。通过角色关系,用户可以继承角色的权限,角色还可以继承其他角色的权限。


keyMatch(r.obj, p.obj) 用来判断请求中的资源路径(r.obj)是否与策略中定义的资源(p.obj)匹配,支持模糊匹配(基于路径的资源匹配)。keyMatch 是 Casbin 提供的内置函数,用来检查两个字符串之间是否匹配,支持带通配符的规则。


r.act == p.act 用来严格匹配请求的操作(r.act)与策略中定义的操作(p.act)。r.act 是请求的具体操作(如 CALL、GET)。p.act 是策略中定义允许或拒绝的操作。


匹配器中可以使用算术运算符如+,-,*,/和逻辑运算符如&&,||,!。


开发授权包


miniblog 授权代码实现位于 pkg/auth/authz.go 文件中,代码如代码清单 11-5 所示。

代码清单 11-5 授权实现

const (
    // aclModel 定义了 casbin 访问控制模型.
    aclModel = `[request_definition]
r = sub, obj, act

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

[role_definition]
g = _, _

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

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

// Authz 定义了一个授权器,提供授权功能.
type Authz struct {
    *casbin.SyncedEnforcer // 使用 Casbin 的同步授权器
}

// NewAuthz 创建一个使用 Casbin 完成授权的授权器.
func NewAuthz(db *gorm.DB) (*Authz, error) {
    // 初始化 Gorm 适配器并用于 Casbin 授权器
    adapter, err := adapter.NewAdapterByDB(db)
    if err != nil {
        return nil, err // 返回错误
    }

    // 从字符串中创建 Casbin 模型
    m, _ := model.NewModelFromString(aclModel)

    // 初始化授权器
    enforcer, err := casbin.NewSyncedEnforcer(m, adapter)
    if err != nil {
        return nil, err // 返回错误
    }

    // 从数据库加载策略
    if err := enforcer.LoadPolicy(); err != nil {
        return nil, err // 返回错误
    }

    // 启动自动加载策略,间隔为 5 秒
    enforcer.StartAutoLoadPolicy(5 * time.Second)

    // 返回新的授权器实例
    return &Authz{enforcer}, nil
}

// Authorize 用于进行授权.
func (a *Authz) Authorize(sub, obj, act string) (bool, error) {
    // 调用 Enforce 方法进行授权检查
    return a.Enforce(sub, obj, act)
}

代码清单 11-5 定义了一个授权器 Authz,通过嵌入 Casbin 的同步授权器(SyncedEnforcer)来提供授权功能。aclModel 描述了 Casbin 的访问控制模型。NewAuthz 函数使用 GORM 数据库适配器初始化 Casbin 授权器,并从数据库加载权限策略,同时启用每 5 秒自动加载策略更新的功能。


授权逻辑由 Authorize 方法实现,它通过调用 Enforce 方法检查某个主体(sub)是否可以对某个资源(obj)执行指定操作(act)。如果匹配模型规则和策略,返回 true,否则返回 false。


实现授权中间件


gRPC 授权拦截器和 Gin 中间件实现原理相同,本节仅解析 gRPC 授权拦截器的实现。gRPC 授权拦截器的实现位于 internal/pkg/middleware/grpc/authz.go 文件中,代码如代码清单 11-6 所示。

代码清单 11-6 gRPC 授权拦截器实现

// Authorizer 用于定义授权接口的实现.
type Authorizer interface {
    Authorize(subject, object, action string) (bool, error)
}

// AuthzInterceptor 是一个 gRPC 拦截器,用于进行请求授权.
func AuthzInterceptor(authorizer Authorizer) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        subject := contextx.UserID(ctx) // 获取用户ID
        object := info.FullMethod       // 获取请求资源
        action := "CALL"                // 默认操作

        // 记录授权上下文信息
        log.Debugw("Build authorize context", "subject", subject, "object", object, "action", action)

        // 调用授权接口进行验证
        if allowed, err := authorizer.Authorize(subject, object, action); err != nil || !allowed {
            return nil, errno.ErrPermissionDenied.WithMessage(
                "access denied: subject=%s, object=%s, action=%s, reason=%v",
                subject,
                object,
                action,
                err,
            )
        }

        // 继续处理请求
        return handler(ctx, req)
    }
}

代码清单 11-6 实现了一个 gRPC 授权拦截器 AuthzInterceptor,用于在处理 gRPC 请求之前对用户权限进行验证。它通过从请求上下文中提取用户 ID(subject)和请求目标(object,即 gRPC 方法的全路径),以及默认的操作类型(action,设为 CALL),构建权限验证信息,并调用传入的 Authorizer 接口进行权限检查。如果用户未被授权或者发生错误,拦截器返回一个权限拒绝错误并终止请求;否则,授权通过后调用实际的业务处理逻辑。


加载授权中间件


修改文件 internal/apiserver/grpcserver.go,添加以下代码:

...
func (c *ServerConfig) NewGRPCServerOr() (server.Server, error) {
    // 配置 gRPC 服务器选项,包括拦截器链
    serverOptions := []grpc.ServerOption{
        // 注意拦截器顺序!
        grpc.ChainUnaryInterceptor(
            ...
            // 授权拦截器
            selector.UnaryServerInterceptor(mw.AuthzInterceptor(c.authz), NewAuthzWhiteListMatcher()),
        ),
    }
    ...
}

// NewAuthzWhiteListMatcher 创建授权白名单匹配器.
func NewAuthzWhiteListMatcher() selector.Matcher {
    whitelist := map[string]struct{}{
        apiv1.MiniBlog_Healthz_FullMethodName:    {},
        apiv1.MiniBlog_CreateUser_FullMethodName: {},
        apiv1.MiniBlog_Login_FullMethodName:      {},
    }
    return selector.MatchFunc(func(ctx context.Context, call interceptors.CallMeta) bool {
        _, ok := whitelist[call.FullMethod()]
        return !ok
    })
}

上述代码在 gRPC 拦截器链中加入了授权拦截器,并通过白名单匹配器定义了无需授权验证的 gRPC 方法。健康检查接口、创建用户接口及用户登录接口,均不需要对接口进行授权。


同样的方法,修改 internal/apiserver/httpserver.go 文件,在 authMiddlewares 中间件数组中,添加授权中间件 mw.AuthzMiddleware(c.authz),其中 c.authz 在 internal/apiserver/server.go 文件中,通过以下方式创建:

authz, err := auth.NewAuthz(store.DB(context.TODO()))


为用户添加/删除普通用户角色


在添加了授权功能之后,还需要在创建用户时,给用户添加普通用户 role::user 角色,在删除用户时,删除其对应的 role::user 角色。在 gRPC 服务器或者 HTTP 服务 Biz 层 Create 方法和 Delete 方法中,添加以下代码:

...
// Create 实现 UserBiz 接口中的 Create 方法.
func (b *userBiz) Create(ctx context.Context, rq *apiv1.CreateUserRequest) (*apiv1.CreateUserResponse, error) {
    ...
    if _, err := b.authz.AddGroupingPolicy(userM.UserID, known.RoleUser); err != nil {
        log.W(ctx).Errorw("Failed to add grouping policy for user", "user", userM.UserID, "role", known.RoleUser)
        return nil, errno.ErrAddRole.WithMessage(err.Error())
    }
    ...
}
...
func (b *userBiz) Delete(ctx context.Context, rq *apiv1.DeleteUserRequest) (*apiv1.DeleteUserResponse, error) {
    ...
    if _, err := b.authz.RemoveGroupingPolicy(rq.GetUserID(), known.RoleUser); err != nil {
        log.W(ctx).Errorw("Failed to remove grouping policy for user", "user", rq.GetUserID(), "role", known.RoleUser)
        return nil, errno.ErrRemoveRole.WithMessage(err.Error())
    }
    ...
}

上述代码通过在用户创建时添加角色绑定(AddGroupingPolicy)和在用户删除时移除角色绑定(RemoveGroupingPolicy),动态地将用户与角色(如 RoleUser)关联或解除,以实现基于角色的权限管理。


至此,已成功为 miniblog 添加接口授权功能,完整代码参见分支:feature/s25


授权功能测试


执行以下命令编译并启动 mb-apiserver:

$ git checkout feature/s25 # 切换到对应的分支,或者master分支
$ make build # 编译源码
$ _output/platforms/linux/amd64/mb-apiserver # 启动服务

打开一个新的 Linux 终端,分别执行以下命令进行测试。


(1)创建新的用户


创建用户命令如下:

$ curl -XPOST -H"Content-Type: application/json" http://127.0.0.1:5555/v1/users  -d'{"username":"authztest","password":"miniblog1234","nickname":"colin404","email":"[email protected]","phone":"18130000000"}'
{"userID":"user-die7iy"}

上述命令会新建一个名为 authztest 的测试用户,接口返回用户的 ID:user-die7iy。


(2)使用普通用户访问 GET /v1/users 接口


使用普通用户登录,并访问登录 GET /v1/users 接口,命令如下:

$ user_token=`curl -s -XPOST -H"Content-Type: application/json" http://127.0.0.1:5555/login -d'{"username":"authztest","password":"miniblog1234"}'|jq -r .token`
$ curl -H"Authorization: Bearer ${user_token}" http://127.0.0.1:5555/v1/users?limit=1
{"code":7,"message":"access denied: subject=user-die7iy, object=/v1.MiniBlog/ListUser, action=CALL, reason=<nil>","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"PermissionDenied","metadata":{"X-Request-ID":"bcfd9076-549f-4139-8f8f-710218521190"}}]}

上述命令尝试以普通用户的身份访问 GET /v1/users 接口,因为权限不足,导致报:PermissionDenied 错误。


(3)使用管理员用户访问 GET /v1/users 接口


尝试以管理员用户登录,并访问 GET /v1/users 接口,命令如下:

$ admin_token=`curl -s -XPOST -H"Content-Type: application/json" http://127.0.0.1:5555/login -d'{"username":"root","password":"miniblog1234"}'|jq -r .token`
$ curl -H"Authorization: Bearer ${admin_token}" http://127.0.0.1:5555/v1/users?limit=1
{"totalCount":"2","users":[{"userID":"user-die7iy","username":"authntest","nickname":"colin404","email":"[email protected]","phone":"18130000000","createdAt":"2025-02-01T13:45:09Z","updatedAt":"2025-02-01T13:45:09.328211797Z"}]}

使用管理员用户可以成功访问 GET /v1/users 接口。


小结(AI 自动生成并人工审核)


本文详细介绍了在企业应用中如何通过 RBAC(基于角色的访问控制)模型实现服务的授权功能,并以 miniblog 项目为例进行实践。


文章首先概述了常见的权限模型,并重点选择了 RBAC 模式作为授权基础,同时引入了成熟的开源框架 Casbin 来实现权限管理。通过 Casbin 的模型配置与策略定义,结合 GORM 数据库适配器,miniblog 项目设计了一套基于拒绝覆盖模式的权限验证逻辑,并通过中间件对 gRPC 和 HTTP 请求进行统一的权限校验。


开发过程中,miniblog 为用户动态绑定角色权限,实现了对用户创建和删除时的角色管理。最后,通过实际测试验证了授权功能的正确性,确保不同角色用户对接口的访问权限得到有效控制。通过这一实践,展现了如何在企业级应用中高效、安全地实现服务的授权机制。