diff --git a/.rubocop.yml b/.rubocop.yml index 6651a65c..b1a11239 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,2 +1,8 @@ inherit_from: - https://raw.githubusercontent.com/lessonly/rubocop-default-configuration/master/.rubocop.yml + +Metrics/BlockLength: + # don't warn about block length in block-centered DSLs + Exclude: + - 'config/routes.rb' + - 'spec/**/*.rb' diff --git a/app/controllers/concerns/scim_rails/exception_handler.rb b/app/controllers/concerns/scim_rails/exception_handler.rb index f87cbc0a..a6621f12 100644 --- a/app/controllers/concerns/scim_rails/exception_handler.rb +++ b/app/controllers/concerns/scim_rails/exception_handler.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ScimRails module ExceptionHandler extend ActiveSupport::Concern @@ -11,6 +13,9 @@ class InvalidQuery < StandardError class UnsupportedPatchRequest < StandardError end + class UnsupportedDeleteRequest < StandardError + end + included do if Rails.env.production? rescue_from StandardError do |exception| @@ -65,6 +70,17 @@ class UnsupportedPatchRequest < StandardError ) end + rescue_from ScimRails::ExceptionHandler::UnsupportedDeleteRequest do + json_response( + { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "Delete operation is disabled for the requested resource.", + status: "501" + }, + :not_implemented + ) + end + rescue_from ActiveRecord::RecordNotFound do |e| json_response( { diff --git a/app/controllers/concerns/scim_rails/response.rb b/app/controllers/concerns/scim_rails/response.rb index 8fe1b3ea..00bbbe67 100644 --- a/app/controllers/concerns/scim_rails/response.rb +++ b/app/controllers/concerns/scim_rails/response.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + module ScimRails module Response - CONTENT_TYPE = "application/scim+json".freeze + CONTENT_TYPE = "application/scim+json" def json_response(object, status = :ok) render \ @@ -18,7 +20,7 @@ def json_scim_response(object:, status: :ok, counts: nil) content_type: CONTENT_TYPE when "show", "create", "put_update", "patch_update" render \ - json: user_response(object), + json: object_response(object), status: status, content_type: CONTENT_TYPE end @@ -32,28 +34,35 @@ def list_response(object, counts) .offset(counts.offset) .limit(counts.limit) { - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:ListResponse" + schemas: [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" ], - "totalResults": counts.total, - "startIndex": counts.start_index, - "itemsPerPage": counts.limit, - "Resources": list_users(object) + totalResults: counts.total, + startIndex: counts.start_index, + itemsPerPage: counts.limit, + Resources: list_objects(object) } end - def list_users(users) - users.map do |user| - user_response(user) + def list_objects(objects) + objects.map do |object| + object_response(object) end end - def user_response(user) - schema = ScimRails.config.user_schema - find_value(user, schema) + def object_response(object) + schema = case object + when ScimRails.config.scim_users_model + ScimRails.config.user_schema + when ScimRails.config.scim_groups_model + ScimRails.config.group_schema + else + raise ScimRails::ExceptionHandler::InvalidQuery, + "Unknown model: #{object}" + end + find_value(object, schema) end - # `find_value` is a recursive method that takes a "user" and a # "user schema" and replaces any symbols in the schema with the # corresponding value from the user. Given a schema with symbols, @@ -61,20 +70,24 @@ def user_response(user) # send those symbols to the model, and replace the symbol with # the return value. - def find_value(user, object) - case object + def find_value(object, schema) + case schema when Hash - object.each.with_object({}) do |(key, value), hash| - hash[key] = find_value(user, value) + schema.each.with_object({}) do |(key, value), hash| + hash[key] = find_value(object, value) end - when Array - object.map do |value| - find_value(user, value) + when Array, ActiveRecord::Associations::CollectionProxy + schema.map do |value| + find_value(object, value) end + when ScimRails.config.scim_users_model + find_value(schema, ScimRails.config.user_abbreviated_schema) + when ScimRails.config.scim_groups_model + find_value(schema, ScimRails.config.group_abbreviated_schema) when Symbol - user.public_send(object) + find_value(object, object.public_send(schema)) else - object + schema end end end diff --git a/app/controllers/scim_rails/application_controller.rb b/app/controllers/scim_rails/application_controller.rb index 2c2d09bb..f9340d5c 100644 --- a/app/controllers/scim_rails/application_controller.rb +++ b/app/controllers/scim_rails/application_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ScimRails class ApplicationController < ActionController::API include ActionController::HttpAuthentication::Basic::ControllerMethods @@ -28,11 +30,43 @@ def authentication_strategy end def authenticate_with_oauth_bearer - authentication_attribute = request.headers["Authorization"].split(" ").last + authentication_attribute = request.headers["Authorization"].split.last payload = ScimRails::Encoder.decode(authentication_attribute).with_indifferent_access searchable_attribute = payload[ScimRails.config.basic_auth_model_searchable_attribute] yield searchable_attribute, authentication_attribute end + + def find_value_for(attribute) + params.dig(*path_for(attribute)) + end + + # `path_for` is a recursive method used to find the "path" for + # `.dig` to take when looking for a given attribute in the + # params. + # + # Example: `path_for(:name)` should return an array that looks + # like [:names, 0, :givenName]. `.dig` can then use that path + # against the params to translate the :name attribute to "John". + + def path_for(attribute, object = controller_schema, path = []) + at_path = path.empty? ? object : object.dig(*path) + return path if at_path == attribute + + case at_path + when Hash + at_path.each do |key, _value| + found_path = path_for(attribute, object, [*path, key]) + return found_path if found_path + end + nil + when Array + at_path.each_with_index do |_value, index| + found_path = path_for(attribute, object, [*path, index]) + return found_path if found_path + end + nil + end + end end end diff --git a/app/controllers/scim_rails/scim_groups_controller.rb b/app/controllers/scim_rails/scim_groups_controller.rb new file mode 100644 index 00000000..6b798617 --- /dev/null +++ b/app/controllers/scim_rails/scim_groups_controller.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module ScimRails + class ScimGroupsController < ScimRails::ApplicationController + def index + if params[:filter].present? + query = ScimRails::ScimQueryParser.new( + params[:filter], ScimRails.config.queryable_group_attributes + ) + + groups = @company + .public_send(ScimRails.config.scim_groups_scope) + .where( + "#{ScimRails.config.scim_groups_model.connection.quote_column_name(query.attribute)} #{query.operator} ?", + query.parameter + ) + .order(ScimRails.config.scim_groups_list_order) + else + groups = @company + .public_send(ScimRails.config.scim_groups_scope) + .preload(:users) + .order(ScimRails.config.scim_groups_list_order) + end + + counts = ScimCount.new( + start_index: params[:startIndex], + limit: params[:count], + total: groups.count + ) + + json_scim_response(object: groups, counts: counts) + end + + def show + group = @company + .public_send(ScimRails.config.scim_groups_scope) + .find(params[:id]) + json_scim_response(object: group) + end + + def create + group = @company + .public_send(ScimRails.config.scim_groups_scope) + .create!(permitted_group_params) + + json_scim_response(object: group, status: :created) + end + + def put_update + group = @company + .public_send(ScimRails.config.scim_groups_scope) + .find(params[:id]) + group.update!(permitted_group_params) + json_scim_response(object: group) + end + + def destroy + unless ScimRails.config.group_destroy_method + raise ScimRails::ExceptionHandler::UnsupportedDeleteRequest + end + group = @company + .public_send(ScimRails.config.scim_groups_scope) + .find(params[:id]) + group.public_send(ScimRails.config.group_destroy_method) + head :no_content + end + + private + + def permitted_group_params + converted = mutable_attributes.each.with_object({}) do |attribute, hash| + hash[attribute] = find_value_for(attribute) + end + return converted unless params[:members] + + converted.merge(member_params) + end + + def member_params + { + ScimRails.config.group_member_relation_attribute => + params[:members].map do |member| + member[ScimRails.config.group_member_relation_schema.keys.first] + end + } + end + + def mutable_attributes + ScimRails.config.mutable_group_attributes + end + + def controller_schema + ScimRails.config.mutable_group_attributes_schema + end + end +end diff --git a/app/controllers/scim_rails/scim_users_controller.rb b/app/controllers/scim_rails/scim_users_controller.rb index d0acb4fe..69c4dbc1 100644 --- a/app/controllers/scim_rails/scim_users_controller.rb +++ b/app/controllers/scim_rails/scim_users_controller.rb @@ -1,8 +1,12 @@ +# frozen_string_literal: true + module ScimRails class ScimUsersController < ScimRails::ApplicationController def index if params[:filter].present? - query = ScimRails::ScimQueryParser.new(params[:filter]) + query = ScimRails::ScimQueryParser.new( + params[:filter], ScimRails.config.queryable_user_attributes + ) users = @company .public_send(ScimRails.config.scim_users_scope) @@ -31,7 +35,7 @@ def create user = @company.public_send(ScimRails.config.scim_users_scope).create!(permitted_user_params) else username_key = ScimRails.config.queryable_user_attributes[:userName] - find_by_username = Hash.new + find_by_username = {} find_by_username[username_key] = permitted_user_params[username_key] user = @company .public_send(ScimRails.config.scim_users_scope) @@ -70,36 +74,8 @@ def permitted_user_params end end - def find_value_for(attribute) - params.dig(*path_for(attribute)) - end - - # `path_for` is a recursive method used to find the "path" for - # `.dig` to take when looking for a given attribute in the - # params. - # - # Example: `path_for(:name)` should return an array that looks - # like [:names, 0, :givenName]. `.dig` can then use that path - # against the params to translate the :name attribute to "John". - - def path_for(attribute, object = ScimRails.config.mutable_user_attributes_schema, path = []) - at_path = path.empty? ? object : object.dig(*path) - return path if at_path == attribute - - case at_path - when Hash - at_path.each do |key, value| - found_path = path_for(attribute, object, [*path, key]) - return found_path if found_path - end - nil - when Array - at_path.each_with_index do |value, index| - found_path = path_for(attribute, object, [*path, index]) - return found_path if found_path - end - nil - end + def controller_schema + ScimRails.config.mutable_user_attributes_schema end def update_status(user) diff --git a/app/models/scim_rails/scim_query_parser.rb b/app/models/scim_rails/scim_query_parser.rb index 969135d9..c0475dfa 100644 --- a/app/models/scim_rails/scim_query_parser.rb +++ b/app/models/scim_rails/scim_query_parser.rb @@ -1,37 +1,39 @@ +# frozen_string_literal: true + module ScimRails class ScimQueryParser - attr_accessor :query_elements + attr_accessor :query_elements, :query_attributes - def initialize(query_string) - self.query_elements = query_string.split(" ") + def initialize(query_string, queryable_attributes) + self.query_elements = query_string.split + self.query_attributes = queryable_attributes end def attribute - attribute = query_elements.dig(0) + attribute = query_elements[0] raise ScimRails::ExceptionHandler::InvalidQuery if attribute.blank? + attribute = attribute.to_sym - mapped_attribute = attribute_mapping(attribute) + mapped_attribute = query_attributes[attribute] raise ScimRails::ExceptionHandler::InvalidQuery if mapped_attribute.blank? + mapped_attribute end def operator - sql_comparison_operator(query_elements.dig(1)) + sql_comparison_operator(query_elements[1]) end def parameter parameter = query_elements[2..-1].join(" ") return if parameter.blank? + parameter.gsub(/"/, "") end private - def attribute_mapping(attribute) - ScimRails.config.queryable_user_attributes[attribute] - end - def sql_comparison_operator(element) case element when "eq" diff --git a/config/routes.rb b/config/routes.rb index 3f2d16e1..398477ba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,4 +4,9 @@ get 'scim/v2/Users/:id', action: :show, controller: 'scim_users' put 'scim/v2/Users/:id', action: :put_update, controller: 'scim_users' patch 'scim/v2/Users/:id', action: :patch_update, controller: 'scim_users' + get 'scim/v2/Groups', action: :index, controller: 'scim_groups' + post 'scim/v2/Groups', action: :create, controller: 'scim_groups' + get 'scim/v2/Groups/:id', action: :show, controller: 'scim_groups' + put 'scim/v2/Groups/:id', action: :put_update, controller: 'scim_groups' + delete 'scim/v2/Groups/:id', action: :destroy, controller: 'scim_groups' end diff --git a/lib/generators/scim_rails/templates/initializer.rb b/lib/generators/scim_rails/templates/initializer.rb index 2cd7e503..5e045684 100644 --- a/lib/generators/scim_rails/templates/initializer.rb +++ b/lib/generators/scim_rails/templates/initializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ScimRails.configure do |config| # Model used for authenticating and scoping users. config.basic_auth_model = "Company" @@ -22,6 +24,12 @@ # or throws an error (returning 409 Conflict in accordance with SCIM spec) config.scim_user_prevent_update_on_create = false + # Model used for group records. + config.scim_groups_model = "Group" + # Method used for retrieving user records from the + # authenticatable model. + config.scim_groups_scope = :groups + # Cryptographic algorithm used for signing the auth tokens. # It supports all algorithms supported by the jwt gem. # See https://github.com/jwt/ruby-jwt#algorithms-and-usage for supported algorithms @@ -101,8 +109,58 @@ emails: [ { value: :email - }, + } ], active: :active? } + + # Schema for users used in "abbreviated" lists such as in + # the `members` field of a Group. + config.user_abbreviated_schema = { + value: :id, + display: :email + } + + # Allow filtering Groups based on these parameters + config.queryable_group_attributes = { + displayName: :name + } + + # List of attributes on a Group that can be updated through SCIM + config.mutable_group_attributes = [ + :name + ] + + # Hash of mutable Group attributes. This object is the map + # for this Gem to figure out where to look in a SCIM + # response for mutable values. This object should + # include all attributes listed in + # config.mutable_group_attributes. + config.mutable_group_attributes_schema = { + displayName: :name + } + + # The User relation's IDs field name on the Group model. + # Eg. if the relation is `has_many :users` this will be :user_ids + config.group_member_relation_attribute = :user_ids + # Which fields from the request's `members` field should be + # assigned to the relation IDs field. Should include the field + # set in config.group_member_relation_attribute. + config.group_member_relation_schema = { value: :user_ids } + + config.group_schema = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + id: :id, + displayName: :name, + members: :users + } + + config.group_abbreviated_schema = { + value: :id, + display: :name + } + + # Set group_destroy_method to a method on the Group model + # to be called on a destroy request + # config.group_destroy_method = :destroy! end diff --git a/lib/scim_rails/config.rb b/lib/scim_rails/config.rb index aa4f108b..6f229f57 100644 --- a/lib/scim_rails/config.rb +++ b/lib/scim_rails/config.rb @@ -18,7 +18,9 @@ class Config attr_writer \ :basic_auth_model, :mutable_user_attributes_schema, - :scim_users_model + :mutable_group_attributes_schema, + :scim_users_model, + :scim_groups_model attr_accessor \ :basic_auth_model_authenticatable_attribute, @@ -26,29 +28,48 @@ class Config :mutable_user_attributes, :on_error, :queryable_user_attributes, + :queryable_group_attributes, :scim_users_list_order, :scim_users_scope, :scim_user_prevent_update_on_create, + :mutable_group_attributes, + :scim_groups_list_order, + :scim_groups_scope, + :group_member_relation_attribute, + :group_member_relation_schema, + :user_abbreviated_schema, + :group_abbreviated_schema, :signing_secret, :signing_algorithm, :user_attributes, :user_deprovision_method, :user_reprovision_method, - :user_schema + :user_schema, + :group_schema, + :group_destroy_method def initialize @basic_auth_model = "Company" @scim_users_list_order = :id @scim_users_model = "User" + @scim_groups_list_order = :id + @scim_groups_model = "Group" @signing_algorithm = ALGO_NONE @user_schema = {} @user_attributes = [] + @user_abbreviated_schema = {} + @group_schema = {} + @group_abbreviated_schema = {} end def mutable_user_attributes_schema @mutable_user_attributes_schema || @user_schema end + def mutable_group_attributes_schema + @mutable_group_attributes_schema || @group_schema + end + def basic_auth_model @basic_auth_model.constantize end @@ -56,5 +77,9 @@ def basic_auth_model def scim_users_model @scim_users_model.constantize end + + def scim_groups_model + @scim_groups_model.constantize + end end end diff --git a/spec/controllers/scim_rails/scim_groups_controller_spec.rb b/spec/controllers/scim_rails/scim_groups_controller_spec.rb new file mode 100644 index 00000000..e168eac7 --- /dev/null +++ b/spec/controllers/scim_rails/scim_groups_controller_spec.rb @@ -0,0 +1,494 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ScimRails::ScimGroupsController, type: :controller do + include AuthHelper + + routes { ScimRails::Engine.routes } + + describe "index" do + let(:company) { create(:company) } + + context "when unauthorized" do + it "returns scim+json content type" do + get :index, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "fails with no credentials" do + get :index, as: :json + + expect(response.status).to eq 401 + end + + it "fails with invalid credentials" do + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") + + get :index, as: :json + + expect(response.status).to eq 401 + end + end + + context "when authorized" do + before :each do + http_login(company) + end + + it "returns scim+json content type" do + get :index, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "is successful with valid credentials" do + get :index, as: :json + + expect(response.status).to eq 200 + end + + it "returns all results" do + create_list(:group, 5, company: company) + + get :index, as: :json + response_body = JSON.parse(response.body) + expect(response_body.dig("schemas", 0)).to( + eq "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ) + expect(response_body["totalResults"]).to eq 5 + end + + it "defaults to 100 results" do + create_list(:group, 300, company: company) + + get :index, as: :json + response_body = JSON.parse(response.body) + expect(response_body["totalResults"]).to eq 300 + expect(response_body["Resources"].count).to eq 100 + end + + it "paginates results" do + create_list(:group, 400, company: company) + expect(company.groups.first.id).to eq 1 + + get :index, params: { + startIndex: 101, + count: 200 + }, as: :json + response_body = JSON.parse(response.body) + expect(response_body["totalResults"]).to eq 400 + expect(response_body["Resources"].count).to eq 200 + expect(response_body.dig("Resources", 0, "id")).to eq 101 + end + + it "paginates results by configurable scim_groups_list_order" do + allow(ScimRails.config).to( + receive(:scim_groups_list_order).and_return(created_at: :desc) + ) + + create_list(:group, 400, company: company) + expect(company.groups.first.id).to eq 1 + + get :index, params: { + startIndex: 1, + count: 10 + }, as: :json + response_body = JSON.parse(response.body) + expect(response_body["totalResults"]).to eq 400 + expect(response_body["Resources"].count).to eq 10 + expect(response_body.dig("Resources", 0, "id")).to eq 400 + end + + it "filters results by provided displayName filter" do + create(:group, name: "Foo", company: company) + create(:group, name: "Bar", company: company) + + get :index, params: { + filter: "displayName eq Bar" + }, as: :json + response_body = JSON.parse(response.body) + expect(response_body["totalResults"]).to eq 1 + expect(response_body["Resources"].count).to eq 1 + expect(response_body.dig("Resources", 0, "displayName")).to eq "Bar" + end + + it "returns no results for unfound filter parameters" do + get :index, params: { + filter: "displayName eq fake_not_there" + }, as: :json + response_body = JSON.parse(response.body) + expect(response_body["totalResults"]).to eq 0 + expect(response_body["Resources"].count).to eq 0 + end + + it "returns no results for undefined filter queries" do + get :index, params: { + filter: "address eq 101 Nowhere USA" + }, as: :json + expect(response.status).to eq 400 + response_body = JSON.parse(response.body) + expect(response_body.dig("schemas", 0)).to( + eq "urn:ietf:params:scim:api:messages:2.0:Error" + ) + end + end + end + + describe "show" do + let(:company) { create(:company) } + + context "when unauthorized" do + it "returns scim+json content type" do + get :show, params: { id: 1 }, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "fails with no credentials" do + get :show, params: { id: 1 }, as: :json + + expect(response.status).to eq 401 + end + + it "fails with invalid credentials" do + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") + + get :show, params: { id: 1 }, as: :json + + expect(response.status).to eq 401 + end + end + + context "when authorized" do + before :each do + http_login(company) + end + + it "returns scim+json content type" do + get :show, params: { id: 1 }, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "is successful with valid credentials" do + create(:group, id: 1, company: company) + get :show, params: { id: 1 }, as: :json + + expect(response.status).to eq 200 + end + + it "returns :not_found for id that cannot be found" do + get :show, params: { id: "fake_id" }, as: :json + + expect(response.status).to eq 404 + end + + it "returns :not_found for a correct id but unauthorized company" do + new_company = create(:company) + create(:group, company: new_company, id: 1) + + get :show, params: { id: 1 }, as: :json + + expect(response.status).to eq 404 + end + end + end + + describe "create" do + let(:company) { create(:company) } + + context "when unauthorized" do + it "returns scim+json content type" do + post :create, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "fails with no credentials" do + post :create, as: :json + + expect(response.status).to eq 401 + end + + it "fails with invalid credentials" do + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") + + post :create, as: :json + + expect(response.status).to eq 401 + end + end + + context "when authorized" do + before :each do + http_login(company) + end + + it "returns scim+json content type" do + post :create, params: { + displayName: "Test Group", + members: [] + }, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "is successful with valid credentials" do + expect(company.groups.count).to eq 0 + + post :create, params: { + displayName: "Test Group", + members: [] + }, as: :json + + expect(response.status).to eq 201 + expect(company.groups.count).to eq 1 + group = company.groups.first + expect(group.persisted?).to eq true + expect(group.name).to eq "Test Group" + expect(group.users).to eq [] + end + + it "ignores unconfigured params" do + post :create, params: { + displayName: "Test Group", + department: "Best Department", + members: [] + }, as: :json + + expect(response.status).to eq 201 + expect(company.groups.count).to eq 1 + end + + it "returns 422 if required params are missing" do + post :create, params: { + members: [] + }, as: :json + + expect(response.status).to eq 422 + expect(company.users.count).to eq 0 + end + + it "returns 409 if group already exists" do + create(:group, name: "Test Group", company: company) + + post :create, params: { + displayName: "Test Group", + members: [] + }, as: :json + + expect(response.status).to eq 409 + expect(company.groups.count).to eq 1 + end + + it "creates group" do + users = create_list(:user, 3, company: company) + + post :create, params: { + displayName: "Test Group", + members: users.map do |user| + { value: user.id.to_s, display: user.email } + end + }, as: :json + + expect(response.status).to eq 201 + expect(company.groups.count).to eq 1 + group = company.groups.first + expect(group.name).to eq "Test Group" + expect(group.users.count).to eq 3 + end + end + end + + describe "put update" do + let(:company) { create(:company) } + + context "when unauthorized" do + it "returns scim+json content type" do + put :put_update, params: { id: 1 }, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "fails with no credentials" do + put :put_update, params: { id: 1 }, as: :json + + expect(response.status).to eq 401 + end + + it "fails with invalid credentials" do + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") + + put :put_update, params: { id: 1 }, as: :json + + expect(response.status).to eq 401 + end + end + + context "when authorized" do + let!(:group) { create(:group, id: 1, company: company) } + + before :each do + http_login(company) + end + + it "returns scim+json content type" do + put :put_update, params: put_params, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "is successful with with valid credentials" do + put :put_update, params: put_params, as: :json + + expect(response.status).to eq 200 + end + + it "can add and delete Users from a Group at once" do + user1 = create(:user, company: company, groups: [group]) + user2 = create(:user, company: company) + + expect do + put :put_update, params: put_params(users: [user2]), as: :json + end.to change { group.reload.users }.from([user1]).to([user2]) + + expect(response.status).to eq 200 + end + + it "returns :not_found for id that cannot be found" do + put :put_update, params: { id: "fake_id" }, as: :json + + expect(response.status).to eq 404 + end + + it "returns :not_found for a correct id but unauthorized company" do + new_company = create(:company) + create(:group, company: new_company, id: 1000) + + put :put_update, params: { id: 1000 }, as: :json + + expect(response.status).to eq 404 + end + + it "returns 422 with incomplete request" do + put :put_update, params: { + id: 1, + members: [] + }, as: :json + + expect(response.status).to eq 422 + end + end + end + + describe "destroy" do + let(:company) { create(:company) } + + context "when unauthorized" do + it "returns scim+json content type" do + delete :destroy, params: { id: 1 }, as: :json + + expect(response.media_type).to eq "application/scim+json" + end + + it "fails with no credentials" do + delete :destroy, params: { id: 1 }, as: :json + + expect(response.status).to eq 401 + end + + it "fails with invalid credentials" do + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") + + delete :destroy, params: { id: 1 }, as: :json + + expect(response.status).to eq 401 + end + end + + context "when authorized" do + let!(:group) { create(:group, id: 1, company: company) } + + before :each do + http_login(company) + end + + context "when Group destroy method is configured" do + before do + allow(ScimRails.config).to( + receive(:group_destroy_method).and_return(:destroy!) + ) + end + + it "returns empty response" do + delete :destroy, params: { id: 1 }, as: :json + + expect(response.body).to be_empty + end + + it "is successful with valid credentials" do + delete :destroy, params: { id: 1 }, as: :json + + expect(response.status).to eq 204 + end + + it "returns :not_found for id that cannot be found" do + delete :destroy, params: { id: "fake_id" }, as: :json + + expect(response.status).to eq 404 + end + + it "returns :not_found for a correct id but unauthorized company" do + new_company = create(:company) + create(:group, company: new_company, id: 1000) + + delete :destroy, params: { id: 1000 }, as: :json + + expect(response.status).to eq 404 + end + + it "successfully deletes Group" do + expect do + delete :destroy, params: { id: 1 }, as: :json + end.to change { company.groups.reload.count }.from(1).to(0) + + expect(response.status).to eq 204 + end + end + + context "when Group destroy method is not configured" do + it "does not delete Group" do + allow(ScimRails.config).to( + receive(:group_destroy_method).and_return(nil) + ) + + expect do + delete :destroy, params: { id: 1 }, as: :json + end.not_to change { company.groups.reload.count }.from(1) + + expect(response.status).to eq 501 + end + end + end + end + + def put_params(name: "Test Group", users: []) + { + id: 1, + displayName: name, + members: users.map { |user| { value: user.id.to_s, display: user.email } } + } + end +end diff --git a/spec/controllers/scim_rails/scim_groups_request_spec.rb b/spec/controllers/scim_rails/scim_groups_request_spec.rb new file mode 100644 index 00000000..be40bee1 --- /dev/null +++ b/spec/controllers/scim_rails/scim_groups_request_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ScimRails::ScimGroupsController, type: :request do + let(:company) { create(:company) } + let(:credentials) do + Base64.encode64("#{company.subdomain}:#{company.api_token}") + end + let(:authorization) { "Basic #{credentials}" } + + def post_request(content_type = "application/scim+json") + post "/scim/v2/Groups", + params: { + displayName: "Dummy Group", + members: [] + }.to_json, + headers: { + Authorization: authorization, + 'Content-Type': content_type + } + end + + describe "Content-Type" do + it "accepts scim+json" do + expect(company.groups.count).to eq 0 + + post_request("application/scim+json") + + expect(request.params).to include :displayName + expect(response.status).to eq 201 + expect(response.media_type).to eq "application/scim+json" + expect(company.groups.count).to eq 1 + end + + it "can not parse unfamiliar content types" do + expect(company.groups.count).to eq 0 + + post_request("text/csv") + + expect(request.params).not_to include :displayName + expect(response.status).to eq 422 + expect(company.groups.count).to eq 0 + end + end + + context "OAuth Bearer Authorization" do + context "with valid token" do + let(:authorization) { "Bearer #{company.api_token}" } + + it "supports OAuth bearer authorization and succeeds" do + expect { post_request }.to change(company.groups, :count).from(0).to(1) + + expect(response.status).to eq 201 + end + end + + context "with invalid token" do + let(:authorization) { "Bearer #{SecureRandom.hex}" } + + it "The request fails" do + expect { post_request }.not_to change(company.groups, :count) + + expect(response.status).to eq 401 + end + end + end +end diff --git a/spec/controllers/scim_rails/scim_users_controller_spec.rb b/spec/controllers/scim_rails/scim_users_controller_spec.rb index 03b9125d..a9fbfbea 100644 --- a/spec/controllers/scim_rails/scim_users_controller_spec.rb +++ b/spec/controllers/scim_rails/scim_users_controller_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" RSpec.describe ScimRails::ScimUsersController, type: :controller do @@ -22,7 +24,9 @@ end it "fails with invalid credentials" do - request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials("unauthorized","123456") + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") get :index, as: :json @@ -71,7 +75,7 @@ get :index, params: { startIndex: 101, - count: 200, + count: 200 }, as: :json response_body = JSON.parse(response.body) expect(response_body["totalResults"]).to eq 400 @@ -87,7 +91,7 @@ get :index, params: { startIndex: 1, - count: 10, + count: 10 }, as: :json response_body = JSON.parse(response.body) expect(response_body["totalResults"]).to eq 400 @@ -139,7 +143,6 @@ end end - describe "show" do let(:company) { create(:company) } @@ -157,7 +160,9 @@ end it "fails with invalid credentials" do - request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials("unauthorized","123456") + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") get :show, params: { id: 1 }, as: :json @@ -200,7 +205,6 @@ end end - describe "create" do let(:company) { create(:company) } @@ -218,7 +222,9 @@ end it "fails with invalid credentials" do - request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials("unauthorized","123456") + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") post :create, as: :json @@ -356,7 +362,7 @@ emails: [ { value: "test@example.com" - }, + } ], active: "false" }, as: :json @@ -369,7 +375,6 @@ end end - describe "put update" do let(:company) { create(:company) } @@ -387,7 +392,9 @@ end it "fails with invalid credentials" do - request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials("unauthorized","123456") + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") put :put_update, params: { id: 1 }, as: :json @@ -433,7 +440,7 @@ end it "returns :not_found for id that cannot be found" do - get :put_update, params: { id: "fake_id" }, as: :json + put :put_update, params: { id: "fake_id" }, as: :json expect(response.status).to eq 404 end @@ -442,7 +449,7 @@ new_company = create(:company) create(:user, company: new_company, id: 1000) - get :put_update, params: { id: 1000 }, as: :json + put :put_update, params: { id: 1000 }, as: :json expect(response.status).to eq 404 end @@ -454,7 +461,7 @@ emails: [ { value: "test@example.com" - }, + } ], active: "true" }, as: :json @@ -464,7 +471,6 @@ end end - describe "patch update" do let(:company) { create(:company) } @@ -482,7 +488,9 @@ end it "fails with invalid credentials" do - request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials("unauthorized","123456") + request.env["HTTP_AUTHORIZATION"] = + ActionController::HttpAuthentication::Basic + .encode_credentials("unauthorized", "123456") patch :patch_update, params: patch_params(id: 1), as: :json @@ -542,7 +550,10 @@ user = company.users.first.tap(&:archive!) expect(user.archived?).to eq true - patch :patch_update, params: patch_params(id: 1, active: true), as: :json + patch \ + :patch_update, + params: patch_params(id: 1, active: true), + as: :json expect(response.status).to eq 200 expect(company.users.count).to eq 1 @@ -662,7 +673,7 @@ def put_params(active: true) emails: [ { value: "test@example.com" - }, + } ], active: active } diff --git a/spec/controllers/scim_rails/scim_users_request_spec.rb b/spec/controllers/scim_rails/scim_users_request_spec.rb index 7e9168e3..41a75c2e 100644 --- a/spec/controllers/scim_rails/scim_users_request_spec.rb +++ b/spec/controllers/scim_rails/scim_users_request_spec.rb @@ -1,8 +1,12 @@ +# frozen_string_literal: true + require "spec_helper" RSpec.describe ScimRails::ScimUsersController, type: :request do let(:company) { create(:company) } - let(:credentials) { Base64::encode64("#{company.subdomain}:#{company.api_token}") } + let(:credentials) do + Base64.encode64("#{company.subdomain}:#{company.api_token}") + end let(:authorization) { "Basic #{credentials}" } def post_request(content_type = "application/scim+json") @@ -12,17 +16,17 @@ def post_request(content_type = "application/scim+json") params: { name: { givenName: "New", - familyName: "User", + familyName: "User" }, emails: [ { - value: "new@example.com", - }, - ], + value: "new@example.com" + } + ] }.to_json, headers: { - 'Authorization': authorization, - 'Content-Type': content_type, + Authorization: authorization, + 'Content-Type': content_type } end diff --git a/spec/dummy/app/models/company.rb b/spec/dummy/app/models/company.rb index b3f5fc05..ffb52bdb 100644 --- a/spec/dummy/app/models/company.rb +++ b/spec/dummy/app/models/company.rb @@ -1,3 +1,4 @@ class Company < ApplicationRecord has_many :users + has_many :groups end diff --git a/spec/dummy/app/models/group.rb b/spec/dummy/app/models/group.rb new file mode 100644 index 00000000..379a3c96 --- /dev/null +++ b/spec/dummy/app/models/group.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Group < ApplicationRecord + belongs_to :company + has_many :group_users + has_many :users, through: :group_users + + validates \ + :name, + presence: true, + uniqueness: { + case_insensitive: true, + scope: :company + } +end diff --git a/spec/dummy/app/models/group_user.rb b/spec/dummy/app/models/group_user.rb new file mode 100644 index 00000000..b36e9105 --- /dev/null +++ b/spec/dummy/app/models/group_user.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class GroupUser < ApplicationRecord + belongs_to :group + belongs_to :user +end diff --git a/spec/dummy/app/models/user.rb b/spec/dummy/app/models/user.rb index b6173858..01f60d35 100644 --- a/spec/dummy/app/models/user.rb +++ b/spec/dummy/app/models/user.rb @@ -1,5 +1,7 @@ class User < ApplicationRecord belongs_to :company + has_many :group_users + has_many :groups, through: :group_users validates \ :first_name, diff --git a/spec/dummy/config/initializers/scim_rails_config.rb b/spec/dummy/config/initializers/scim_rails_config.rb index 2d9341bf..5af04c86 100644 --- a/spec/dummy/config/initializers/scim_rails_config.rb +++ b/spec/dummy/config/initializers/scim_rails_config.rb @@ -1,11 +1,13 @@ ScimRails.configure do |config| config.basic_auth_model = "Company" config.scim_users_model = "User" + config.scim_groups_model = "Group" config.basic_auth_model_searchable_attribute = :subdomain config.basic_auth_model_authenticatable_attribute = :api_token config.scim_users_scope = :users config.scim_users_list_order = :id + config.scim_groups_scope = :groups config.signing_algorithm = "HS256" config.signing_secret = "2d6806dd11c2fece2e81b8ca76dcb0062f5b08e28e3264e8ba1c44bbd3578b70" @@ -53,4 +55,31 @@ ], active: :unarchived? } + + config.queryable_group_attributes = { + displayName: :name + } + + config.mutable_group_attributes = [ + :name + ] + + config.mutable_group_attributes_schema = { + displayName: :name + } + + config.group_member_relation_attribute = :user_ids + config.group_member_relation_schema = { value: :user_ids } + + config.group_schema = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + id: :id, + displayName: :name, + members: :users + } + + config.group_abbreviated_schema = { + value: :id, + display: :name + } end diff --git a/spec/dummy/db/migrate/20210423075859_create_groups.rb b/spec/dummy/db/migrate/20210423075859_create_groups.rb new file mode 100644 index 00000000..54566e90 --- /dev/null +++ b/spec/dummy/db/migrate/20210423075859_create_groups.rb @@ -0,0 +1,10 @@ +class CreateGroups < ActiveRecord::Migration[5.0] + def change + create_table :groups do |t| + t.string :name, null: false + t.references :company, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/spec/dummy/db/migrate/20210423075950_create_group_users.rb b/spec/dummy/db/migrate/20210423075950_create_group_users.rb new file mode 100644 index 00000000..7c5b7b14 --- /dev/null +++ b/spec/dummy/db/migrate/20210423075950_create_group_users.rb @@ -0,0 +1,10 @@ +class CreateGroupUsers < ActiveRecord::Migration[5.0] + def change + create_table :group_users do |t| + t.references :group, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 395e9b85..a184a664 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -2,32 +2,52 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20181206184313) do +ActiveRecord::Schema.define(version: 2021_04_23_075950) do create_table "companies", force: :cascade do |t| - t.string "name", null: false - t.string "subdomain", null: false - t.string "api_token", null: false + t.string "name", null: false + t.string "subdomain", null: false + t.string "api_token", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false end + create_table "group_users", force: :cascade do |t| + t.integer "group_id", null: false + t.integer "user_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["group_id"], name: "index_group_users_on_group_id" + t.index ["user_id"], name: "index_group_users_on_user_id" + end + + create_table "groups", force: :cascade do |t| + t.string "name", null: false + t.integer "company_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["company_id"], name: "index_groups_on_company_id" + end + create_table "users", force: :cascade do |t| - t.string "first_name", null: false - t.string "last_name", null: false - t.string "email", null: false - t.integer "company_id" + t.string "first_name", null: false + t.string "last_name", null: false + t.string "email", null: false + t.integer "company_id" t.datetime "archived_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end + add_foreign_key "group_users", "groups" + add_foreign_key "group_users", "users" + add_foreign_key "groups", "companies" end diff --git a/spec/factories/group.rb b/spec/factories/group.rb new file mode 100644 index 00000000..6ac6922d --- /dev/null +++ b/spec/factories/group.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :group do + company + + sequence(:name) { |i| "Test Group ##{i}" } + + trait :with_users do + users { create_list(:user) } + end + end +end