diff --git a/Gemfile b/Gemfile index 7e73359b..bda8d670 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gem 'pg', '>= 0.18', '< 2.0' gem 'puma', '~> 4.3' gem 'panoptes-client' +gem 'pundit' # jsonapi.rb is a bundle that incorporates fast_jsonapi (serialization), # ransack (filtration), and some RSpec matchers along with some @@ -26,8 +27,6 @@ group :development, :test do gem 'rspec-rails' gem 'factory_bot_rails' gem 'jsonapi-rspec', require: false - gem 'pry-byebug' - gem 'rspec-rails' end group :development do @@ -38,6 +37,7 @@ end group :test do gem 'simplecov' + gem 'pundit-matchers' end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index b3faf4c7..9517218f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -147,6 +147,10 @@ GEM pry (>= 0.10.4) puma (4.3.1) nio4r (~> 2.0) + pundit (2.1.0) + activesupport (>= 3.0.0) + pundit-matchers (1.6.0) + rspec-rails (>= 3.0.0) rack (2.0.8) rack-cors (1.1.0) rack (>= 2.0.0) @@ -230,7 +234,7 @@ GEM sprockets (>= 3.0.0) term-ansicolor (1.7.1) tins (~> 1.0) - thor (0.20.3) + thor (1.0.1) thread_safe (0.3.6) tins (1.22.2) tzinfo (1.2.5) @@ -258,6 +262,8 @@ DEPENDENCIES pry-byebug pry-rails puma (~> 4.3) + pundit + pundit-matchers rack-cors rails (~> 6.0.1) rspec-rails diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e5f080cc..4dd39880 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,8 +1,12 @@ class ApplicationController < ActionController::Base + include Pundit + protect_from_forgery unless: -> { request.format.json? } attr_reader :current_user, :auth_token before_action :set_user + after_action :verify_authorized, except: :index + after_action :verify_policy_scoped, only: :index include ErrorExtender include JSONAPI::Pagination diff --git a/app/controllers/concerns/error_extender.rb b/app/controllers/concerns/error_extender.rb index 0c9093ca..3f0c09e1 100644 --- a/app/controllers/concerns/error_extender.rb +++ b/app/controllers/concerns/error_extender.rb @@ -6,6 +6,7 @@ module ErrorExtender rescue_from ActionController::BadRequest, with: :render_jsonapi_bad_request rescue_from ActiveModel::UnknownAttributeError, with: :render_jsonapi_unknown_attribute rescue_from Panoptes::Client::AuthenticationExpired, with: :render_jsonapi_token_expired + rescue_from Pundit::NotAuthorizedError, with: :render_jsonapi_not_authorized end def report_to_sentry(exception) @@ -22,8 +23,7 @@ def report_to_sentry(exception) # Overriding this JSONAPI::Errors method to add Sentry reporting def render_jsonapi_internal_server_error(exception) report_to_sentry(exception) - error = { status: '500', title: Rack::Utils::HTTP_STATUS_CODES[500] } - render jsonapi_errors: [error], status: :internal_server_error + super(exception) end def render_jsonapi_bad_request(exception) @@ -45,4 +45,9 @@ def render_jsonapi_unknown_attribute(exception) render jsonapi_errors: [error], status: :unprocessable_entity end + + def render_jsonapi_not_authorized + error = { status: '403', title: Rack::Utils::HTTP_STATUS_CODES[403] } + render jsonapi_errors: [error], status: :forbidden + end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 93361274..0cbc70f3 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,11 +1,12 @@ class ProjectsController < ApplicationController def index - @projects = Project.all + @projects = policy_scope(Project) jsonapi_render(@projects, allowed_filters) end def show @project = Project.find(params[:id]) + authorize @project render jsonapi: @project end diff --git a/app/controllers/status_controller.rb b/app/controllers/status_controller.rb index e2a455db..098a0097 100644 --- a/app/controllers/status_controller.rb +++ b/app/controllers/status_controller.rb @@ -1,5 +1,6 @@ class StatusController < ApplicationController def show + skip_authorization @status = ApplicationStatus.new render json: @status end diff --git a/app/controllers/transcriptions_controller.rb b/app/controllers/transcriptions_controller.rb index b194e84a..a7f51297 100644 --- a/app/controllers/transcriptions_controller.rb +++ b/app/controllers/transcriptions_controller.rb @@ -4,19 +4,27 @@ class TranscriptionsController < ApplicationController before_action :status_filter_to_int, only: :index def index - @transcriptions = Transcription.all + @transcriptions = policy_scope(Transcription) jsonapi_render(@transcriptions, allowed_filters) end - def update + def show @transcription = Transcription.find(params[:id]) - raise ActionController::BadRequest if type_invalid? - @transcription.update!(update_attrs) + authorize @transcription render jsonapi: @transcription end - def show + def update @transcription = Transcription.find(params[:id]) + raise ActionController::BadRequest if type_invalid? + + if approve? + authorize @transcription, :approve? + else + authorize @transcription + end + + @transcription.update!(update_attrs) render jsonapi: @transcription end @@ -26,25 +34,25 @@ def update_attrs jsonapi_deserialize(params) end - # jsonapi.rb filtering doesn't handle filtering by enum term (e.g. 'ready'), + # Ransack filtering doesn't handle filtering by enum term (e.g. 'ready'), # so we must translate status terms to their integer value if they're present def status_filter_to_int if params[:filter] params[:filter].each do |key, value| - # filter key is comprised of _ + # filter key is comprised of _ # e.g. id_eq, status_in, etc - check if filter term is status if key.split('_').first == 'status' # split status terms in case there is a list of them status_terms = value.split(',') status_enum_values = [] - + # for each status term, try to convert to enum value, # and add to list of converted enum values status_terms.each do |term| enum_value = Transcription.statuses[term] status_enum_values.append(enum_value.to_s) if enum_value end - + # if list of converted enum values is not empty, # update params to reflect converted values unless status_enum_values.empty? @@ -59,6 +67,10 @@ def type_invalid? params[:data][:type] != "transcriptions" end + def approve? + update_attrs["status"] == "approved" + end + def allowed_filters [:id, :workflow_id, :group_id, :flagged, :status] end diff --git a/app/controllers/workflows_controller.rb b/app/controllers/workflows_controller.rb index 8647fe5c..c9158d6a 100644 --- a/app/controllers/workflows_controller.rb +++ b/app/controllers/workflows_controller.rb @@ -1,11 +1,12 @@ class WorkflowsController < ApplicationController def index - @workflows = Workflow.all + @workflows = policy_scope(Workflow) jsonapi_render(@workflows, allowed_filters) end def show @workflow = Workflow.find(params[:id]) + authorize @workflow render jsonapi: @workflow end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 00000000..964952d2 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,37 @@ +class ApplicationPolicy + attr_reader :user, :records, :role_checker + + def initialize(user, records) + @user = user + @records = Array.wrap(records) + raise Pundit::NotAuthorizedError, "must be logged in to Panoptes" unless logged_in? + @role_checker = ProjectRoleChecker.new(user, @records) + end + + def index? + admin? || (logged_in? && viewer?) + end + + def show? + admin? || (logged_in? && viewer?) + end + + def admin? + logged_in? && user.admin + end + + def logged_in? + !!user + end + + class Scope + attr_reader :user, :scope, :role_checker + + def initialize(user, scope) + raise Pundit::NotAuthorizedError, "must be logged in to Panoptes" unless user + @user = user + @scope = scope + @role_checker = ProjectRoleChecker.new(user, scope) + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb new file mode 100644 index 00000000..11d3ac55 --- /dev/null +++ b/app/policies/project_policy.rb @@ -0,0 +1,27 @@ +class ProjectPolicy < ApplicationPolicy + def editor? + role_checker.can_edit? + end + + def approver? + role_checker.can_approve? + end + + def viewer? + role_checker.can_view? + end + + class Scope < Scope + def resolve + if user.admin? + scope.all + else + viewer_policy_scope + end + end + + def viewer_policy_scope + scope.where id: role_checker.viewer_project_ids + end + end +end \ No newline at end of file diff --git a/app/policies/transcription_policy.rb b/app/policies/transcription_policy.rb new file mode 100644 index 00000000..1fff2966 --- /dev/null +++ b/app/policies/transcription_policy.rb @@ -0,0 +1,38 @@ +class TranscriptionPolicy < ApplicationPolicy + delegate :editor?, :approver?, :viewer?, to: :project_policy + + def update? + has_update_rights? + end + + def approve? + if has_update_rights? + approver? || admin? + else + false + end + end + + def has_update_rights? + admin? || (logged_in? && editor?) + end + + def project_policy + workflow_ids = records.map(&:workflow_id).uniq + ProjectPolicy.new(user, Project.joins(:workflows).where(workflows: { id: workflow_ids }).distinct) + end + + class Scope < Scope + def resolve + viewer_policy_scope + end + + def viewer_policy_scope + if user.admin? + scope.all + elsif user + scope.joins(:workflow).where(workflows: { project_id: role_checker.viewer_project_ids } ) + end + end + end +end \ No newline at end of file diff --git a/app/policies/workflow_policy.rb b/app/policies/workflow_policy.rb new file mode 100644 index 00000000..4023e0bc --- /dev/null +++ b/app/policies/workflow_policy.rb @@ -0,0 +1,21 @@ +class WorkflowPolicy < ApplicationPolicy + delegate :editor?, :approver?, :viewer?, to: :project_policy + + def project_policy + ProjectPolicy.new(user, Project.where(id: records.pluck(:project_id).uniq)) + end + + class Scope < Scope + def resolve + viewer_policy_scope + end + + def viewer_policy_scope + if user.admin? + scope.all + elsif user + scope.joins(:project).where project_id: role_checker.viewer_project_ids + end + end + end +end diff --git a/app/lib/panoptes_api.rb b/app/services/panoptes_api.rb similarity index 95% rename from app/lib/panoptes_api.rb rename to app/services/panoptes_api.rb index cb1cef1b..70eb69aa 100644 --- a/app/lib/panoptes_api.rb +++ b/app/services/panoptes_api.rb @@ -18,7 +18,7 @@ def roles(user_id) { }.tap do |roles| response = get_roles(user_id) response['project_roles'].map do |role| - project_id = role['links']['project'].to_i + project_id = role['links']['project'] roles[project_id] ||= [] roles[project_id] |= role['roles'] end diff --git a/app/services/project_role_checker.rb b/app/services/project_role_checker.rb new file mode 100644 index 00000000..5e223aff --- /dev/null +++ b/app/services/project_role_checker.rb @@ -0,0 +1,51 @@ +class ProjectRoleChecker + attr_reader :user, :records, :viewer_project_ids + + EDITOR_ROLES = %w(owner collaborator expert scientist moderator) + APPROVER_ROLES = %w(owner collaborator) + VIEWER_ROLES = %w(owner collaborator expert scientist tester) + + def initialize(user, records) + @user = user + @records = records + @viewer_project_ids = get_viewer_project_ids + end + + def can_edit? + ids = user_project_ids(user.roles, EDITOR_ROLES) + check_roles(ids, records) + end + + def can_approve? + ids = user_project_ids(user.roles, APPROVER_ROLES) + check_roles(ids, records) + end + + def can_view? + ids = user_project_ids(user.roles, VIEWER_ROLES) + check_roles(ids, records) + end + + def get_viewer_project_ids + user_project_ids(user.roles, VIEWER_ROLES) + end + + private + + def user_project_ids(user_roles, allowed_roles) + allowed_role_ids = [] + user_roles.each do |id, roles| + if (roles & allowed_roles).any? + allowed_role_ids << id + end + end + allowed_role_ids + end + + def check_roles(project_ids, records) + return false if records.empty? + records.all? do |record| + project_ids.include? record.id.to_s + end + end +end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 753365a7..340dcea7 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -1,5 +1,8 @@ RSpec.describe ProjectsController, type: :controller do + let(:admin_user) { create :user, :admin } + before { allow(controller).to receive(:current_user).and_return admin_user } + describe '#index' do let!(:project) { create(:project, slug: "userone/projectone") } let!(:another_project) { create(:project, slug: "usertwo/projecttwo") } @@ -28,19 +31,71 @@ expect(json_data.first).to have_id(another_project.id.to_s) end end + + describe 'roles' do + before { allow(controller).to receive(:current_user).and_return user } + + context 'without any roles' do + let(:user) { create(:user, roles: {} )} + it "returns return an empty response" do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(0) + end + end + + context 'as an admin' do + let(:user) { create(:user, :admin) } + it 'returns the full scope' do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(2) + end + end + + context 'as a viewer' do + let(:user) { create(:user, roles: {project.id => ['tester']}) } + it 'returns the authorized project' do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(1) + expect(json_data.first["id"]).to eql(project.id.to_s) + end + end + end end describe '#show' do let!(:project) { create(:project, slug: "userone/projectone") } - it 'returns successfully' do - get :show, params: { id: project.id } - expect(response).to have_http_status(:ok) - end + describe 'roles' do + before do + allow(controller).to receive(:current_user).and_return user + get :show, params: { id: project.id } + end + + context 'without any roles' do + let(:user) { create(:user, roles: {} )} + it "returns a 403" do + expect(response).to have_http_status(:forbidden) + end + end + + context 'as an admin' do + let(:user) { create(:user, :admin) } + it 'returns the full scope' do + expect(response).to have_http_status(:ok) + expect(json_data).to have_id(project.id.to_s) + end + end - it 'renders the requested project' do - get :show, params: { id: project.id } - expect(json_data).to have_id(project.id.to_s) + context 'as a viewer' do + let(:user) { create(:user, roles: {project.id => ['tester']}) } + it 'returns the authorized project' do + expect(response).to have_http_status(:ok) + expect(json_data["id"]).to eql(project.id.to_s) + end + end end end end diff --git a/spec/controllers/transcriptions_controller_spec.rb b/spec/controllers/transcriptions_controller_spec.rb index ed84adeb..187c101e 100644 --- a/spec/controllers/transcriptions_controller_spec.rb +++ b/spec/controllers/transcriptions_controller_spec.rb @@ -1,4 +1,6 @@ RSpec.describe TranscriptionsController, type: :controller do + let(:admin_user) { create :user, :admin } + before { allow(controller).to receive(:current_user).and_return admin_user } describe '#index' do let!(:transcription) { create(:transcription, status: 1) } @@ -9,16 +11,38 @@ let(:another) { another_transcription } end - it "returns http success" do - get :index - expect(response).to have_http_status(:ok) - end + describe 'roles' do + before { allow(controller).to receive(:current_user).and_return user } + + context 'without any roles' do + let(:user) { create(:user, roles: {} )} + it "returns return an empty response" do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(0) + end + end + + context 'as an admin' do + let(:user) { create(:user, :admin) } + it 'returns the full scope' do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(3) + end + end - it 'should render' do - get :index - expect(json_data.first).to have_type('transcription') - expect(json_data.first).to have_attributes(:text, :status, :flagged) - expect(json_data.first["id"]).to eql(transcription.id.to_s) + context 'as a viewer' do + let(:user) { create(:user, roles: {transcription.workflow.project.id => ['tester']}) } + it 'returns the authorized workflow' do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.first).to have_type('transcription') + expect(json_data.first).to have_attributes(:text, :status, :flagged) + expect(json_data.first["id"]).to eql(transcription.id.to_s) + expect(json_data.size).to eq(2) + end + end end describe "filtration" do @@ -84,14 +108,34 @@ describe '#show' do let!(:transcription) { create(:transcription) } - it 'returns successfully' do - get :show, params: { id: transcription.id } - expect(response).to have_http_status(:ok) - end + describe 'roles' do + before do + allow(controller).to receive(:current_user).and_return user + get :show, params: { id: transcription.id } + end - it 'renders the requested transcription' do - get :show, params: { id: transcription.id } - expect(json_data).to have_id(transcription.id.to_s) + context 'without any roles' do + let(:user) { create(:user, roles: {} )} + it "returns a 403" do + expect(response).to have_http_status(:forbidden) + end + end + + context 'as an admin' do + let(:user) { create(:user, :admin) } + it 'returns the full scope' do + expect(response).to have_http_status(:ok) + expect(json_data).to have_id(transcription.id.to_s) + end + end + + context 'as a viewer' do + let(:user) { create(:user, roles: {transcription.workflow.project.id => ['tester']}) } + it 'returns the authorized workflow' do + expect(response).to have_http_status(:ok) + expect(json_data["id"]).to eql(transcription.id.to_s) + end + end end end @@ -129,5 +173,73 @@ expect(response).to have_http_status(:unprocessable_entity) end end + + describe 'roles' do + before { allow(controller).to receive(:current_user).and_return user } + + context 'without any roles' do + let(:user) { create(:user, roles: {} )} + it "returns a 403 Forbidden" do + patch :update, params: update_params + expect(response).to have_http_status(:forbidden) + end + end + + context 'as an admin' do + let(:user) { create(:user, :admin) } + it 'updates the resource' do + patch :update, params: update_params + expect(response).to have_http_status(:ok) + end + + it 'allows approval' do + update_params[:data][:attributes][:status] = "approved" + patch :update, params: update_params + expect(response).to have_http_status(:ok) + end + end + + context 'as an approver' do + let(:user) { create(:user, roles: { transcription.workflow.project.id => ['owner']}) } + it 'updates the resource' do + patch :update, params: update_params + expect(response).to have_http_status(:ok) + end + + it 'allows approval' do + update_params[:data][:attributes][:status] = "approved" + patch :update, params: update_params + expect(response).to have_http_status(:ok) + end + end + + context 'as an editor' do + let(:user) { create(:user, roles: { transcription.workflow.project.id => ['scientist']}) } + it 'updates the resource' do + patch :update, params: update_params + expect(response).to have_http_status(:ok) + end + + it 'forbids approval' do + update_params[:data][:attributes][:status] = "approved" + patch :update, params: update_params + expect(response).to have_http_status(:forbidden) + end + end + + context 'as a viewer' do + let(:user) { create(:user, roles: { transcription.workflow.project.id => ['tester']}) } + it 'returns a 403 Forbidden' do + patch :update, params: update_params + expect(response).to have_http_status(:forbidden) + end + + it 'forbids approval' do + update_params[:data][:attributes][:status] = "approved" + patch :update, params: update_params + expect(response).to have_http_status(:forbidden) + end + end + end end end diff --git a/spec/controllers/workflows_controller_spec.rb b/spec/controllers/workflows_controller_spec.rb index 2a31fbb4..906cc08e 100644 --- a/spec/controllers/workflows_controller_spec.rb +++ b/spec/controllers/workflows_controller_spec.rb @@ -1,5 +1,8 @@ RSpec.describe WorkflowsController, type: :controller do + let(:admin_user) { create :user, :admin } + before { allow(controller).to receive(:current_user).and_return admin_user } + describe '#index' do let!(:workflow) { create(:workflow) } let!(:another_workflow) { create(:workflow, display_name: "honkhonk") } @@ -61,19 +64,71 @@ expect(json_data.first).to have_id(another_workflow.id.to_s) end end + + describe 'roles' do + before { allow(controller).to receive(:current_user).and_return user } + + context 'without any roles' do + let(:user) { create(:user, roles: {} )} + it "returns return an empty response" do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(0) + end + end + + context 'as an admin' do + let(:user) { create(:user, :admin) } + it 'returns the full scope' do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(2) + end + end + + context 'as a viewer' do + let(:user) { create(:user, roles: {workflow.project.id => ['tester']}) } + it 'returns the authorized workflow' do + get :index + expect(response).to have_http_status(:ok) + expect(json_data.size).to eq(1) + expect(json_data.first["id"]).to eql(workflow.id.to_s) + end + end + end end describe '#show' do let!(:workflow) { create(:workflow) } - it 'returns successfully' do - get :show, params: { id: workflow.id } - expect(response).to have_http_status(:ok) - end + describe 'roles' do + before do + allow(controller).to receive(:current_user).and_return user + get :show, params: { id: workflow.id } + end + + context 'without any roles' do + let(:user) { create(:user, roles: {} )} + it "returns a 403" do + expect(response).to have_http_status(:forbidden) + end + end + + context 'as an admin' do + let(:user) { create(:user, :admin) } + it 'returns the full scope' do + expect(response).to have_http_status(:ok) + expect(json_data).to have_id(workflow.id.to_s) + end + end - it 'renders the requested workflow' do - get :show, params: { id: workflow.id } - expect(json_data).to have_id(workflow.id.to_s) + context 'as a viewer' do + let(:user) { create(:user, roles: {workflow.project.id => ['tester']}) } + it 'returns the authorized workflow' do + expect(response).to have_http_status(:ok) + expect(json_data["id"]).to eql(workflow.id.to_s) + end + end end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 62961d1c..312c9b27 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -5,6 +5,7 @@ trait :admin do admin { true } + roles { {} } end end end diff --git a/spec/policies/application_policy_spec.rb b/spec/policies/application_policy_spec.rb new file mode 100644 index 00000000..e7d10d3d --- /dev/null +++ b/spec/policies/application_policy_spec.rb @@ -0,0 +1,25 @@ +RSpec.describe ApplicationPolicy, type: :policy do + let(:records){ [] } + let(:user) { create(:user, roles: {}) } + let(:policy){ ApplicationPolicy.new user, records } + + context 'with a user' do + it 'acts logged in' do + expect(policy.logged_in?).to be true + expect(policy.admin?).to be false + end + + it 'acts like an admin' do + user.admin = true + new_policy = ApplicationPolicy.new user, records + expect(policy.logged_in?).to be true + expect(policy.admin?).to be true + end + end + + context 'without a user' do + it 'is not logged in' do + expect{ApplicationPolicy.new(nil, records)}.to raise_error Pundit::NotAuthorizedError + end + end +end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb new file mode 100644 index 00000000..b5480dfb --- /dev/null +++ b/spec/policies/project_policy_spec.rb @@ -0,0 +1,46 @@ +RSpec.describe ProjectPolicy, type: :policy do + let(:user) { create(:user, roles: {}) } + + permissions :index?, :show? do + let!(:records) { create :project } + + it 'permits viewers' do + user.roles = {records.id => ['tester'] } + expect(described_class).to permit(user, records) + end + + it 'permits admins' do + user.admin = true + expect(described_class).to permit(user, records) + end + + it 'forbids unauthorized users' do + expect(described_class).not_to permit(user, records) + end + + it 'forbids unauthorized requests' do + user = nil + expect{described_class.new(user, records)}.to raise_error(Pundit::NotAuthorizedError) + end + end + + describe "Scope" do + let!(:projects) {create_list(:project, 2)} + let(:scope) { Pundit.policy_scope!(user, Project) } + + context 'viewer' do + let(:user) { create(:user, roles: { projects[0].id => ['tester'] }) } + it 'allows a limited subset' do + expect(scope.to_a).to include(projects[0]) + expect(scope.to_a).not_to include(projects[1]) + end + end + + context 'admin' do + let(:user) { create(:user, :admin) } + it 'returns all projects' do + expect(scope.to_a).to include(projects[0], projects[1]) + end + end + end +end diff --git a/spec/policies/transcription_policy_spec.rb b/spec/policies/transcription_policy_spec.rb new file mode 100644 index 00000000..7b48ae20 --- /dev/null +++ b/spec/policies/transcription_policy_spec.rb @@ -0,0 +1,76 @@ +RSpec.describe TranscriptionPolicy, type: :policy do + let(:user) { create(:user, roles: {}) } + + permissions :index?, :show? do + let(:records) { create(:transcription) } + + it 'permits viewers' do + user.roles = {records.workflow.project.id => ['tester'] } + expect(described_class).to permit(user, records) + end + + it 'permits admins' do + user.admin = true + expect(described_class).to permit(user, records) + end + + it 'forbids unauthorized users' do + expect(described_class).not_to permit(user, records) + end + + it 'forbids unauthenticated requests' do + user = nil + expect{described_class.new(user, records)}.to raise_error(Pundit::NotAuthorizedError) + end + end + + permissions :update? do + let(:records) { create(:transcription) } + + it 'permits admins' do + user.admin = true + expect(described_class).to permit(user, records) + end + + it 'permits editors' do + user.roles = {records.workflow.project.id => ['expert'] } + expect(described_class).to permit(user, records) + end + + it 'forbids viewers' do + user.roles = {records.workflow.project.id => ['tester'] } + expect(described_class).not_to permit(user, records) + end + + it 'forbids unauthorized users' do + expect(described_class).not_to permit(user, records) + end + + it 'forbids unauthenticated requests' do + user = nil + expect{described_class.new(user, records)}.to raise_error(Pundit::NotAuthorizedError) + end + + end + + describe "Scope" do + let!(:transcription) { create(:transcription)} + let!(:another_transcription) { create(:transcription)} + let(:scope) { Pundit.policy_scope!(user, Transcription) } + + context 'viewer' do + let(:user) { create(:user, roles: { transcription.workflow.project.id => ['tester'] }) } + it 'allows a limited subset' do + expect(scope.to_a).to include(transcription) + expect(scope.to_a).not_to include(another_transcription) + end + end + + context 'admin' do + let(:user) { create(:user, :admin) } + it 'returns all workflows' do + expect(scope.to_a).to include(transcription, another_transcription) + end + end + end +end diff --git a/spec/policies/workflow_policy_spec.rb b/spec/policies/workflow_policy_spec.rb new file mode 100644 index 00000000..b9a7c9a9 --- /dev/null +++ b/spec/policies/workflow_policy_spec.rb @@ -0,0 +1,49 @@ +RSpec.describe WorkflowPolicy, type: :policy do + let(:user) { create(:user, roles: {}) } + + permissions :index?, :show? do + let(:project) { create(:project) } + let(:records) { create(:workflow, project: project)} + + it 'permits viewers' do + user.roles = {project.id => ['tester'] } + expect(described_class).to permit(user, records) + end + + it 'permits admins' do + user.admin = true + expect(described_class).to permit(user, records) + end + + it 'forbids unauthorized users' do + expect(described_class).not_to permit(user, records) + end + + it 'forbids unauthorized requests' do + user = nil + expect{described_class.new(user, records)}.to raise_error(Pundit::NotAuthorizedError) + end + end + + describe "Scope" do + let!(:workflow) { create(:workflow) } + let!(:another_workflow) { create(:workflow) } + + let(:scope) { Pundit.policy_scope!(user, Workflow) } + + context 'viewer' do + let(:user) { create(:user, roles: { workflow.project.id => ['tester'] }) } + it 'allows a limited subset' do + expect(scope.to_a).to include(workflow) + expect(scope.to_a).not_to include(another_workflow) + end + end + + context 'admin' do + let(:user) { create(:user, :admin) } + it 'returns all workflows' do + expect(scope.to_a).to include(workflow, another_workflow) + end + end + end +end diff --git a/spec/lib/panoptes_api_spec.rb b/spec/services/panoptes_api_spec.rb similarity index 91% rename from spec/lib/panoptes_api_spec.rb rename to spec/services/panoptes_api_spec.rb index 767be38b..453cb247 100644 --- a/spec/lib/panoptes_api_spec.rb +++ b/spec/services/panoptes_api_spec.rb @@ -1,7 +1,7 @@ require './spec/fixtures/project_roles.rb' require './spec/fixtures/project.rb' -RSpec.describe PanoptesApi, type: :lib do +RSpec.describe PanoptesApi, type: :service do include_context 'role parsing' include_context 'project parsing' @@ -31,7 +31,7 @@ end describe '#roles' do - let(:parsed_roles) { { 1 => ["collaborator"], 2 => ["owner"], 3 => ["researcher"] } } + let(:parsed_roles) { { '1' => ["collaborator"], '2' => ["owner"], '3' => ["researcher"] } } it 'returns a hash' do allow(panoptes_api).to receive(:api).and_return(client_double) diff --git a/spec/services/role_checker_spec.rb b/spec/services/role_checker_spec.rb new file mode 100644 index 00000000..445aac48 --- /dev/null +++ b/spec/services/role_checker_spec.rb @@ -0,0 +1,62 @@ +RSpec.describe ProjectRoleChecker, type: :service do + let(:records){ [] } + let(:user) { create(:user, roles: {'123': ['tester'], '456': ['expert']}) } + let(:checker){ described_class.new user, records } + let(:resource_double) { double(id: '123')} + let(:another_resource_double) { double(id: '456')} + let(:one_more) { double(id: '666')} + + describe '#viewer_project_ids' do + it 'returns an array of resource ids' do + expect(checker.viewer_project_ids).to eq ['123', '456'] + end + end + + context 'the user has permission' do + before do + allow_any_instance_of(described_class).to receive(:check_roles).and_return true + end + + describe '#can_edit?' do + it 'returns true when the user has permission' do + expect(checker.can_edit?).to be true + end + end + + describe '#can_approve?' do + it 'returns true when the user has permission' do + expect(checker.can_approve?).to be true + end + end + + describe '#can_view?' do + it 'returns true when the user has permission' do + expect(checker.can_edit?).to be true + end + end + end + + context 'the user does not have permission' do + before do + allow_any_instance_of(described_class).to receive(:check_roles).and_return false + end + + describe '#can_edit?' do + it 'returns false when the user does not have permission' do + expect(checker.can_edit?).to be false + end + end + + describe '#can_approve?' do + it 'returns false when the user does not have permission' do + expect(checker.can_approve?).to be false + end + end + + describe '#can_view?' do + it 'returns false when the user does not have permission' do + expect(checker.can_view?).to be false + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b80b0e00..290604e8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,7 @@ require 'factory_bot' require 'jsonapi/rspec' +require "pundit/rspec" +require 'pundit/matchers' # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config|