출처: 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 클래스 구체화하기 등에 관심 있다면 도큐멘테이션을 살펴보는 것을 추천한다.
'배움의 즐거움 > 프로그래밍' 카테고리의 다른 글
(33) Graphql-ruby - 에러 핸들링 (0) | 2019.01.01 |
---|---|
(32) Graphql-ruby - Mutation의 모든 것 (0) | 2019.01.01 |
(31) Graphql-ruby - 필드(fields) 의 모든 것 (0) | 2019.01.01 |
(30) Graphql-ruby - 범위설정(scoping) (0) | 2019.01.01 |
(29) Graphql-ruby - Pundit Integration (0) | 2019.01.01 |