본문 바로가기

배움의 즐거움/프로그래밍

Rails Authorization with Pundit

반응형

출처: https://www.sitepoint.com/straightforward-rails-authorization-with-pundit/#report-ad




"Pundit" 은 "학자" 또는 "똑똑한 사람"(때때로 부정적인 의미로 씌인다)를 뜻하지만 실제로 이를 사용하기 위해서는 똑똑할 필요는 없다. Pundit 은 굉장히 이해하기 쉽다. Pundit 의 도큐멘테이션을 살펴보면서 나는 pundit과 사랑에 빠지게 되었다.

Pundit 의 배경에 있는 아이디어는 새로운 DSL 을 이용하지 않고 이전의 Ruby 클래스와 메서드를 사용함에 있다. 이 젬은 오직 몇 개의 유용한 헬퍼들만 추가하므로 전반적으로 당신이 적합하다고 생각하는 방식으로 시스템을 만들 수 있다. 이 해결책은 CanCanCan 보다 약간 낮은 레벨이고 이 둘을 비교하는 것은 굉장히 재미있다.

이 포스팅에서는 다음과 같은 Pundit 의 메인 기능들을 모두 살펴볼 것이다. 접근 룰 적용하기, 헬퍼 메서드 사용하기, 범위 지정하기 그리고 허용된 attributes 정의하기

소스코드는 GitHub 여기서 볼 수 있다.


준비

준비 단계는 굉장히 간단하다. 레일즈 앱을 생성한다. 

젬파일에 추가:

Gemfile

[...]
gem 'pundit'
gem 'clearance'
gem 'bootstrap-sass'
[...]


Clearance 젬은 인증을 빠르게 셋업하기 위해서 사용될 것이다. 해당 젬은 “Simple Rails Authentication with Clearance” 포스팅에서 다뤄졌다. 

부트스트랩은 기본 스타일링을 위해서 사용될 것이므로 원한다면 스킵해도 좋다.

다음 라인을 실행하는걸 잊지 마라.

$ bundle install

그 다음 Clearance 의 generator 를 실행해라. 이것은 User  모델과 기본적인 configuration을 생성할 것이다.

$ rails generate clearance:install

 레이아웃에 플래쉬 메시지를 추가

layouts/application.html.erb

[...]
<div id="flash">
  <% flash.each do |key, value| %>
    <div class="alert alert-<%= key %>"><%= value %></div>
  <% end %>
</div>
[...]

새로운  Post scaffold 생성

$ rails g scaffold Post title:string body:text

사용자가 앱을 사용하기 전에 로그인 하도록 설정

application_controller.rb

[...]
before_action :require_login
[...]

루트 route 설정

routes.rb

[...]
root to: 'posts#index'
[...]


Admin User


다음와 같이 마이그레이션을 수정하여 admin 필드를 users 테이블 안에 추가하다.

xxx_create_users.rb

[...]
t.boolean :admin, default: false, null: false
[...]

migrations 실행

$ rake db:migrate

마지막으로 작은 버튼을 추가해서 어드민과의 전환이 가능하도록 함.

layouts/application.html.erb

[...]
<% if current_user %>
  <div class="well well-sm">
    Admin: <strong><%= current_user.admin? %></strong><br>
    <%= link_to 'Toggle admin rights', user_path(current_user), method: :patch, class: 'btn btn-info' %>
  </div>
<% end %>
[...] 

current_user 이 존재하는지 체크하고 그렇지 않다면 인증을 거치지 않은 사용자는 다음과 같은 에러를 보게 될 것이다.

route 추가

routes.rb

[...]
resources :users, only: [:update]
[...]

controller 추가

users_controller.rb

class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    @user.toggle!(:admin)
    flash[:success] = 'OK!'
    redirect_to root_path
  end
end

필요한 세팅을 마쳤으니 이제 Pundit을 추가해보자.


Pundit 추가


시작하면서 다음 라인을 ApplicationController 에 추가한다.

application_controller.rb

[...]
include Pundit
[...]

Pundit의 generator 을 실행

$ rails g pundit:install

이 것은 app/policies 폴더 하위에 policies 를 가진 base 클래스를 생성할 것이다. Policy classes 는 Pundit 의 핵심이며 우리는 이것을 광범위하게 사용하게 될 것이다. base policy 클래스는 다음과 같이 생겼다.

app/policies/application_policy.rb

class ApplicationPolicy
  attr_reader :user, :record

  def initialize(user, record)
    raise Pundit::NotAuthorizedError, "must be logged in" unless user
    @user = user
    @record = record
  end

  def index?
    false
  end

  def show?
    scope.where(:id => record.id).exists?
  end

  def create?
    false
  end

  def new?
    create?
  end

  # [...]
  # some stuff omitted

  class Scope
    # [...]
  end
end

 각 policy 는 기본 루비 클래스이지만 몇 가지 기억해야할 것이 있다.

  • Policy 는 그들이 포함되는 모델의 이름을 따서 만들어야 하며 뒤에 Policy 가 붙는 형식이다. 예를 들어 PostPolicy는 Post 모델에 속한다. 만약 연결된 모델이 없더라도 여전히 pundit 을 사용할수 있다.(이에 대해 더 보기 here

  • 우선 initialize 메서드의 첫번째 argument 는 사용자 레코드이다. Pundit 은 current_user 메서드를 사용해서 사용자를 가져올 수 있지만, 그러한 메서드가 없다면 pundit_user 를 오버라이딩 함으로써 변경 가능하다.(더 보기 here

  • 두 번째 argument 는 모델 객체이다. 그러나 이 것은 ActiveModel 객체가 아니어도 된다. policy 클래스는 접근 권한을 검사하기 위해서 create? 또는 new? 와 같은 메서드를 구현해야 한다.

만약 너가 base policy 클래스를 쓰고 있고 이를 통해서 상속받는다면 대부분의 것들은 걱정하지 않아도 된다. 그러나 커스텀 policy 클래스를 써야할 때가 있을 것이다.(예를 들어 응당하는 모델이 없을 경우)


접근 룰 제공

자 이제 첫번째 접근 룰을 만들어보자. 예르 ㄹ들어 오직 admin 사용자가 post 를 제거할 수 있다고 생각해보자. 굉장히 쉽다. policies 폴더에 post_policy.rb 를 생성하고 다음과 같이 붙혀넣는다.

policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  def destroy?
    user.admin?
  end
end

 보는 바와 같이 destroy? 메서드는 해당 사용자가 액션을 수항해는데 권한이 있는지 없는지에 따라 true 또는 false 를 리턴한다.

컨트롤러에서 우리의 룰을 검사해야 한다.

posts_controller.rb

[...]
def destroy
  authorize @post
  @post.destroy

  redirect_to posts_url, notice: 'Post was successfully destroyed.'
end
[...]

authorize 는 두번째 옵션으로 argument 를 받는데 이는 사용할 룰의 이름이다. 이는 만약에 옵션 이름이 메서드 이름과 다를 때 유용하다. 예를 들면 다음과 같다.

def publish
  authorize @post, :update?
end

만약 해당 과정을 실행할 리소스가 없다면 다음과 같이 인스턴스 대신에 클래스 명을 전달 할 수도 있다. 

authorize Post

만약 사용자가 액션을 수행하는 것이 허용되지 않는다면 이는 에러를 불러일으킬 것이다. 우리는 이 대신에 좀 더 유용한 메시지를 보여주기 위해서 이를 rescue 할 것이다. 가장 쉬운 방법은 기본적인 텍스트를 렌더링하는 것이다.

application_controller.rb

[...]
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

private

def user_not_authorized
  flash[:warning] = "You are not authorized to perform this action."
  redirect_to(request.referrer || root_path)
end
[...]

그러나 각각의 케이스에 따라 다른 커스텀 메시지를 사용해야 될 수도 있다. 또는 이를 번역해서 보여줘야 할 때도 있다. 이것도 역시 가능하다.

application_controller.rb

[...]
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

private

def user_not_authorized(exception)
  policy_name = exception.policy.class.to_s.underscore
  flash[:warning] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
  redirect_to(request.referrer || root_path)
end
[...]

번역을 파일을 업데이트 하는것도 잊지 말것

config/locales/en.yml

en:
 pundit:
   default: 'You cannot perform this action.'
   post_policy:
     destroy?: 'You cannot destroy this post!'

만약 사용자가 해당 포스트를 제거할 수 없다면 destroy 버튼을 보여주는 것은 의미가 없다. 다행히도 이를 위해 pundit 은 특별한 헬퍼 메서드를 제공한다.

views/posts/index.html.erb

[...]
<% if policy(post).destroy? %>
  <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
<% end %>
[...]

이제 가서 확인해보면 모든 것이 잘 동작할 것이다.

잘 보면 사용자가 인증을 거치지 않았을 경우의 케이스가 핸들링 되지 않았다는 것을 볼 수 있을 것이다. 이를 수정하기 위해서 다음과 같인 라인을 추가한다.

policies/application_policy.rb

[...]
def initialize(user, record)
  raise Pundit::NotAuthorizedError, "must be logged in" unless user
  @user = user
  @record = record
end
[...]

Pundit::NotAuthorizedError 에러를  ApplicationController 에서 rescue 하는 것을 잊지 말자.


Relations 설정


이제 포스트와 사용자 간의 one-to-many 관계를 설정해보자. 해당 마이그레이션을 만들고 적용해보자.

$ rails g migration add_user_id_to_posts user:references
$ rake db:migrate

모델 파일 수정

models/post.rb

[...]
belongs_to :user
[...]

models/user.rb

[...]
has_many :posts
[...]

또한 seed 파일을 이용해서 테스트 레코드들을 생성하자.

20.times do |i|
  Post.create({title: "Post #{i + 1}", body: 'test body', user_id: i > 10 ? 1 : 2})
end

데이터를 생성해보자.

$ rake db:seed

사용자가 자신의 post 를 삭제할 수 있도록 policy 를 수정해보자.

policies/post_policy.rb

[...]
def destroy?
  user.admin? || record.user == user
end
[...]

기억할지 모르겠지만 record 는 현재 우리가 작업하고 있는 객체로 설정되어 있다. 이것도 좋지만 우리는 더 나아갈 수 있다. 사용자 자신의 포스트만 로드할 수 있도록 범위설정을 생성하는 것은 어떨까? pundit 은 이를 지원한다.


Scope 와 함께 작업하기

우선 non-RESTful 액션을 생성한다.

posts_controller.rb

def user_posts
end

routes.rb

resources :posts do
  collection do
    get '/user_posts', to: 'posts#user_posts', as: :user
  end
end

top menu 추가

layouts/application.html.erb

[...]
<nav class="navbar navbar-inverse">
  <div class="container">
    <div class="navbar-header">
      <%= link_to 'Pundit', root_path, class: 'navbar-brand' %>
    </div>
    <div id="navbar">
      <ul class="nav navbar-nav">
        <li><%= link_to 'All posts', posts_path %></li>
        <li><%= link_to 'Your posts', user_posts_path %></li>
      </ul>
    </div>
  </div>
</nav>
[...]

우리는 실제 scope 을 생성해야 한다. application_policy.rb 파일을 보면 다음과 같은 코드를 가진 scope 클래스가 있다.

policies/application_policy.rb

class Scope
  attr_reader :user, :scope

  def initialize(user, scope)
    @user = user
    @scope = scope
  end

  def resolve
    scope
  end
end

policy 와 마찬가지로 몇가지 고려해야 할 사항들이 있다.

  • 클래스는 Scope 으로 명명되어야 하며 policy 클래스 안에 포함된다. policy와 마찬가지로 initialize 메서드의 첫 번째 argument 는 user 이다. 두 번째 argument 는 scope 이다.(이는  ActiveRecord 또는 ActiveRecord::Relation 또는 다른 어떤 것의 인스턴스이다) Scope 클래스는 라고 불리우는 메서드를 구현하는데 이는 반복 가능한(iterable) 결과를 리턴한다. 

 base 클래스로부터 상속받고 자신만의resolve  메서드를 구현해보자.

policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      scope.where(user: user)
    end
  end
  [...]
end

해당 scope 은 단순히 해당 사용자에게 해당되는 포스트만들 로드한다. 이 새로운 scope 를 컨트롤러에서 사용해보자.

posts_controller.rb

[...]
def user_posts
  @posts = policy_scope(Post)
end
[...]

index 뷰에서 일부 부분을 partial 로 추출할 수 있다.

views/posts/_post.html.erb

<tr>
  <td><%= post.title %></td>
  <td><%= post.body %></td>
  <td><%= link_to 'Show', post %></td>
  <td><%= link_to 'Edit', edit_post_path(post) %></td>
  <% if policy(post).destroy? %>
    <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
  <% end %>
</tr>

views/posts/_list.html.erb

<table class="table table-bordered table-striped table-condensed table-hover">
  <thead>
  <tr>
    <th>Title</th>
    <th>Body</th>
    <th colspan="3"></th>
  </tr>
  </thead>

  <tbody>
  <%= render @posts %>
  </tbody>
</table>

<br>

<%= link_to 'New Post', new_post_path %>

views/posts/index.html.erb

<h1>Listing Posts</h1>

<%= render 'list' %>

이제 새로운 뷰를 만들어보자.

views/posts/user_posts.html.erb

<h1>Your Posts</h1>

<%= render 'list' %>

다음과 같이 뷰에서 너는 오직 필요한 데이터만 선택하기 위해서 다음과 같은 코드를 사용할 수 있다.

<% policy_scope(@user.posts).each do |post| %>
<% end %>


권한부여 강제하기

만약 해당 권한부여가 너의 컨트롤러에서 발생하는지 검사하고 싶다면 verify_authorized 를 after action 으로 사용해라. 원하는 액션만 검사하도록 할 수도 있다.

posts_controller.rb

[...]
after_action :verify_authorized, only: [:destroy]
[...]

scoping 을 검사하기 위해서 같은 걸 사용할 수 있다.

posts_controller.rb

[...]
after_action :verify_policy_scoped, only: [:user_posts]
[...]

그러나 어떠한 경우에는 권한부여 검사를 실행하는 것을 스킵하고 싶을 수도 있다. 예를 들어 삭제하고 싶은 데이터가 존재하지 않는다면 더 이상 액션을 하지 않은 채 리턴할 수 있다. 이럴 경우 skip_authorization 메서드를 사용하면 된다.

[...]
def destroy
  if @post.present?
    authorize @post
    @post.destroy
  else
    skip_authorization
  end

  redirect_to posts_url, notice: 'Post was successfully destroyed.'
end

private

def set_post
  @post = Post.find_by(id: params[:id])
end
[...]


허용된 매개변수(Parameters)


admin 사용자만 수정할 수 있는 "특별한" 매개변수가 있다고 가정해보자. 다음과 같이 마이그레이션을 추가하자.

$ rails g migration add_special_to_posts special:boolean

마이그레이션 일부 수정

xxx_add_special_to_posts.rb

[...]
add_column :posts, :special, :boolean, default: false
[...]

실행

$ rake db:migrate

이제 새로운 메서드를 policy 파일에 정의해보자.

policies/post_policy.rb

[...]
def permitted_attributes
  if user.admin?
    [:title, :body, :special]
  else
    [:title, :body]
  end
end
[...]

 즉 admin 사용자는 모든 attribute 를 수정할 수 있는 반면 일반 사용자는 오직 title 과 body만을 수정할 수 있다.

posts_controller.rb

[...]
def create
  @post = Post.new
  @post.update_attributes(permitted_attributes(@post))

  if @post.save
    redirect_to @post, notice: 'Post was successfully created.'
  else
    render :new
  end
end

def update
  if @post.update(permitted_attributes(@post))
    redirect_to @post, notice: 'Post was successfully updated.'
  else
    render :edit
  end
end
[...]

permitted_attributes 는 resource 가 전해지도록 예상하는 헬퍼 메서드이다. create 메서드에서는 작은 문제점이 하나 있다. attribute 를 설정하에 앞서 @post 를 먼저 생성해야 한다. 만약 다음과 같이 한다면

@post = Post.new(permitted_attributes(@post))

이는 에러를 발생시킬 것이다. 왜냐하면 존재하지 않는 객체에 permitted_attributes 를 전달하려 했기 때문이다.

permitted_attributes 를 사용하는 대신에 기본적인 post_params 를 다음과 같이 수정하여 사용할 수도 있다.

posts_controller.rb

[...]
def post_params
  params.require(:post).permit(policy(@post).permitted_attributes)
end
[...]

마지막으로 뷰를 수정한다.

views/posts/_form.html.erb

[...]
<div class="field">
  <%= f.label :special %><br>
  <%= f.check_box :special %>
</div>
[...]

views/posts/_list.html.erb

[...]
<thead>
<tr>
  <th>Title</th>
  <th>Body</th>
  <th>Special?</th>
  <th colspan="3"></th>
</tr>
</thead>
[...]

views/posts/_post.html.erb

<tr>
  <td><%= post.title %></td>
  <td><%= post.body %></td>
  <td><%= post.special? %></td>
  <td><%= link_to 'Show', post %></td>
  <td><%= link_to 'Edit', edit_post_path(post) %></td>
  <% if policy(post).destroy? %>
    <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
  <% end %>
</tr>

이제부터는 일반 사용자가 만약 "special" 매개변수를 true 로 설정하는 것이 불가능할 것이다.


결론


이번 포스트에서는 기본 루비 클래스를 이용한 권한 부여 gem인 Pundit 에 대해서 살펴보았다. 우리는 대부분의 기능들을 살펴 보았지만 추가적인 context 사용하는 방법이나 수동으로 policy 클래스 구체화하기 등에 관심 있다면 도큐멘테이션을 살펴보는 것을 추천한다.




반응형