Rails 애플리케이션에서 인가(Authorization)는 보안의 핵심 요소입니다. 사용자가 권한 없는 리소스에 접근할 때 일관되지 않은 에러 응답, 분산된 검증 로직, 포맷별 상이한 응답 처리 등은 시스템의 취약점이 될 수 있습니다. CanCanCan은 이러한 문제를 해결하는 강력한 Rails용 인가 Gem입니다. 이 글에서는 CanCanCan의 접근 거절(Access Denied) 처리 메커니즘과 보안 강화 방법을 살펴봅니다.
CanCanCan 접근 거절의 핵심: AccessDenied 예외
CanCanCan은 권한 위반 시 CanCan::AccessDenied 예외를 발생시킵니다. 이 예외는 단순히 에러를 알리는 것을 넘어, 거부된 작업과 대상에 대한 컨텍스트를 포함합니다.
# lib/cancan/exceptions.rb (개념적 구조)
class AccessDenied < Error
attr_reader :action, :subject, :conditions
attr_writer :default_message
def initialize(message = nil, action = nil, subject = nil, conditions = nil)
@message = message
@action = action
@subject = subject
@conditions = conditions
@default_message = I18n.t(:"unauthorized.default",
default: '이 페이지에 접근할 권한이 없습니다.')
end
end
컨트롤러에서의 전역 예외 처리
ApplicationController에서 rescue_from을 사용하여 일관된 응답을 반환하는 것이 기본입니다.
class BaseController < ActionController::Base
rescue_from CanCan::AccessDenied do |access_error|
respond_to do |format_type|
format_type.html { redirect_to main_dashboard_url, flash: { warning: access_error.message } }
format_type.json { render json: { status: 'error', detail: access_error.message }, status: :forbidden }
format_type.js { render js: "alert('#{j access_error.message}');" }
end
end
end
보안 강화: 리소스 존재 여부 은닉
공격자가 HTTP 상태 코드를 통해 리소스의 존재 여부를 추론하는 것을 방지하기 위해, 권한이 없는 경우에도 404(Not Found)를 반환하는 것이 안전합니다.
class SecureBaseController < ActionController::Base
rescue_from CanCan::AccessDenied do |denied_exception|
respond_to do |format|
format.html { redirect_to root_url, alert: '요청한 리소스를 찾을 수 없습니다.', status: :not_found }
format.json { head :not_found }
format.xml { head :not_found }
end
end
end
에러 메시지의 세밀한 제어와 다국어 지원
권한 검증 시점에 직접 메시지를 지정하거나, I18n을 통해 다국어 지원을 적용할 수 있습니다.
class DocumentsController < ApplicationController
def view
@document = Document.find(params[:id])
authorize! :view, @document, message: "이 문서를 열람할 권한이 없습니다."
end
end
# config/locales/ko.yml
ko:
unauthorized:
default: "접근 권한이 없습니다."
manage:
all: "%{subject}에 대한 %{action} 작업이 거부되었습니다."
user: "다른 사용자 계정을 관리할 수 없습니다."
view:
document: "구독자만 이 문서를 열람할 수 있습니다."
복잡한 로직에서의 수동 예외 발생
특수한 비즈니스 로직에서 수동으로 접근 거절을 트리거해야 할 때가 있습니다.
class ReportsController < ApplicationController
def generate_financial_report
if current_user.finance_team_member?
# 보고서 생성 로직
else
raise CanCan::AccessDenied.new("재무 보고서 생성은 재무팀만 가능합니다.", :generate_financial_report, Report)
end
end
end
고급 처리 기법
클라이언트 유형에 따른 동적 라우팅
rescue_from CanCan::AccessDenied do |err|
if request.user_agent =~ /Mobile/i
redirect_to mobile_login_path, alert: err.message
elsif request.headers['X-API-Request'] == 'true'
render json: { error_code: 403, error_msg: err.message }, status: :forbidden
else
redirect_to unauthorized_path, alert: err.message
end
end
보안 감사 로깅
rescue_from CanCan::AccessDenied do |denied_exception|
AuditLogger.info(
event_type: 'unauthorized_access',
actor_id: current_user&.id,
attempted_action: denied_exception.action,
target_model: denied_exception.subject.class.name,
source_ip: request.remote_ip,
occurred_at: Time.current
)
redirect_to root_path, alert: '접근이 제한되었습니다.'
end
계층별 역할 기반 인가 (Ability)
class UserAbility
include CanCan::Ability
def initialize(current_user)
return unless current_user
if current_user.super_user?
can :manage, :all
elsif current_user.content_manager?
can :manage, Post
cannot :publish, Post
else
can :read, Post, is_published: true
end
end
end
성능 최적화 및 모범 사례
N+1 쿼리 방지
컬렉션에 대한 권한 검사는 accessible_by를 사용하여 데이터베이스 레벨에서 필터링해야 합니다.
def index
@posts = Post.accessible_by(current_ability).page(params[:page])
end
Ability 객체 캐싱
def current_ability
@cached_ability ||= UserAbility.new(current_user)
end
권한 거절 시나리오 테스트
RSpec.describe PostsController, type: :controller do
describe 'GET #edit' do
context '인가되지 않은 사용자일 때' do
it '메인 대시보드로 리다이렉트하고 경고 메시지를 띄워야 한다' do
get :edit, params: { id: post.id }
expect(response).to redirect_to(main_dashboard_url)
expect(flash[:warning]).to be_present
end
end
end
end
주요 문제 해결
권한 검증 누락 방지
모든 액션에 대해 인가 검사가 이루어졌는지 강제할 수 있습니다.
class ApplicationController < ActionController::Base
check_authorization
rescue_from CanCan::AccessDenied do |exception|
# 예외 처리 로직
end
end
class PublicPagesController < ApplicationController
skip_authorization_check
end
중첩된 리소스의 인가
class TasksController < ApplicationController
load_and_authorize_resource :project
load_and_authorize_resource :task, through: :project
end