实现简单的、针对咖啡店增删改查的 RBAC 权限管理功能


随着应用的扩张,用户越来越多,数据越来越多,用来管理这些数据的后台管理系统日渐被提上日程,此外我们还要为用户进行分级,为不同角色用户设置不同的操作权限,比如商家可以编辑或删除自家的咖啡店,以及在自家公司下新增咖啡店,普通用户只有浏览权限,新增和更新操作需要审核通过才能执行,管理员才能登录到管理后台对大盘数据进行管理,以及为不具备权限的咖啡店更新和新增操作进行审核和处理。所以在开发管理后台功能之前,我们有必要先对用户进行角色和权限设置,并对之前的咖啡店增删改查添加相应的权限判断。

第一步:设置用户角色类型

在 Roast 应用中,我们通过为 users 表新增 permission 字段用来设置对应用户角色:

php artisan make:migration alter_users_add_permission --table=users

在新生成的迁移类的 up() 方法中编写代码如下:

Schema::table('users', function (Blueprint $table) {
    $table->tinyInteger('permission')->after('id')->default(0);
});

我们将会为应用定义四种角色类型,它们的 permission 字段值及对应描述信息如下:

  • 3 – 超级管理员,具备所有权限
  • 2 – 管理员,具备后台管理权限和咖啡店增删改权限
  • 1 – 商家,具备对自有咖啡店和对应公司的更新权限
  • 0 – 普通用户,具备更新个人信息、喜欢及咖啡店浏览权限

所以默认用户角色是普通用户,为了简化应用流程,permission 既承担了用户角色功能,又承担了对应的权限功能,我们将基于这个字段实现简单的 RBAC 权限管理功能。

运行数据库迁移命令 php artisan migrate 应用上述数据库更新。

第二步:在模型类中定义常量属性

User 模型类中定义如下常量属性以便在其他地方引用:

const ROLE_GENERAL_USER = 0;  // 普通用户
const ROLE_SHOP_OWNER = 1;    // 商家用户
const ROLE_ADMIN = 2;         // 管理员
const ROLE_SUPER_ADMIN = 3;   // 超级管理员  

同理,在 Action 模型类中定义如下常量属性:

const STATUS_PENDING = 0;   // 待审核
const STATUS_APPROVED = 1;  // 已通过
const STATUS_DENIED = 2;    // 已拒绝

这样做的好处是一旦后续属性值有修改,只需要维护这一个地方就好了。

第三步:实现咖啡店策略类

我们将基于 Laravel 自带授权功能中的策略类结合用户实例上的 permission 字段实现简单的 RBAC 权限管理,所以我们要创建针对咖啡店权限的策略类:

php artisan make:policy CafePolicy

初始化新生成的策略类 app/Policies/CafePolicy.php 代码如下:

<?php

namespace App\Policies;

use App\Models\Cafe;
use App\Models\Company;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;

class CafePolicy
{
    use HandlesAuthorization;

    /**
     * 创建一个新的策略类实例.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * 如果用户是管理员或超级管理员则可以新增咖啡店
     *
     * @param User $user
     * @param Company $company
     * @return boolean
     */
    public function create(User $user, Company $company)
    {
        if ($user->permission ==  || $user->permission == 3) {
            return true;
        } else if ($company != null && $user->companiesOwned->contains($company->id)) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * 如果用户是管理员、超级管理员或者拥有该咖啡店所属公司则可以更新该咖啡店
     *
     * @param User $user
     * @param Cafe $cafe
     * @return boolean
     */
    public function update(User $user, Cafe $cafe)
    {
        if ($user->permission == 2 || $user->permission == 3) {
            return true;
        } else if ($user->companiesOwned->contains($cafe->company_id)) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * 如果用户是管理员、超级管理员或拥有该咖啡店所属公司则可以删除该咖啡店
     *
     * @param User $user
     * @param Cafe $cafe
     * @return boolean
     */
    public function delete(User $user, Cafe $cafe)
    {
        if ($user->permission == 2 || $user->permission == 3) {
            return true;
        } else if ($user->companiesOwned->contains($cafe->company_id)) {
            return true;
        } else {
            return false;
        }
    }

}

然后在 app/Providers/AuthServiceProvider.php 中注册这个策略类:

protected $policies = [
    Cafe::class => CafePolicy::class,
];

第四步:实现咖啡店增删改查授权功能

在应用上述策略类实现权限判断之前,需要新建一张 actions 表,用于存储待审核/已处理动作记录:

php artisan make:migration create_actions_table

编写新生成的迁移类 up 方法如下:

public function up()
{
    Schema::create('actions', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('user_id')->unsigned();
        $table->integer('company_id')->unsigned()->nullable();
        $table->integer('cafe_id')->unsigned()->nullable();
        $table->integer('status');
        $table->integer('processed_by')->unsigned()->nullable();
        $table->timestamp('processed_on')->nullable();
        $table->string('type');
        $table->text('content');
        $table->timestamps();
    });
}

运行完 php artisan migrate 命令在数据库创建 actions 数据表之后,新建 Action 模型类:

php artisan make:model Models/Action

在新生成的 app/Models/Action.php 模型类中定义关联关系如下:

// 该更新动作所属咖啡店
public function cafe()
{
    return $this->belongsTo(Cafe::class, 'cafe_id', 'id');
}

// 对应前台操作用户
public function by()
{
    return $this->belongsTo(User::class, 'user_id', 'id');
}

// 对应后台处理管理员
public function processedBy()
{
    return $this->belongsTo(User::class, 'processed_by', 'id');
}

然后在 app/User.php 模型类中与之相对的关联关系如下:

// 该用户名下所有动作
public function actions()
{
    return $this->hasMany(Action::class, 'id', 'user_id');
}

// 该用户名下所有处理的后台审核动作
public function actionsProcessed()
{
    return $this->hasMany(Action::class, 'id', 'processed_by');
} 

接下来,我们需要在控制器的新增、编辑、删除咖啡店方法中编写基于策略类的权限判断代码,不过,在此之前,我们先创建一个服务类 app/Services/ActionService.php 用于在 actions 表中存储待审核动作和审核通过动作,我们的处理逻辑是,对于一个拥有对应操作权限的用户来说,调用控制器的新增、编辑或删除方法会在执行对应处理后在 actions 表中插入一条已处理记录,然后返回响应给用户,而对于一个没有对应操作权限的用户而言,调用对应控制器方法则不会执行新增、编辑或删除操作,而是在 actions 表中插入一条待后台管理员审核记录,并返回响应给用户,我们将 actions 表新增记录逻辑封装到 ActionService 类中:

<?php

namespace App\Services;

use App\Models\Action;
use Illuminate\Support\Carbon;

class ActionService
{
    // 创建一条待审核记录
    public function createPendingAction($cafeID, $companyID, $type, $content, $userId)
    {
        $action = new Action();

        $action->cafe_id = $cafeID;
        $action->company_id = $companyID;
        $action->user_id = $userId;
        $action->status = Action::STATUS_PENDING;
        $action->type = $type;
        $action->content = json_encode($content);

        $action->save();
    }

    // 创建一条已处理操作
    public function createApprovedAction($cafeID, $companyID, $type, $content, $userId)
    {
        $action = new Action();

        $action->cafe_id = $cafeID;
        $action->company_id = $companyID;
        $action->user_id = $userId;
        $action->status = Action::STATUS_APPROVED;
        $action->type = $type;
        $action->content = json_encode($content);
        $action->processed_by = $userId;
        $action->processed_on = Carbon::now();

        $action->save();
    }
}

最后我们在控制器 app/Http/Controllers/API/CafesController.php 中修改新增、编辑、删除方法实现代码如下:

public function postNewCafe(StoreCafeRequest $request)
{
    $companyID = $request->input('company_id');
    $company = Company::where('id', '=', $companyID)->first();
    $company = $company == null ? new Company() : $company;

    $actionService = new ActionService();
    if (Auth::user()->can('create', [Cafe::class, $company])) {
        $cafeService = new CafeService();
        $cafe = $cafeService->addCafe($request->all(), Auth::user()->id);

        $actionService->createApprovedAction(null, $cafe->company_id, 'cafe-added', $request->all(), Auth::user()->id);

        $company = Company::where('id', '=', $cafe->company_id)
            ->with('cafes')
            ->first();

        return response()->json($company, 201);
    } else {
        $actionService->createPendingAction(null, $request->get('company_id'), 'cafe-added', $request->all(), Auth::user()->id);
        return response()->json(['cafe_add_pending' => $request->get('company_name')], 202);
    }
}

// 更新咖啡店数据
public function putEditCafe($id, EditCafeRequest $request)
{
    $cafe = Cafe::where('id', '=', $id)->with('brewMethods')->with('company')->first();
    if (!$cafe) {
        abort(404);
    }

    // 保存修改之前/之后的咖啡店数据
    $content['before'] = $cafe;
    $content['after'] = $request->all();

    $actionService = new ActionService();
    if (Auth::user()->can('update', $cafe)) {
        // 具备更新权限自动审核通过
        $actionService->createApprovedAction($cafe->id, $cafe->company_id, 'cafe-updated', $content, Auth::user()->id);
        $cafeService = new CafeService();
        $updatedCafe = $cafeService->editCafe($cafe->id, $request->all(), Auth::user()->id);

        $company = Company::where('id', '=', $updatedCafe->company_id)
            ->with('cafes')
            ->first();

        return response()->json($company, 200);
    } else {
        // 不具备更新权限需要等待后台审核通过才能更新这个咖啡店
        $actionService->createPendingAction($cafe->id, $cafe->company_id, 'cafe-updated', $content, Auth::user()->id);
        return response()->json(['cafe_updates_pending' => $request->get('company_name')], 202);
    }
}

// 删除咖啡店
public function deleteCafe($id)
{
    $cafe = Cafe::where('id', '=', $id)->with('company')->first();
    if (!$cafe) {
        abort(404);
    }

    $actionService = new ActionService();
    if (Auth::user()->can('delete', $cafe)) {
        // 具备删除权限自动审核通过
        $actionService->createApprovedAction($cafe->id, $cafe->company_id, 'cafe-deleted', '', Auth::user()->id);

        $cafe->delete();
        return response()->json(['message' => '删除成功'], 204);
    } else {
        // 不具备删除权限需要等后台审核通过后才能执行删除操作
        $actionService->createPendingAction($cafe->id, $cafe->company_id, 'cafe-deleted', '', Auth::user()->id);
        return response()->json(['cafe_delete_pending' => $cafe->company->name], 202);
    }
}

如果没有对应权限,则分别返回 cafe_add_pendingcafe_updates_pendingcafe_delete_pending 字段交由前端判断并提示用户,至此,Laravel 后端接口部分就已经完成了,接下来我们要修改前端代码了。

第五步:修改前端代码

最后我们在前端 resources/assets/js/modules/cafes.js 文件中调用后端接口成功后的相应 Action 处理做优化:

addCafe({commit, state, dispatch}, data) {
   commit('setCafeAddStatus', 1);

   CafeAPI.postAddNewCafe(data.company_name, data.company_id, data.company_type, data.subscription, data.website, data.location_name, data.address, data.city, data.state, data.zip, data.brew_methods, data.matcha, data.tea)
       .then(function (response) {
           if (typeof response.data.cafe_add_pending !== 'undefined') {
               // 没有新增权限提示文本
               commit('setCafeAddedText', response.data.cafe_add_pending + ' 审核通过后才能添加!');
           } else {
               commit('setCafeAddedText', response.data.name + ' 已经添加!');
           }

           commit('setCafeAddStatus', 2);
           commit('setCafeAdded', response.data);

           dispatch('loadCafes');
       })
       .catch(function () {
           commit('setCafeAddStatus', 3);
       });
},

editCafe({commit, state, dispatch}, data) {
   commit('setCafeEditStatus', 1);

   CafeAPI.putEditCafe(data.id, data.company_name, data.company_id, data.company_type, data.subscription, data.website, data.location_name, data.address, data.city, data.state, data.zip, data.brew_methods, data.matcha, data.tea)
       .then(function (response) {
           if (typeof response.data.cafe_updates_pending !== 'undefined') {
               // 没有修改权限提示文本
               commit('setCafeEditText', response.data.cafe_updates_pending + ' 审核通过才能更新!');
           } else {
               commit('setCafeEditText', response.data.name + ' 已经编辑成功!');
           }

           commit('setCafeEditStatus', 2);

           dispatch('loadCafes');
       })
       .catch(function (error) {
           commit('setCafeEditStatus', 3);
       });
},

deleteCafe({commit, state, dispatch}, data) {
   commit('setCafeDeleteStatus', 1);

   CafeAPI.deleteCafe(data.id)
       .then(function (response) {

           if (typeof response.data.cafe_delete_pending !== 'undefined') {
               // 没有删除权限提示文本
               commit('setCafeDeletedText', response.data.cafe_delete_pending + ' 审核通过才能删除!');
           } else {
               commit('setCafeDeletedText', '咖啡店删除成功!');
           }

           commit('setCafeDeleteStatus', 2);

           dispatch('loadCafes');
       })
       .catch(function () {
           commit('setCafeDeleteStatus', 3);
       });
},

在新增咖啡店表单页面组件 resources/assets/js/pages/NewCafe.vue 中,添加咖啡店成功后触发通知的代码片段如下(对应代码之前都已经存在了):

addCafeText(){
  return this.$store.getters.getCafeAddText;
}

...

EventBus.$emit('show-success', {
  notification: this.addCafeText
});

同理,在编辑咖啡店表单页面组件 resources/assets/js/pages/EditCafe.vue 中,编辑/删除咖啡店成功后对应触发通知的代码片段如下(同上,对应代码之前都已经存在了):

editCafeText() {
    return this.$store.getters.getCafeEditText;
},

...

EventBus.$emit('show-success', {
    notification: this.editCafeText
});

以及:

deleteCafeText() {
    return this.$store.getters.getCafeDeletedText;
}

...

EventBus.$emit('show-success', {
    notification: this.deleteCafeText
});

运行 npm run dev 重新编译前端资源,此时,应用默认用户的 permission 字段应该 0,不具备新增、修改、删除咖啡店权限,此时试着去更新咖啡店,页面顶部弹出通知如下:

同时在 actions 表中会生成一条 status=0 的待审核记录:

至此,我们就完成了一个简单的基于 RBAC 的咖啡店增删改查权限管理功能,下一篇教程我们将在此授权功能基础上进行管理后台的开发工作,敬请期待。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 通过 Vue Transitions 实现 Vue 组件的 CSS 动画效果 & 若干 Bug 修复

>> 下一篇: 管理后台后端动作审核接口实现