diff --git a/db/schema.rb b/db/schema.rb index 8fe0ee7fcc397f2eb53c56aad1ee1a8707355138..fc02cad3e53cb1ba1fcacb091c40b775ec339657 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2773,6 +2773,14 @@ t.index ["group_id"], name: "index_saml_providers_on_group_id", using: :btree end + create_table "scim_oauth_access_tokens", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.integer "group_id", null: false + t.string "token_encrypted", null: false + t.index ["group_id", "token_encrypted"], name: "index_scim_oauth_access_tokens_on_group_id_and_token_encrypted", unique: true, using: :btree + end + create_table "sent_notifications", force: :cascade do |t| t.integer "project_id" t.integer "noteable_id" @@ -3613,6 +3621,7 @@ add_foreign_key "reviews", "projects", on_delete: :cascade add_foreign_key "reviews", "users", column: "author_id", on_delete: :nullify add_foreign_key "saml_providers", "namespaces", column: "group_id", on_delete: :cascade + add_foreign_key "scim_oauth_access_tokens", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade add_foreign_key "slack_integrations", "services", on_delete: :cascade add_foreign_key "smartcard_identities", "users", on_delete: :cascade diff --git a/ee/app/controllers/concerns/saml_authorization.rb b/ee/app/controllers/concerns/saml_authorization.rb new file mode 100644 index 0000000000000000000000000000000000000000..747f4587f1bf02214d6ae7259dbf2e140d5652f7 --- /dev/null +++ b/ee/app/controllers/concerns/saml_authorization.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +module SamlAuthorization + extend ActiveSupport::Concern + + private + + def authorize_manage_saml! + render_404 unless can?(current_user, :admin_group_saml, group) + end + + def check_group_saml_configured + render_404 unless Gitlab::Auth::GroupSaml::Config.enabled? + end + + def require_top_level_group + render_404 if group.subgroup? + end +end diff --git a/ee/app/controllers/groups/saml_providers_controller.rb b/ee/app/controllers/groups/saml_providers_controller.rb index 5e26b2df5f39cbb90e3ca842afdcd5d702160317..7e5cc1aac7a130949251c039f52601d6bc120b67 100644 --- a/ee/app/controllers/groups/saml_providers_controller.rb +++ b/ee/app/controllers/groups/saml_providers_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true +require_relative '../concerns/saml_authorization.rb' class Groups::SamlProvidersController < Groups::ApplicationController + include SamlAuthorization before_action :require_top_level_group before_action :authorize_manage_saml! before_action :check_group_saml_available! @@ -8,6 +10,10 @@ class Groups::SamlProvidersController < Groups::ApplicationController def show @saml_provider = @group.saml_provider || @group.build_saml_provider + + scim_token = ScimOauthAccessToken.find_by_group_id(@group.id) + + @scim_token_url = scim_token.as_entity_json[:scim_api_url] if scim_token end def create @@ -28,18 +34,6 @@ def update private - def authorize_manage_saml! - render_404 unless can?(current_user, :admin_group_saml, @group) - end - - def check_group_saml_configured - render_404 unless Gitlab::Auth::GroupSaml::Config.enabled? - end - - def require_top_level_group - render_404 if @group.subgroup? - end - def saml_provider_params allowed_params = %i[sso_url certificate_fingerprint enabled] diff --git a/ee/app/controllers/groups/scim_oauth_controller.rb b/ee/app/controllers/groups/scim_oauth_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..5f3ad5db2d84b78508eaf965570dd08fd8ca2e49 --- /dev/null +++ b/ee/app/controllers/groups/scim_oauth_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class Groups::ScimOauthController < Groups::ApplicationController + include SamlAuthorization + + before_action :require_top_level_group + before_action :authorize_manage_saml! + before_action :check_group_saml_available! + before_action :check_group_saml_configured + before_action :check_group_scim_enabled + + def show + scim_token = ScimOauthAccessToken.find_by_group_id(@group.id) + + respond_to do |format| + format.json do + if scim_token + render json: scim_token.as_entity_json + else + render json: {} + end + end + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def create + scim_token = ScimOauthAccessToken.find_or_initialize_by(group: @group) + + if scim_token.new_record? + scim_token.save + else + scim_token.reset_token! + end + + respond_to do |format| + format.json do + if scim_token.valid? + render json: scim_token.as_entity_json + else + render json: { errors: scim_token.errors.full_messages }, status: :unprocessable_entity + end + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def check_group_scim_enabled + route_not_found unless Feature.enabled?(:group_scim, @group) + end +end diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb index aa6d8602b021b391e1c4a9ec7f1530e3a59d8262..00d5005457e5e9cc005a643bab375f4e490999a4 100644 --- a/ee/app/models/ee/group.rb +++ b/ee/app/models/ee/group.rb @@ -20,6 +20,7 @@ module Group has_one :saml_provider has_one :insight, foreign_key: :namespace_id accepts_nested_attributes_for :insight + has_one :scim_oauth_access_token has_many :ldap_group_links, foreign_key: 'group_id', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :hooks, dependent: :destroy, class_name: 'GroupHook' # rubocop:disable Cop/ActiveRecordDependent diff --git a/ee/app/models/scim_oauth_access_token.rb b/ee/app/models/scim_oauth_access_token.rb new file mode 100644 index 0000000000000000000000000000000000000000..aaf5833d56a8ca71cd8fd2f637c08c93cf264d64 --- /dev/null +++ b/ee/app/models/scim_oauth_access_token.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ScimOauthAccessToken < ApplicationRecord + include TokenAuthenticatable + + belongs_to :group + + add_authentication_token_field :token, encrypted: :required + + validates :group, presence: true + before_save :ensure_token + + def as_entity_json + ScimOauthAccessTokenEntity.new(self).as_json + end +end diff --git a/ee/app/serializers/scim_oauth_access_token_entity.rb b/ee/app/serializers/scim_oauth_access_token_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d678ec598a27f7fba68b21d77e19dcce8e47ce6 --- /dev/null +++ b/ee/app/serializers/scim_oauth_access_token_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ScimOauthAccessTokenEntity < Grape::Entity + include ::API::Helpers::RelatedResourcesHelpers + + SCIM_PATH = '/api/scim/v2/groups' + + expose :scim_api_url do |scim| + expose_url("#{SCIM_PATH}/#{scim.group.full_path}") + end + + expose :token, as: :scim_token +end diff --git a/ee/config/routes/group.rb b/ee/config/routes/group.rb index fc9fcf53b3f9419382df457c09f197052282dd49..ed548b05848ca7b079918402e748ec542fa9b45a 100644 --- a/ee/config/routes/group.rb +++ b/ee/config/routes/group.rb @@ -89,6 +89,8 @@ delete :unlink, to: 'sso#unlink' end + resource :scim_oauth, only: [:show, :create], controller: :scim_oauth + resource :roadmap, only: [:show], controller: 'roadmap' legacy_ee_group_boards_redirect = redirect do |params, request| diff --git a/ee/db/migrate/20190301095211_create_scim_oauth_access_tokens.rb b/ee/db/migrate/20190301095211_create_scim_oauth_access_tokens.rb new file mode 100644 index 0000000000000000000000000000000000000000..17b9a4d38a4d92adf327d374b4ba817b806e25f6 --- /dev/null +++ b/ee/db/migrate/20190301095211_create_scim_oauth_access_tokens.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateScimOauthAccessTokens < ActiveRecord::Migration[5.0] + DOWNTIME = false + + def change + create_table :scim_oauth_access_tokens do |t| + t.timestamps_with_timezone null: false + t.references :group, null: false, index: false + t.string :token_encrypted, null: false + + t.index [:group_id, :token_encrypted], unique: true + t.foreign_key :namespaces, column: :group_id, on_delete: :cascade + end + end +end diff --git a/ee/spec/controllers/groups/saml_providers_controller_spec.rb b/ee/spec/controllers/groups/saml_providers_controller_spec.rb index fafea97ae0bf77220021bb642a817b2edd1e670b..eec2919c6907986b2d792d5b722c68c70ea7fa5e 100644 --- a/ee/spec/controllers/groups/saml_providers_controller_spec.rb +++ b/ee/spec/controllers/groups/saml_providers_controller_spec.rb @@ -79,6 +79,23 @@ def stub_saml_config(enabled:) expect(response).to render_template 'groups/saml_providers/show' end + it 'has no SCIM token URL' do + group.add_owner(user) + + subject + + expect(assigns(:scim_token_url)).to be_nil + end + + it 'has the SCIM token URL when it exists' do + create(:scim_oauth_access_token, group: group) + group.add_owner(user) + + subject + + expect(assigns(:scim_token_url)).to eq("http://localhost/api/scim/v2/groups/#{group.full_path}") + end + context 'not on a top level group', :nested_groups do let(:group) { create(:group, :nested) } diff --git a/ee/spec/controllers/groups/scim_oauth_controller_spec.rb b/ee/spec/controllers/groups/scim_oauth_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0f17e74702ad5cae36136cc99969d37966c31d1b --- /dev/null +++ b/ee/spec/controllers/groups/scim_oauth_controller_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Groups::ScimOauthController do + let(:saml_provider) { create(:saml_provider, group: group) } + let(:group) { create(:group, :private, parent_id: nil) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + def stub_saml_config(enabled:) + providers = enabled ? %i(group_saml) : [] + allow(Devise).to receive(:omniauth_providers).and_return(providers) + end + + context 'when the feature is enabled' do + before do + stub_saml_config(enabled: true) + stub_licensed_features(group_saml: true) + stub_feature_flags(group_scim: true) + end + + describe 'GET #show' do + subject { get :show, params: { group_id: group }, format: :json } + + before do + group.add_owner(user) + end + + context 'without token' do + it 'shows an empty response' do + subject + + expect(json_response).to eq({}) + end + end + + context 'with token' do + let!(:scim_token) { create(:scim_oauth_access_token, group: group) } + + it 'shows the token' do + subject + + expect(json_response['scim_token']).to eq(scim_token.token) + end + + it 'shows the url' do + subject + + expect(json_response['scim_api_url']).not_to be_empty + end + end + end + + describe 'POST #create' do + subject { post :create, params: { group_id: group }, format: :json } + + before do + group.add_owner(user) + end + + context 'without token' do + it 'creates a new SCIM token record' do + expect { subject }.to change { ScimOauthAccessToken.count }.by(1) + end + + context 'json' do + before do + subject + end + + it 'shows the token' do + expect(json_response['scim_token']).not_to be_empty + end + + it 'shows the url' do + expect(json_response['scim_api_url']).not_to be_empty + end + end + end + + context 'with token' do + let!(:scim_token) { create(:scim_oauth_access_token, group: group) } + + it 'does not create a new SCIM token record' do + expect { subject }.not_to change { ScimOauthAccessToken.count } + end + + it 'updates the token' do + expect { subject }.to change { scim_token.reload.token } + end + + context 'json' do + before do + subject + end + + it 'shows the token' do + expect(json_response['scim_token']).to eq(scim_token.reload.token) + end + + it 'shows the url' do + expect(json_response['scim_api_url']).not_to be_empty + end + end + end + end + end +end diff --git a/ee/spec/factories/scim_oauth_access_tokens.rb b/ee/spec/factories/scim_oauth_access_tokens.rb new file mode 100644 index 0000000000000000000000000000000000000000..0863415a1f092dd758d4ef9738d45defa5220382 --- /dev/null +++ b/ee/spec/factories/scim_oauth_access_tokens.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :scim_oauth_access_token do + group + end +end diff --git a/ee/spec/models/scim_oauth_access_token_spec.rb b/ee/spec/models/scim_oauth_access_token_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..abbf8a06683379ebb387705f9bfe9db6870232e4 --- /dev/null +++ b/ee/spec/models/scim_oauth_access_token_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ScimOauthAccessToken do + describe "Associations" do + it { is_expected.to belong_to :group } + end + + describe 'Validations' do + it { is_expected.to validate_presence_of(:group) } + end + + describe '#token' do + it 'generates a token on creation' do + scim_token = described_class.create(group: create(:group)) + + expect(scim_token.token).to be_a(String) + end + end +end diff --git a/ee/spec/serializers/scim_oauth_access_token_entity_spec.rb b/ee/spec/serializers/scim_oauth_access_token_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6515280a7564b1ed04ea5781c6abd83ddec50c1 --- /dev/null +++ b/ee/spec/serializers/scim_oauth_access_token_entity_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ScimOauthAccessTokenEntity do + let(:entity) do + described_class.new(create(:scim_oauth_access_token)) + end + + subject { entity.as_json } + + it "exposes the URL" do + is_expected.to include(:scim_api_url) + end + + it "exposes the token" do + is_expected.to include(:scim_token) + end +end