Ruby on Rails에서 CanCanCan을 활용한 접근 거절 처리 및 보안 강화 전략

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

태그: Ruby on Rails CanCanCan AccessDenied authorization ruby

7월 30일 06:55에 게시됨