Improve ArticlesController's authorization (#466)

* Fix lint

* Add pundit-matchers gem

* Strengthen ArticlePolicy & create spec

* Change ApplicationPolicy to a closed system

* Globally handle pundit's NotAuthorizedError

* Fix lint

* Adjust /new's authorization
This commit is contained in:
Mac Siri 2018-06-21 14:18:32 -04:00 committed by Ben Halpern
parent c121dd63d3
commit ff36271108
13 changed files with 149 additions and 64 deletions

View file

@ -58,8 +58,8 @@ gem "pry", "~> 0.11"
gem "pry-rails", "~> 0.3"
gem "puma", "~> 3.11"
gem "puma_worker_killer", "~> 0.1"
gem "pusher", "~> 1.3"
gem "pundit", "~> 1.1"
gem "pusher", "~> 1.3"
gem "rack-host-redirect", "~> 1.3"
gem "rack-timeout", "~> 0.4"
gem "rails", "~> 5.1"
@ -85,13 +85,12 @@ gem "stream_rails", "~> 2.5"
gem "stripe", "~> 3.9"
gem "therubyracer", "~> 0.12", platforms: :ruby
gem "timber", "~> 2.6"
gem 'twilio-ruby', '~> 5.10.3'
gem "twilio-ruby", "~> 5.10.3"
gem "twitter", "~> 6.2"
gem "uglifier", "~> 3.2"
gem "validate_url", "~> 1.0"
gem "webpacker", "~> 3.2"
gem 'webpush', "~> 0.3"
gem "webpush", "~> 0.3"
group :development do
gem "brakeman", "~> 3.7", require: false
@ -129,6 +128,7 @@ group :test do
gem "factory_bot_rails", "~> 4.8"
gem "fake_stripe", "~> 0.1"
gem "launchy", "~> 2.4"
gem "pundit-matchers", "~> 1.6"
gem "rack_session_access", "~> 0.1"
gem "rails-controller-testing", "~> 1.0"
gem "ruby-prof", "~> 0.17", require: false

View file

@ -618,6 +618,8 @@ GEM
puma (>= 2.7, < 4)
pundit (1.1.0)
activesupport (>= 3.0.0)
pundit-matchers (1.6.0)
rspec-rails (>= 3.0.0)
pusher (1.3.1)
httpclient (~> 2.7)
multi_json (~> 1.0)
@ -979,6 +981,7 @@ DEPENDENCIES
puma (~> 3.11)
puma_worker_killer (~> 0.1)
pundit (~> 1.1)
pundit-matchers (~> 1.6)
pusher (~> 1.3)
rack-host-redirect (~> 1.3)
rack-timeout (~> 0.4)

View file

@ -4,7 +4,6 @@ class ApplicationController < ActionController::Base
include EnforceAdmin
include Pundit
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
# before_action :require_http_auth if ENV["APP_NAME"] == "dev_stage"
@ -87,17 +86,4 @@ class ApplicationController < ActionController::Base
def touch_current_user
current_user.touch
end
private
def user_not_authorized
respond_to do |format|
format.html do
flash[:alert] = "You are not authorized to perform this action."
redirect_to(request.referrer || root_path)
end
format.json { render json: { error: "Not authorized" }, status: 401 }
end
end
end

View file

@ -1,24 +1,26 @@
class ArticlesController < ApplicationController
include ApplicationHelper
before_action :authenticate_user!, except: [:feed, :new]
before_action :set_article, only: [:edit, :update, :destroy]
before_action :raise_banned, only: [:new, :create, :update]
before_action :set_cache_control_headers, only: [:feed]
before_action :authenticate_user!, except: %i[feed new]
before_action :set_article, only: %i[edit update destroy]
before_action :raise_banned, only: %i[new create update]
before_action :set_cache_control_headers, only: %i[feed]
after_action :verify_authorized
def feed
skip_authorization
@page = params[:page].to_i
if params[:username]
if @user = User.find_by_username(params[:username])
@articles = Article.where(published: true, user_id: @user.id).
includes(:user).
select(:published_at, :slug, :processed_html, :user_id, :organization_id, :title).
order('published_at DESC').
order("published_at DESC").
page(@page).per(15)
elsif @user = Organization.find_by_slug(params[:username])
@articles = Article.where(published: true, organization_id: @user.id).
includes(:user).
select(:published_at, :slug, :processed_html, :user_id, :organization_id, :title).
order('published_at DESC').
order("published_at DESC").
page(@page).per(15)
else
render body: nil
@ -28,7 +30,7 @@ class ArticlesController < ApplicationController
@articles = Article.where(published: true, featured: true).
includes(:user).
select(:published_at, :slug, :processed_html, :user_id, :organization_id, :title).
order('published_at DESC').
order("published_at DESC").
page(@page).per(15)
end
set_surrogate_key_header "feed", @articles.map(&:record_key)
@ -37,14 +39,19 @@ class ArticlesController < ApplicationController
end
def new
authorize Article
@user = current_user
@tag = Tag.find_by_name(params[:template])
if @tag && @tag.submission_template.present? && @user
@article = Article.new(body_markdown:@tag.submission_template_customized(@user.name),processed_html:"")
else
@article = Article.new(body_markdown:"---\ntitle: \npublished: false\ndescription: \ntags: \n---\n\n",processed_html:"")
end
@article = if @tag&.submission_template.present? && @user
authorize Article
Article.new(body_markdown: @tag.submission_template_customized(@user.name),
processed_html: "")
else
skip_authorization
Article.new(
body_markdown: "---\ntitle: \npublished: false\ndescription: \ntags: \n---\n\n",
processed_html: "",
)
end
end
def edit
@ -53,6 +60,7 @@ class ArticlesController < ApplicationController
end
def preview
authorize Article
begin
fixed_body_markdown = MarkdownFixer.fix_for_preview(params[:article_body])
parsed = FrontMatterParser::Parser.new(:md).call(fixed_body_markdown)
@ -108,29 +116,20 @@ class ArticlesController < ApplicationController
def delete_confirm
@article = current_user.articles.find_by_slug(params[:slug])
authorize @article
end
def destroy
if current_user == @article.user
@article.destroy
respond_to do |format|
format.html { redirect_to "/dashboard", notice: "Article was successfully deleted." }
format.json { head :no_content }
end
else
respond_to do |format|
format.html { redirect_to "/dashboard", notice: "Unauthorized attempt" }
format.json { head :no_content }
end
authorize @article
@article.destroy!
respond_to do |format|
format.html { redirect_to "/dashboard", notice: "Article was successfully deleted." }
format.json { head :no_content }
end
end
private
def user_not_authorized
redirect_to @article ? @article.path : "/"
end
def handle_org_assignment
if @user.organization_id.present? && article_params[:publish_under_org].to_i == 1
@article.organization_id = @user.organization_id
@ -175,10 +174,7 @@ class ArticlesController < ApplicationController
def article_params
params[:article][:published] = true if params[:submit_button] == "PUBLISH"
params.require(:article).
permit(:title, :body_html, :body_markdown, :user_id, :main_image, :published,
:description, :allow_small_edits, :allow_big_edits, :tag_list, :publish_under_org,
:video, :video_code, :video_source_url, :video_thumbnail_url)
params.require(:article).permit(policy(Article).permitted_attributes)
end
def job_opportunity_params

View file

@ -2,6 +2,7 @@ class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
raise Pundit::NotAuthorizedError, "You must be logged in" unless user
@user = user
@record = record
end
@ -11,7 +12,7 @@ class ApplicationPolicy
end
def show?
scope.where(:id => record.id).exists?
scope.where(id: record.id).exists?
end
def create?
@ -42,6 +43,7 @@ class ApplicationPolicy
attr_reader :user, :scope
def initialize(user, scope)
raise Pundit::NotAuthorizedError, "must be logged in" unless user
@user = user
@scope = scope
end
@ -51,8 +53,6 @@ class ApplicationPolicy
end
end
private
def user_is_admin?
user.has_any_role?(:super_admin, :admin)
end

View file

@ -4,13 +4,31 @@ class ArticlePolicy < ApplicationPolicy
end
def new?
!user_is_banned?
true
end
def create?
!user_is_banned?
end
def delete_confirm?
update?
end
def destroy?
update?
end
def preview?
true
end
def permitted_attributes
%i[title body_html body_markdown user_id main_image published
description allow_small_edits allow_big_edits tag_list publish_under_org
video video_code video_source_url video_thumbnail_url]
end
private
def user_is_author?
@ -22,6 +40,6 @@ class ArticlePolicy < ApplicationPolicy
end
def user_is_banned?
user && user.has_role?(:banned)
user.has_role?(:banned)
end
end

View file

@ -44,6 +44,10 @@ module PracticalDeveloper
# to implement access control for the Flipflop dashboard.
config.flipflop.dashboard_access_filter = -> { head :forbidden unless current_user.has_any_role?(:super_admin) }
# Globally handle Pundit::NotAuthorizedError by serving 404
config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :not_found
# After-initialize checker to add routes to reserved words
config.after_initialize do
Rails.application.reload_routes!
top_routes = []

View file

@ -3,6 +3,8 @@
require "rails_helper"
RSpec.describe ArticlesController, type: :controller do
let(:user) { create(:user) }
describe "GET #feed" do
it "works" do
get :feed, format: :rss
@ -11,6 +13,8 @@ RSpec.describe ArticlesController, type: :controller do
end
describe "GET #new" do
before { sign_in user }
context "with authorized user" do
it "returns a new article" do
get :new

View file

@ -22,6 +22,10 @@ FactoryBot.define do
after(:build) { |user| user.add_role(:super_admin) }
end
trait :admin do
after(:build) { |user| user.add_role(:admin) }
end
trait :banned do
after(:build) { |user| user.add_role(:banned) }
end

View file

@ -0,0 +1,57 @@
require "rails_helper"
RSpec.describe ArticlePolicy do
subject { described_class.new(user, article) }
let(:article) { build(:article) }
let(:valid_attributes) do
%i[title body_html body_markdown user_id main_image published
description allow_small_edits allow_big_edits tag_list publish_under_org
video video_code video_source_url video_thumbnail_url]
end
context "when user is not signed-in" do
let(:user) { nil }
it { within_block_is_expected.to raise_error(Pundit::NotAuthorizedError) }
end
context "when user is not the author" do
let(:user) { build(:user) }
it { is_expected.to permit_actions(%i[new create preview]) }
it { is_expected.to forbid_actions(%i[update delete_confirm destroy]) }
context "with banned status" do
before { user.add_role :banned }
it { is_expected.to permit_actions(%i[new preview]) }
it { is_expected.to forbid_actions(%i[create update delete_confirm destroy]) }
end
end
context "when user is the author" do
let(:user) { article.user }
it { is_expected.to permit_actions(%i[update new create delete_confirm destroy preview]) }
it { is_expected.to permit_mass_assignment_of(valid_attributes) }
context "with banned status" do
before { user.add_role :banned }
it { is_expected.to permit_actions(%i[update new delete_confirm destroy preview]) }
end
end
context "when user is an admin" do
let(:user) { build(:user, :admin) }
it { is_expected.to permit_actions(%i[update new create delete_confirm destroy preview]) }
end
context "when user is a super_admin" do
let(:user) { build(:user, :super_admin) }
it { is_expected.to permit_actions(%i[update new create delete_confirm destroy preview]) }
end
end

View file

@ -17,6 +17,9 @@ require "rspec/retry"
require "algolia/webmock"
require "approvals/rspec"
require "shoulda/matchers"
require "pundit/rspec"
require "pundit/matchers"
WebMock.disable_net_connect!(allow_localhost: true)

View file

@ -10,7 +10,6 @@ RSpec.describe "ChatChannels", type: :request do
chat_channel.add_users([user])
end
describe "GET /connect" do
context "logged in" do
before do
@ -52,19 +51,21 @@ RSpec.describe "ChatChannels", type: :request do
end
describe "POST /chat_channels/:id/moderate" do
it "returns 401 unless user is logged in" do
post "/chat_channels/#{chat_channel.id}/moderate",
it "raises NotAuthorizedError if user is not logged in" do
expect do
post "/chat_channels/#{chat_channel.id}/moderate",
params: { chat_channel: { command: "/ban huh" } },
headers: { HTTP_ACCEPT: "application/json" }
expect(response.status).to eq(401)
end.to raise_error(Pundit::NotAuthorizedError)
end
it "returns 401 if user is logged in but not authorized" do
it "raises NotAuthorizedError if user is logged in but not authorized" do
sign_in user
post "/chat_channels/#{chat_channel.id}/moderate",
params: { chat_channel: { command: "/ban huh" } },
headers: { HTTP_ACCEPT: "application/json" }
expect(response.status).to eq(401)
expect do
post "/chat_channels/#{chat_channel.id}/moderate",
params: { chat_channel: { command: "/ban huh" } },
headers: { HTTP_ACCEPT: "application/json" }
end.to raise_error(Pundit::NotAuthorizedError)
end
context "when user is logged-in and authorized" do

View file

@ -0,0 +1,9 @@
module WithinBlockIsExpected
def within_block_is_expected
expect { subject }
end
end
RSpec.configure do |config|
config.include WithinBlockIsExpected
end