diff --git a/Gemfile b/Gemfile index 9ba21a26c4..50efe3040d 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,6 @@ source 'https://rubygems.org' gem 'addressable' -gem 'allowy', '>= 2.1.0' gem 'bootsnap', require: false gem 'clockwork', require: false gem 'cloudfront-signer' diff --git a/Gemfile.lock b/Gemfile.lock index e13d48f12e..70e3a57b5a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -71,9 +71,6 @@ GEM aliyun-sdk (0.8.0) nokogiri (~> 1.6) rest-client (~> 2.0) - allowy (2.1.0) - activesupport (>= 3.2) - i18n ast (2.4.3) azure-core (0.1.15) faraday (~> 0.9) @@ -619,7 +616,6 @@ DEPENDENCIES actionview (~> 8.1.1) activemodel (~> 8.1.2) addressable - allowy (>= 2.1.0) azure-storage-blob! bootsnap byebug diff --git a/lib/allowy/LICENSE b/lib/allowy/LICENSE new file mode 100644 index 0000000000..e5f0549ace --- /dev/null +++ b/lib/allowy/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Dmytrii Nagirniak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/allowy/README.md b/lib/allowy/README.md new file mode 100644 index 0000000000..fdce23ab17 --- /dev/null +++ b/lib/allowy/README.md @@ -0,0 +1,41 @@ +# Allowy (Internalized Copy) + +This directory contains an internalized copy of the archived allowy authorization library: +https://github.com/dnagir/allowy + +**License:** MIT License +**Copyright:** (c) 2014 Dmytrii Nagirniak +**Inlined version:** 2.1.0 +**Source commit:** `5d2c6f09a9617a2ad097a3b11ecabb32d48ff80b` (2015-01-06) +**Upstream status:** Archived (last commit: 2015-01-06) + +The upstream LICENSE file is included in this directory. + +## Why Inlined + +- The upstream repository was archived with no updates since 2015 +- Removes external gem dependency +- CCNG only uses a subset of allowy functionality (AccessControl, Context, Registry) + +## Changes from Upstream + +**Files included:** `access_control.rb`, `context.rb`, `registry.rb` (with RuboCop fixes applied) + +**Files skipped (not used by CCNG):** +- `controller_extensions.rb` - Rails helper_method integration +- `matchers.rb` and `rspec.rb` - RSpec `be_able_to` matcher (CCNG uses its own `allow_op_on_object`) +- `version.rb` - version constant + +## Usage in CCNG + +Allowy is used **only by the V2 API** for authorization. This code can be removed together with the V2 API removal. + +Note: If `/v2/info` endpoint is kept after V2 removal, `InfoController` should be refactored to not extend `RestController::BaseController` first. + +The V3 API uses a different authorization system (`VCAP::CloudController::Permissions`). + +## Tests + +```bash +bundle exec rspec spec/unit/lib/allowy/ +``` diff --git a/lib/allowy/access_control.rb b/lib/allowy/access_control.rb new file mode 100644 index 0000000000..e08dae09e4 --- /dev/null +++ b/lib/allowy/access_control.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Inlined from https://github.com/dnagir/allowy +# See lib/allowy/README.md for details + +module Allowy + # This module provides the interface for implementing the access control actions. + # In order to use it, mix it into a plain Ruby class and define methods ending with `?`. + # + # @example + # class PageAccess + # include Allowy::AccessControl + # + # def view?(page) + # page and page.wiki? and context.user_signed_in? + # end + # end + # + # And then you can check the permissions from a controller: + # + # @example + # def show + # @page = Page.find params[:id] + # authorize! :view, @page + # end + # + module AccessControl + extend ActiveSupport::Concern + + included do + attr_reader :context + end + + def initialize(ctx) + @context = ctx + end + + def can?(action, subject, *params) + allowing, _payload = check_permission(action, subject, *params) + allowing + end + + def cannot?(*) + !can?(*) + end + + def authorize!(action, subject, *params) + allowing, payload = check_permission(action, subject, *params) + raise AccessDenied.new('Not authorized', action, subject, payload) unless allowing + end + + def deny!(payload) + throw(:deny, payload) + end + + private + + def check_permission(action, subject, *params) + m = "#{action}?" + raise UndefinedAction.new("The #{self.class.name} needs to have #{m} method. Please define it.") unless respond_to?(m) + + allowing = false + payload = catch(:deny) { allowing = send(m, subject, *params) } + [allowing, payload] + end + end +end diff --git a/lib/allowy/allowy.rb b/lib/allowy/allowy.rb new file mode 100644 index 0000000000..f7831f60d6 --- /dev/null +++ b/lib/allowy/allowy.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Inlined from https://github.com/dnagir/allowy +# See lib/allowy/README.md for details + +require 'active_support' +require 'active_support/core_ext' +require 'active_support/concern' +require 'active_support/inflector' + +require 'allowy/access_control' +require 'allowy/registry' +require 'allowy/context' + +module Allowy + class UndefinedAccessControl < StandardError; end + class UndefinedAction < StandardError; end + + class AccessDenied < StandardError + attr_reader :action, :subject, :payload + + def initialize(message, action, subject, payload=nil) + super(message) + @action = action + @subject = subject + @payload = payload + end + end +end diff --git a/lib/allowy/context.rb b/lib/allowy/context.rb new file mode 100644 index 0000000000..2259367ff6 --- /dev/null +++ b/lib/allowy/context.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Inlined from https://github.com/dnagir/allowy +# See lib/allowy/README.md for details + +module Allowy + # This module provides the default and common context for checking the permissions. + # It is mixed into controllers and provides an easy way to reuse it + # in other parts of the application (RSpec, Cucumber or standalone). + # + # @example + # class MyContext + # include Allowy::Context + # attr_accessor :current_user + # + # def initialize(user) + # @current_user = user + # end + # end + # + # And then you can easily check the permissions like so: + # + # @example + # MyContext.new(that_user).can?(:create, Blog) + # + module Context + extend ActiveSupport::Concern + + def allowy_context + self + end + + def current_allowy + @current_allowy ||= ::Allowy::Registry.new(allowy_context) + end + + def can?(action, subject, *) + current_allowy.access_control_for!(subject).can?(action, subject, *) + end + + def cannot?(action, subject, *) + current_allowy.access_control_for!(subject).cannot?(action, subject, *) + end + + def authorize!(action, subject, *) + current_allowy.access_control_for!(subject).authorize!(action, subject, *) + end + end +end diff --git a/lib/allowy/registry.rb b/lib/allowy/registry.rb new file mode 100644 index 0000000000..143adf7c75 --- /dev/null +++ b/lib/allowy/registry.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Inlined from https://github.com/dnagir/allowy +# See lib/allowy/README.md for details + +module Allowy + # Registry maps objects to their corresponding Access classes. + # Given a Space object, it finds SpaceAccess class automatically. + class Registry + def initialize(ctx, options={}) + options.assert_valid_keys(:access_suffix) + @context = ctx + @registry = {} + @options = options + end + + def access_control_for!(subject) + ac = access_control_for(subject) + raise UndefinedAccessControl.new("Please define Access Control class for #{subject.inspect}") unless ac + + ac + end + + def access_control_for(subject) + # Try subject as decorated object + clazz = class_for(subject.class.source_class.name) if subject.class.respond_to?(:source_class) + + # Try subject as an object + clazz ||= class_for(subject.class.name) + + # Try subject as a class + clazz = class_for(subject.name) if !clazz && subject.is_a?(Class) + + return unless clazz + + # create a new instance or return existing + @registry[clazz] ||= clazz.new(@context) + end + + private + + def class_for(name) + "#{name}#{access_suffix}".safe_constantize + end + + def access_suffix + @options.fetch(:access_suffix, 'Access') + end + end +end diff --git a/lib/cloud_controller.rb b/lib/cloud_controller.rb index ba8297fdc4..2ded100bf0 100644 --- a/lib/cloud_controller.rb +++ b/lib/cloud_controller.rb @@ -3,7 +3,7 @@ require 'oj' require 'delayed_job' -require 'allowy' +require 'allowy/allowy' require 'uaa/token_coder' diff --git a/spec/spec_helper_helper.rb b/spec/spec_helper_helper.rb index 841ff201d9..1197a35fe5 100644 --- a/spec/spec_helper_helper.rb +++ b/spec/spec_helper_helper.rb @@ -30,7 +30,6 @@ def self.init require 'pry' require 'cloud_controller' - require 'allowy/rspec' require 'rspec_api_documentation' require 'services' diff --git a/spec/unit/lib/allowy/access_control_spec.rb b/spec/unit/lib/allowy/access_control_spec.rb new file mode 100644 index 0000000000..b1ba33f22c --- /dev/null +++ b/spec/unit/lib/allowy/access_control_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative 'allowy_spec_helper' + +module Allowy + RSpec.describe 'checking permissions' do + let(:access) { SampleAccess.new(123) } + + describe '#context as an arbitrary object' do + subject { access.context } + + it 'returns the context passed to initialize' do + expect(subject.to_s).to eq('123') + end + + it 'context is not zero' do + expect(subject.zero?).to be(false) + end + + it 'can access the context in permission check' do + expect(access.can?(:context_is_123, nil)).to be(true) + end + end + + describe '#can?' do + it 'returns true when permission allows' do + expect(access.can?(:read, 'allow')).to be(true) + end + + it 'returns false when permission denies' do + expect(access.can?(:read, 'deny')).to be(false) + end + end + + describe '#cannot?' do + it 'returns false when permission allows' do + expect(access.cannot?(:read, 'allow')).to be(false) + end + + it 'returns true when permission denies' do + expect(access.cannot?(:read, 'deny')).to be(true) + end + end + + it 'passes extra parameters' do + expect(access.can?(:extra_params, 'same', bar: 'same')).to be(true) + end + + it 'denies with early termination' do + expect(access.can?(:early_deny, 'foo')).to be(false) + expect(access.can?(:early_deny, 'xx')).to be(false) + end + + it 'raises if no permission defined' do + expect { access.can?(:write, 'allow') }.to raise_error(UndefinedAction) do |err| + expect(err.message).to include('write?') + end + end + + describe '#authorize!' do + it 'raises AccessDenied when not authorized' do + expect { access.authorize!(:read, 'deny') }.to raise_error(AccessDenied) do |err| + expect(err.message).not_to be_empty + expect(err.action).to eq(:read) + expect(err.subject).to eq('deny') + end + end + + it 'does not raise when authorized' do + expect { access.authorize!(:read, 'allow') }.not_to raise_error + end + + it 'raises with payload on early termination' do + expect { access.authorize!(:early_deny, 'subject') }.to raise_error(AccessDenied) do |err| + expect(err.message).not_to be_empty + expect(err.action).to eq(:early_deny) + expect(err.subject).to eq('subject') + expect(err.payload).to eq('early terminate: subject') + end + end + end + end +end diff --git a/spec/unit/lib/allowy/allowy_spec_helper.rb b/spec/unit/lib/allowy/allowy_spec_helper.rb new file mode 100644 index 0000000000..829a7895bb --- /dev/null +++ b/spec/unit/lib/allowy/allowy_spec_helper.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Lightweight spec helper for allowy tests +# Does not require database connection or full CCNG stack + +require 'rspec' +require 'allowy/allowy' + +# Test fixtures +class SampleAccess + include Allowy::AccessControl + + def read?(str) + str == 'allow' + end + + def early_deny?(str) + deny!("early terminate: #{str}") + end + + def extra_params?(foo, *opts) + foo == opts.last[:bar] + end + + def context_is_123?(*_whatever) + context == 123 + end +end + +class SamplePermission + include Allowy::AccessControl +end + +class Sample + attr_accessor :name +end diff --git a/spec/unit/lib/allowy/context_spec.rb b/spec/unit/lib/allowy/context_spec.rb new file mode 100644 index 0000000000..b401b31d16 --- /dev/null +++ b/spec/unit/lib/allowy/context_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative 'allowy_spec_helper' + +module Allowy + class SampleContext + include Context + end + + RSpec.describe Context do + subject(:sample_context) { SampleContext.new } + + let(:access) { double('Access') } + + it 'creates a registry' do + expect(Registry).to receive(:new).with(sample_context).and_call_original + sample_context.current_allowy + end + + it 'checks using can?' do + expect(sample_context.current_allowy).to receive(:access_control_for!).with(123).and_return(access) + expect(access).to receive(:can?).with(:edit, 123) + sample_context.can?(:edit, 123) + end + + it 'checks using cannot?' do + expect(sample_context.current_allowy).to receive(:access_control_for!).with(123).and_return(access) + expect(access).to receive(:cannot?).with(:edit, 123) + sample_context.cannot?(:edit, 123) + end + + it 'calls authorize!' do + expect(access).to receive(:authorize!).with(:edit, 123) + allow(sample_context.current_allowy).to receive(:access_control_for!).and_return(access) + sample_context.authorize!(:edit, 123) + end + end +end diff --git a/spec/unit/lib/allowy/registry_spec.rb b/spec/unit/lib/allowy/registry_spec.rb new file mode 100644 index 0000000000..d372da3e28 --- /dev/null +++ b/spec/unit/lib/allowy/registry_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative 'allowy_spec_helper' + +module Allowy + RSpec.describe Registry do + let(:context) { 123 } + + subject(:registry) { described_class.new(context) } + + describe '#access_control_for!' do + it 'finds AC by appending Access to the subject' do + expect(registry.access_control_for!(Sample.new)).to be_a(SampleAccess) + end + + it 'finds AC by appending custom suffix to the subject' do + custom_registry = described_class.new(context, access_suffix: 'Permission') + expect(custom_registry.access_control_for!(Sample.new)).to be_a(SamplePermission) + end + + it 'raises on invalid option' do + expect { described_class.new(context, foo: 'incorrect option') }.to raise_error(/unknown key/i) + end + + it 'finds AC when the subject is a class' do + expect(registry.access_control_for!(Sample)).to be_a(SampleAccess) + end + + it 'raises when AC is not found by the subject' do + expect { registry.access_control_for!(123) }.to raise_error(UndefinedAccessControl) do |err| + expect(err.message).to include('123') + end + end + + it 'raises when subject is nil' do + expect { registry.access_control_for!(nil) }.to raise_error(UndefinedAccessControl) + end + + it 'returns NilClassAccess when subject is nil and NilClassAccess is defined' do + nil_class_access = Class.new do + include Allowy::AccessControl + end + stub_const('NilClassAccess', nil_class_access) + expect(registry.access_control_for!(nil)).to be_a(nil_class_access) + end + + it 'returns the same AC instance' do + first = registry.access_control_for!(Sample) + second = registry.access_control_for!(Sample) + expect(first).to equal(second) + end + + it 'supports objects that provide source_class method (such as Draper)' do + decorator_class = Class.new do + def self.source_class + Sample + end + end + decorated_object = decorator_class.new + expect(registry.access_control_for!(decorated_object)).to be_a(SampleAccess) + end + end + end +end