Skip to content

Commit

Permalink
Merge pull request #70 from enjaku4/controller-access-fix
Browse files Browse the repository at this point in the history
  • Loading branch information
enjaku4 authored Jan 22, 2025
2 parents 3f53c4c + a20fb5f commit ab920c8
Show file tree
Hide file tree
Showing 15 changed files with 133 additions and 26 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## v4.1.1

### Bugs:

- Fixed an issue where controller-wide `grant_access` calls would overwrite each other instead of being additive, causing inconsistent access control based on statement order

### Misc:

- Minor performance improvement for authorization checks

## v4.1.0

### Features:
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ gemspec
rails_version = ENV.fetch("RAILS_VERSION", "~> 7")

gem "byebug"
gem "concurrent-ruby", "1.3.4"
gem "database_cleaner-active_record"
gem "grepfruit"
gem "rails", rails_version
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,21 @@ end
```
This means that `Crm::InvoicesController` is still accessible to `admin` but is also accessible to `accountant`.

This applies as well to multiple rules defined for the same controller or action:
```rb
class Crm::OrdersController < ApplicationController
grant_access roles: :manager, context: Order
grant_access roles: :admin

grant_access action: :show, roles: :client, context: -> { Order.find(params[:id]) }
grant_access action: :show, roles: :accountant
def show
# ...
end
end
```
This will add rules for `manager` and `admin` roles for all actions in `Crm::OrdersController`, and for `client` and `accountant` roles for the `show` action.

## Dynamic Authorization Rules

For more complex cases, Rabarber provides dynamic rules:
Expand Down
8 changes: 3 additions & 5 deletions lib/rabarber/core/access.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,13 @@ def access_granted?(roleable, action, controller_instance)
end

def controller_accessible?(roleable, controller_instance)
controller_rules.any? do |rule_controller, rule|
controller_instance.is_a?(rule_controller) && rule.verify_access(roleable, controller_instance)
controller_rules.any? do |controller, rules|
controller_instance.is_a?(controller) && rules.any? { _1.verify_access(roleable, controller_instance) }
end
end

def action_accessible?(roleable, action, controller_instance)
action_rules[controller_instance.class].any? do |rule|
rule.action == action && rule.verify_access(roleable, controller_instance)
end
action_rules[controller_instance.class][action].any? { _1.verify_access(roleable, controller_instance) }
end
end
end
Expand Down
9 changes: 6 additions & 3 deletions lib/rabarber/core/permissions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ class Permissions
attr_reader :storage

def initialize
@storage = { controller_rules: Hash.new({}), action_rules: Hash.new([]) }
@storage = {
controller_rules: Hash.new { |h, k| h[k] = [] },
action_rules: Hash.new { |h1, k1| h1[k1] = Hash.new { |h2, k2| h2[k2] = [] } }
}
end

class << self
def add(controller, action, roles, context, dynamic_rule, negated_dynamic_rule)
rule = Rabarber::Core::Rule.new(action, roles, context, dynamic_rule, negated_dynamic_rule)
action ? action_rules[controller] += [rule] : controller_rules[controller] = rule
rule = Rabarber::Core::Rule.new(roles, context, dynamic_rule, negated_dynamic_rule)
action ? action_rules[controller][action] += [rule] : controller_rules[controller] += [rule]
end

def controller_rules
Expand Down
4 changes: 2 additions & 2 deletions lib/rabarber/core/permissions_integrity_checker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ def run!
private

def missing_list
@missing_list ||= action_rules.each_with_object([]) do |(controller, rules), arr|
missing_actions = rules.map(&:action) - controller.action_methods.map(&:to_sym)
@missing_list ||= action_rules.each_with_object([]) do |(controller, hash), arr|
missing_actions = hash.keys - controller.action_methods.map(&:to_sym)
arr << { controller => missing_actions } if missing_actions.any?
end
end
Expand Down
5 changes: 2 additions & 3 deletions lib/rabarber/core/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
module Rabarber
module Core
class Rule
attr_reader :action, :roles, :context, :dynamic_rule, :negated_dynamic_rule
attr_reader :roles, :context, :dynamic_rule, :negated_dynamic_rule

def initialize(action, roles, context, dynamic_rule, negated_dynamic_rule)
@action = action
def initialize(roles, context, dynamic_rule, negated_dynamic_rule)
@roles = Array(roles)
@context = context
@dynamic_rule = dynamic_rule || -> { true }
Expand Down
2 changes: 1 addition & 1 deletion lib/rabarber/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Rabarber
VERSION = "4.1.0"
VERSION = "4.1.1"
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require_relative "shared_examples"

RSpec.describe AllAccessController, type: :controller do
let(:user) { User.create! }

before { allow(controller).to receive(:current_user).and_return(user) }

describe "when everyone is allowed" do
context "when the user has a role" do
before { user.assign_roles(:maintainer) }

it_behaves_like "it allows access", get: :quux
end

context "when the user has no roles" do
it_behaves_like "it allows access", get: :quux
end

it_behaves_like "it does not allow access when user must have roles", get: :quux
it_behaves_like "it checks permissions integrity", get: :quux
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

require_relative "shared_examples"

RSpec.describe MultipleRulesController, type: :controller do
let(:user) { User.create! }

before { allow(controller).to receive(:current_user).and_return(user) }

describe "when multitple controller rules are applied" do
context "when one of the user's roles allows access" do
before { user.assign_roles(:maintainer) }

it_behaves_like "it allows access", delete: :qux
end

context "when another one of the user's roles allows access" do
before { user.assign_roles(:user, context: Project) }

it_behaves_like "it allows access", delete: :qux
end

context "when the user's role does not allow access" do
before { user.assign_roles(:admin) }

it_behaves_like "it does not allow access", delete: :qux
end

context "when the user does not have any roles" do
it_behaves_like "it does not allow access", delete: :qux
end

it_behaves_like "it does not allow access when user must have roles", delete: :qux
it_behaves_like "it checks permissions integrity", delete: :qux
end
end
10 changes: 5 additions & 5 deletions spec/rabarber/core/access_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@

context "if role has access to the controller" do
before do
allow(permissions.controller_rules[controller]).to receive(:verify_access)
allow(permissions.controller_rules[controller].first).to receive(:verify_access)
.with(user, controller_instance).and_return(true)
end

Expand All @@ -61,7 +61,7 @@

context "if role doesn't have access to the controller" do
before do
allow(permissions.controller_rules[controller]).to receive(:verify_access)
allow(permissions.controller_rules[controller].first).to receive(:verify_access)
.with(user, controller_instance).and_return(false)
end

Expand All @@ -74,7 +74,7 @@

context "if role has access to the controller's parent" do
before do
allow(permissions.controller_rules[DummyParentController]).to receive(:verify_access)
allow(permissions.controller_rules[DummyParentController].first).to receive(:verify_access)
.with(user, controller_instance).and_return(true)
end

Expand All @@ -83,7 +83,7 @@

context "if role doesn't have access to the controller's parent" do
before do
allow(permissions.controller_rules[DummyParentController]).to receive(:verify_access)
allow(permissions.controller_rules[DummyParentController].first).to receive(:verify_access)
.with(user, controller_instance).and_return(false)
end

Expand Down Expand Up @@ -114,7 +114,7 @@

context "if role has access to the action" do
before do
allow(permissions.action_rules[controller].first).to receive(:verify_access)
allow(permissions.action_rules[controller][:index].first).to receive(:verify_access)
.with(user, controller_instance).and_return(true)
end

Expand Down
8 changes: 4 additions & 4 deletions spec/rabarber/core/permissions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@

context "when action is given" do
before do
allow(Rabarber::Core::Rule).to receive(:new).with(:index, [:admin], :context, :dynamic_rule, false).and_return(rule)
allow(Rabarber::Core::Rule).to receive(:new).with([:admin], :context, :dynamic_rule, false).and_return(rule)
end

it "adds permissions to the action rules storage" do
expect { permissions.add(DummyController, :index, [:admin], :context, :dynamic_rule, false) }
.to change { permissions.instance.storage[:action_rules] }
.to({ DummyController => [rule] })
.to({ DummyController => { index: [rule] } })
end
end

context "when no action is given" do
before { allow(Rabarber::Core::Rule).to receive(:new).with(nil, [:admin, :manager], nil, nil, nil).and_return(rule) }
before { allow(Rabarber::Core::Rule).to receive(:new).with([:admin, :manager], nil, nil, nil).and_return(rule) }

it "adds permissions to the controller rules storage" do
expect { permissions.add(DummyController, nil, [:admin, :manager], nil, nil, nil) }
.to change { permissions.instance.storage[:controller_rules] }
.to({ DummyController => rule })
.to({ DummyController => [rule] })
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions spec/rabarber/core/rule_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
subject { rule.verify_access(roleable, DummyController) }

let(:roleable) { double }
let(:rule) { described_class.new(:index, :admin, -> { Project }, -> { true }, nil) }
let(:rule) { described_class.new(:admin, -> { Project }, -> { true }, nil) }

context "if all conditions are met" do
before do
Expand Down Expand Up @@ -40,7 +40,7 @@
describe "#roles_permitted?" do
subject { rule.roles_permitted?(roleable, DummyController.new) }

let(:rule) { described_class.new(:index, roles, { context_type: "Project", context_id: nil }, nil, nil) }
let(:rule) { described_class.new(roles, { context_type: "Project", context_id: nil }, nil, nil) }

context "when roleable is a User" do
let(:roleable) { User.create! }
Expand Down Expand Up @@ -125,7 +125,7 @@
subject { rule.dynamic_rules_followed?(controller_instance) }

let(:controller_instance) { double }
let(:rule) { described_class.new(:index, :manager, nil, dynamic_rule, negated_dynamic_rule) }
let(:rule) { described_class.new(:manager, nil, dynamic_rule, negated_dynamic_rule) }

context "both dynamic rules are empty" do
let(:dynamic_rule) { nil }
Expand Down
8 changes: 8 additions & 0 deletions spec/support/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ class DummyApplication < Rails::Application
end
end

resources :multiple_rules, only: [] do
delete :qux, on: :collection
end

resources :all_access, only: [] do
get :quux, on: :collection
end

resources :no_user, only: [] do
collection do
put :access_with_roles
Expand Down
13 changes: 13 additions & 0 deletions spec/support/controllers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ def baz = head(:ok)
def bad = head(:ok)
end

class MultipleRulesController < ApplicationController
grant_access roles: [:maintainer]
grant_access roles: [:admin, :user], context: Project

def qux = head(:ok)
end

class AllAccessController < ApplicationController
grant_access

def quux = head(:ok)
end

class NoUserController < ApplicationController
grant_access action: :access_with_roles, roles: :admin
def access_with_roles = head(:ok)
Expand Down

0 comments on commit ab920c8

Please sign in to comment.