抽象

首先我们看一张图:

Permit Class icon

在代码中赋予用户权限的过程可以抽象为对某个 class 实例化的过程。这个 class 怎么构造,我们 在 编码 中讲解。

编码

建立 permit-demo 项目,

$ rails new permit-demo

创建相关的模型和表,

User

$ bundle exe rails g model User
# db/migrate/xxx_create_users.rb
class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
+     t.string :name, null: false
+     t.timestamps null: false
    end
  end
end

PermissionsUser

$ bundle exe rails g model PermissionsUser

注意: 因为 P 排在 U 前面,所以建立 PermissionsUser, 而不是 UsersPermission 作为 User 和 Permission 的关联 model

# db/migrate/xxx_create_permissions_users.rb

class CreatePermissionsUsers < ActiveRecord::Migration
  def change
    create_table :permissions_users do |t|
      t.integer :user_id
      t.integer :permission_id
      t.timestamps null: false
    end
  end
end

Permission

$ bundle exe rails g model Permission
# db/migrate/xxx_create_permissions.rb
class CreatePermissions < ActiveRecord::Migration
  def change
    create_table :permissions do |t|
+     t.string :name, null: false
+     t.timestamps null: false
    end
  end
end

Post

$ bundle exe rails g model Post
# db/migrate/xxx_create_posts.rb

class CreatePosts < ActiveRecord::Migration
  def change
    create_table :posts do |t|
+     t.string :title
+	  t.text :content
+	  t.integer :user_id
      t.timestamps null: false
    end
  end
end

不要忘记运行 migration,

$ bundle exe rake db:migrate

User, PermissionsUser 和 Permission 三者的关系

为了文章的简单起见,没有引入 Role 模型。

user-permissions

User,

# app/models/user.rb

class User < ActiveRecord::Base

+ has_and_belongs_to_many :permissions
+ validate :name, uniqueness: true

end

Permission,

# app/models/permission.rb

class Permission < ActiveRecord::Base

+ has_and_belongs_to_many :users
+ validates :name, uniqueness: true

end

现在我们开始实现前面提到的某个 class,

某个 class: Permit

# app/models/permit.rb

class Permit < BasicObject

  def self.permission_mapper
    # 默认情况下如果实例能够响应与权限同名的方法,就表示用户拥有此权限
    default_m = ::Hash.new(->{true})

    # 有些权限需要增加特定的参数和逻辑
    custome_m = {
      edit_post: ->(post){ post.user_id == @user.id}
    }

    default_m.merge(custome_m)
  end

  def initialize(user)
    # 权限的实际拥有者
    @user = user
    
    # 这里没有使用常量去存储权限映射,是为了防止权限映射在程序员不知情的情况下被修改
    @permission_mapper = ::Permit.permission_mapper

    # 用于存储和权限同名方法的容器
    @permission_mod = ::Module.new

    load_permissions
  end

  def can?(name, *args)
    __send__(name, *args)
  end

  private

  # 继承于 BasicObject 的 Permit 没有 extend 方法,我们需要自己实现 extend 方法
  def extend(mod)
    mod.__send__(:extend_object, self)
  end

  def load_permissions

    # 从数据库加载权限纪录, 并把权限纪录定义成方法
    @user.permissions.each {|p|
      k = p.name.to_sym
      v = @permission_mapper[k]
      @permission_mod.send(:define_method, k, &v)
    }

    # 为本实例扩展和权限同名的方法,不会影响到 Permit 的其他实例
    extend @permission_mod
  end

  # 用户没有某个权限,调用与此权限同名的方法时,会返回 false,表示此权限不可访问
  def method_missing(permission_name, *args)
    false
  end

end


创建一些种子数据

# db/seeds.rb

users = User.create([{name: 'user0'},
                     {name: 'user1'},
                     {name: 'admin0'},
                     {name: 'admin1'}
                    ])


permissions = Permission.create([{name: 'login_admin'},
                                 {name: 'create_post'},
                                 {name: 'edit_post'},
                                 {name: 'crud_users'},
                                 {name: 'crud_posts'}
                                ])
$ bundle exe rake db:seed

场景1: 用户没有 create_post 权限, 所以用户不能 create post

创建相关的 controller 和 view

首先配置路由,

# config/routes.rb

Rails.application.routes.draw do

+ resources :posts
  
end

建立 posts_controller,

$ bundle exe rails g controller posts
# app/controllers/posts_controller.rb

class PostsController < ApplicationController

+  def index
+    @posts = Post.order('created_at desc').includes(:users)
+  end

+  def new
+    @post = Post.new
+  end

+  def create
+  end
  
end

# app/views/posts/new.html.erb

<h2>Create Post</h2>
<%= form_for @post do |f| %>
  Title: <%= f.text_field :title %> <br/>
  <br/>
  Content: <%= f.text_area :content %> <br/>
  <br/>
  <%= f.submit 'Create' %>
<% end %>

在 application_controller.rb 里增加一些辅助方法,

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception
  
   # 可以在 view 里使用 :current_user 和 :permit? 方法
+  helper_method :current_user, :permit?

   # 为了简单起见,我们没有实现一个完整的注册登录功能
   # 通过在 params 里传递 user name 来识别当前用户
   # 比如: http://localhost:3000/posts/new?current_user=user0
+  def current_user
+    @current_user ||= User.find_by(name: params[:current_user])
+  end

   # 将 current_user 的 permit 缓存起来
   # 并且如果当前用户为空则建立一个游客用户(User.new)
+  def current_permit
+    @current_permit ||= Permit.new(current_user || User.new)
+  end


   # 判断当前登录用户是否具有名字为 name 的权限
+  def permit?(name, *args)
+    current_permit.can?(name, *args)
+  end

   # fence 是篱笆的意思,我们可以通过篱笆保护我们的代码不被不认可的用户访问
   # 如果代码里有多个 render 或者 redirect_to 我们需要使用
   # fence(name, *args) {...} 的形式
+  def fence(name, *args, &block)
+    if !permit?(name, *args)
+      render status: 403, json: {code: 403, msg: 'forbidden'}.to_json
+    else
+      block.call if block_given?
+    end
+  end
  
end

启动 rails 服务,

$ bundle exe rails s

用浏览器访问 http://localhost:3000/posts/new?current_user=user0,

posts new forbidden

这说明我们的权限机制起作用了。

场景2: 用户有 create_post 权限, 所以用户可以创建 post

首先我们给用户 ‘user0’ 赋予 ‘create_post’ 的权限,

u = User.find_by(name: 'user0')
p = Permission.find_by(name: 'create_post')

u.permissions << p
u.save

用浏览器访问 http://localhost:3000/posts/new?current_user=user0,

posts new success

我们再给 PostsController#create 方法增加 fence 代码,

# app/controllers/posts_controller.rb

class PostsController < ApplicationController

  def create
+   fence :create_post do
+     @post = current_user.posts.create(post_params)
+     if @post.valid?
+       redirect_to action: 'index'
+     else
+       render :new
+     end  
+   end
  end
  
+ private

+ def post_params
+   params.require(:post).permit(:title, :content)
+ end
  
end

同时我们在 app/views/posts/new.html.erb 的表单里增加一个隐藏字段,用于提交当前用户,

# app/views/posts/new.html.erb

<%= form_for @post do |f| %>
+  <%= hidden_field_tag :current_user, params[:current_user] %>
<% end %>

最后我们增加 app/views/posts/index.html.erb 视图,

# app/views/posts/index.html.erb

<style>
table, th, td {
    border: 1px solid black;
    border-collapse: collapse;
}
th, td {
    padding: 5px;
}
</style>
<h2>Posts Index</h2>
<table>
  <tr>
    <th>标题</th>
    <th>内容</th>
    <th>作者</th>
    <th>创建时间</th>
	<th>编辑</th>
  </tr>
  <% @posts.each do |post| %>
    <tr>
      <td><%= post.title %></td>
      <td><%= post.content %></td>
      <td><%= post.user.name %></td>
      <td><%= post.created_at.strftime('%Y%m%d %H:%M:%S')%></td>
	  <td></td>
    </tr>
  <% end %>
</table>

此时 Post 模型需要做一些改动,

# app/models/user.rb

class Post < ActiveRecord::Base
  validates :title, :content, presence: true

+ belongs_to :user
end

点击按钮提交后,

submit post

场景3: 用户有 edit_post 权限, 但是 post 不属于用户,所以用户不可以编辑 post

首先我们修改下 app/views/posts/index.html.erb, 增加编辑链接,

<h2>Posts Index</h2>
<table>
  <tr>
+   <th>ID</th>
    <th>标题</th>
    <th>内容</th>
    <th>作者</th>
    <th>创建时间</th>
+   <th>编辑</th>
  </tr>
  <% @posts.each do |post| %>
    <tr>
+     <td><%= post.id %></td>
      <td><%= post.title %></td>
      <td><%= post.content %></td>
      <td><%= post.user.name %></td>
      <td><%= post.created_at.strftime('%Y%m%d %H:%M:%S')%></td>
	  <% # 只有 post 的创建者才可以看到编辑链接 %>
+     <% if permit?(:edit_post, post) %>
+       <td><%= link_to 'edit', "#{edit_post_url(post)}?current_user=#{params[:current_user]}" %></td>
+     <% end %>
    </tr>
  <% end %>
</table>

然后我们给 ‘user1’ 增加 ‘edit_post’ 权限,

u = User.find_by(name: 'user1')
p = Permission.find_by(name: 'edit_post')

u.permissions << p
u.save

我们访问 http://localhost:3000/posts?current_user=user1,

edit post index

由于 index 页面所有的 post 都不是 user1 创建的,所以 user1 看不到编辑链接。edit_post 权限的逻辑是,

 {edit_post: ->(post){ post.user_id == @user.id }}

我们修改下 posts_controller.rb 文件,并增加一个 app/views/posts/edit.html.erb 文件,

# app/controllers/posts_controller.rb

class PostsController < ApplicationController

+  def edit
+    @post = Post.find(params[:id])
+    fence(:edit_post, @post) do
+      render :edit
+    end
+  end
  
end

# app/views/posts/edit.html.erb

<h2>Edit Post</h2>
<%= form_for @post do |f| %>
  Title: <%= f.text_field :title %> <br/>
  <br/>
  Content: <%= f.text_area :content %> <br/>
  <br/>
  <%= hidden_field_tag :current_user, params[:current_user] %>
  <%= f.submit 'Update' %>
<% end %>

我们访问 http://localhost:3000/posts/:id/edit?current_user=user1

请注意, :id 用实际存在的 post id 代替.

edit post forbidden

我们看到 user1 虽然有 edit_post 权限,但是 user1 不是其欲编辑的 post 的创建者, 其 仍然不能编辑 post.

场景4: 用户有 edit_post 权限, 并且 post 属于用户,所以用户可以编辑 post

我们给 user0 增加 edit_post 权限,

u = User.find_by(name: 'user0')
p = Permission.find_by(name: 'edit_post')

u.permissions << p
u.save

然后我们访问 http://localhost:3000/posts?current_user=user0,

post index edit

我们看到 user0 创建的 post 其编辑链接对 user0 可见。

我们随意点击其中的一个编辑链接, 比如 http://localhost:3000/posts/6/edit?current_user=user0,

edit post success

访问没有被拒绝。

小结

我们看到核心的代码即 Permit 类的代码去掉注释不超过 50 行代码, 我们就实现了一个灵活优雅的 权限系统,这确实是 Ruby 之美的一种体现。

代码在 permit-demo