mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: add saml model & controller [CW-2958] (#12289)
This PR adds the foundation for account-level SAML SSO configuration in Chatwoot Enterprise. It introduces a new `AccountSamlSettings` model and management API that allows accounts to configure their own SAML identity providers independently, this also includes the certificate generation flow The implementation includes a new controller (`Api::V1::Accounts::SamlSettingsController`) that provides CRUD operations for SAML configuration The feature is properly gated behind the 'saml' feature flag and includes administrator-only authorization via Pundit policies.
This commit is contained in:
		| @@ -204,3 +204,7 @@ | |||||||
|   enabled: false |   enabled: false | ||||||
|   premium: true |   premium: true | ||||||
|   chatwoot_internal: true |   chatwoot_internal: true | ||||||
|  | - name: saml | ||||||
|  |   display_name: SAML | ||||||
|  |   enabled: false | ||||||
|  |   premium: true | ||||||
|   | |||||||
| @@ -55,6 +55,8 @@ en: | |||||||
|       failed: Signup failed |       failed: Signup failed | ||||||
|     assignment_policy: |     assignment_policy: | ||||||
|       not_found: Assignment policy not found |       not_found: Assignment policy not found | ||||||
|  |     saml: | ||||||
|  |       feature_not_enabled: SAML feature not enabled for this account | ||||||
|     data_import: |     data_import: | ||||||
|       data_type: |       data_type: | ||||||
|         invalid: Invalid data type |         invalid: Invalid data type | ||||||
|   | |||||||
| @@ -69,6 +69,7 @@ Rails.application.routes.draw do | |||||||
|             end |             end | ||||||
|             resources :documents, only: [:index, :show, :create, :destroy] |             resources :documents, only: [:index, :show, :create, :destroy] | ||||||
|           end |           end | ||||||
|  |           resource :saml_settings, only: [:show, :create, :update, :destroy] | ||||||
|           resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do |           resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do | ||||||
|             delete :avatar, on: :member |             delete :avatar, on: :member | ||||||
|             post :reset_access_token, on: :member |             post :reset_access_token, on: :member | ||||||
|   | |||||||
							
								
								
									
										14
									
								
								db/migrate/20250825070005_create_account_saml_settings.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								db/migrate/20250825070005_create_account_saml_settings.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | class CreateAccountSamlSettings < ActiveRecord::Migration[7.1] | ||||||
|  |   def change | ||||||
|  |     create_table :account_saml_settings do |t| | ||||||
|  |       t.references :account, null: false | ||||||
|  |       t.string :sso_url | ||||||
|  |       t.text :certificate | ||||||
|  |       t.string :sp_entity_id | ||||||
|  |       t.string :idp_entity_id | ||||||
|  |       t.json :role_mappings, default: {} | ||||||
|  |  | ||||||
|  |       t.timestamps | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										14
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								db/schema.rb
									
									
									
									
									
								
							| @@ -10,7 +10,7 @@ | |||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||||
|  |  | ||||||
| ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do | ActiveRecord::Schema[7.1].define(version: 2025_08_25_070005) do | ||||||
|   # These extensions should be enabled to support this database |   # These extensions should be enabled to support this database | ||||||
|   enable_extension "pg_stat_statements" |   enable_extension "pg_stat_statements" | ||||||
|   enable_extension "pg_trgm" |   enable_extension "pg_trgm" | ||||||
| @@ -28,6 +28,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do | |||||||
|     t.index ["token"], name: "index_access_tokens_on_token", unique: true |     t.index ["token"], name: "index_access_tokens_on_token", unique: true | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   create_table "account_saml_settings", force: :cascade do |t| | ||||||
|  |     t.bigint "account_id", null: false | ||||||
|  |     t.string "sso_url" | ||||||
|  |     t.text "certificate" | ||||||
|  |     t.string "sp_entity_id" | ||||||
|  |     t.string "idp_entity_id" | ||||||
|  |     t.json "role_mappings", default: {} | ||||||
|  |     t.datetime "created_at", null: false | ||||||
|  |     t.datetime "updated_at", null: false | ||||||
|  |     t.index ["account_id"], name: "index_account_saml_settings_on_account_id" | ||||||
|  |   end | ||||||
|  |  | ||||||
|   create_table "account_users", force: :cascade do |t| |   create_table "account_users", force: :cascade do |t| | ||||||
|     t.bigint "account_id" |     t.bigint "account_id" | ||||||
|     t.bigint "user_id" |     t.bigint "user_id" | ||||||
|   | |||||||
| @@ -0,0 +1,48 @@ | |||||||
|  | class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseController | ||||||
|  |   before_action :check_saml_feature_enabled | ||||||
|  |   before_action :check_authorization | ||||||
|  |   before_action :set_saml_settings | ||||||
|  |  | ||||||
|  |   def show; end | ||||||
|  |  | ||||||
|  |   def create | ||||||
|  |     @saml_settings = Current.account.build_saml_settings(saml_settings_params) | ||||||
|  |     @saml_settings.save! | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def update | ||||||
|  |     @saml_settings.update!(saml_settings_params) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def destroy | ||||||
|  |     @saml_settings.destroy! | ||||||
|  |     head :no_content | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def set_saml_settings | ||||||
|  |     @saml_settings = Current.account.saml_settings || | ||||||
|  |                      Current.account.build_saml_settings | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def saml_settings_params | ||||||
|  |     params.require(:saml_settings).permit( | ||||||
|  |       :sso_url, | ||||||
|  |       :certificate, | ||||||
|  |       :idp_entity_id, | ||||||
|  |       :sp_entity_id, | ||||||
|  |       role_mappings: {} | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def check_authorization | ||||||
|  |     authorize(AccountSamlSettings) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def check_saml_feature_enabled | ||||||
|  |     return if Current.account.feature_enabled?('saml') | ||||||
|  |  | ||||||
|  |     render json: { error: I18n.t('errors.saml.feature_not_enabled') }, status: :forbidden | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										59
									
								
								enterprise/app/models/account_saml_settings.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								enterprise/app/models/account_saml_settings.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | # == Schema Information | ||||||
|  | # | ||||||
|  | # Table name: account_saml_settings | ||||||
|  | # | ||||||
|  | #  id            :bigint           not null, primary key | ||||||
|  | #  certificate   :text | ||||||
|  | #  role_mappings :json | ||||||
|  | #  sso_url       :string | ||||||
|  | #  created_at    :datetime         not null | ||||||
|  | #  updated_at    :datetime         not null | ||||||
|  | #  account_id    :bigint           not null | ||||||
|  | #  idp_entity_id :string | ||||||
|  | #  sp_entity_id  :string | ||||||
|  | # | ||||||
|  | # Indexes | ||||||
|  | # | ||||||
|  | #  index_account_saml_settings_on_account_id  (account_id) | ||||||
|  | # | ||||||
|  | class AccountSamlSettings < ApplicationRecord | ||||||
|  |   belongs_to :account | ||||||
|  |  | ||||||
|  |   validates :account_id, presence: true | ||||||
|  |   validates :sso_url, presence: true | ||||||
|  |   validates :certificate, presence: true | ||||||
|  |   validates :idp_entity_id, presence: true | ||||||
|  |  | ||||||
|  |   before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation? | ||||||
|  |  | ||||||
|  |   def saml_enabled? | ||||||
|  |     sso_url.present? && certificate.present? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def certificate_fingerprint | ||||||
|  |     return nil if certificate.blank? | ||||||
|  |  | ||||||
|  |     begin | ||||||
|  |       cert = OpenSSL::X509::Certificate.new(certificate) | ||||||
|  |       OpenSSL::Digest::SHA1.new(cert.to_der).hexdigest | ||||||
|  |                            .upcase.gsub(/(.{2})(?=.)/, '\1:') | ||||||
|  |     rescue OpenSSL::X509::CertificateError | ||||||
|  |       nil | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def set_sp_entity_id | ||||||
|  |     base_url = GlobalConfigService.load('FRONTEND_URL', 'http://localhost:3000') | ||||||
|  |     self.sp_entity_id = "#{base_url}/saml/sp/#{account_id}" | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def sp_entity_id_needs_generation? | ||||||
|  |     sp_entity_id.blank? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def installation_name | ||||||
|  |     GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot') | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -27,4 +27,8 @@ module Enterprise::Account | |||||||
|   def unmark_for_deletion |   def unmark_for_deletion | ||||||
|     custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save |     custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def saml_enabled? | ||||||
|  |     saml_settings&.saml_enabled? || false | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -13,5 +13,7 @@ module Enterprise::Concerns::Account | |||||||
|  |  | ||||||
|     has_many :copilot_threads, dependent: :destroy_async |     has_many :copilot_threads, dependent: :destroy_async | ||||||
|     has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' |     has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' | ||||||
|  |  | ||||||
|  |     has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings' | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								enterprise/app/policies/account_saml_settings_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								enterprise/app/policies/account_saml_settings_policy.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | class AccountSamlSettingsPolicy < ApplicationPolicy | ||||||
|  |   def show? | ||||||
|  |     @account_user.administrator? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def create? | ||||||
|  |     @account_user.administrator? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def update? | ||||||
|  |     @account_user.administrator? | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def destroy? | ||||||
|  |     @account_user.administrator? | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | json.id account_saml_settings.id | ||||||
|  | json.account_id account_saml_settings.account_id | ||||||
|  | json.sso_url account_saml_settings.sso_url | ||||||
|  | json.certificate account_saml_settings.certificate | ||||||
|  | json.fingerprint account_saml_settings.certificate_fingerprint | ||||||
|  | json.idp_entity_id account_saml_settings.idp_entity_id | ||||||
|  | json.sp_entity_id account_saml_settings.sp_entity_id | ||||||
|  | json.role_mappings account_saml_settings.role_mappings || {} | ||||||
|  | json.created_at account_saml_settings.created_at | ||||||
|  | json.updated_at account_saml_settings.updated_at | ||||||
| @@ -0,0 +1,265 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe 'Api::V1::Accounts::SamlSettings', type: :request do | ||||||
|  |   let(:account) { create(:account) } | ||||||
|  |   let(:agent) { create(:user, account: account, role: :agent) } | ||||||
|  |   let(:administrator) { create(:user, account: account, role: :administrator) } | ||||||
|  |  | ||||||
|  |   before do | ||||||
|  |     account.enable_features('saml') | ||||||
|  |     account.save! | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def json_response | ||||||
|  |     JSON.parse(response.body, symbolize_names: true) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'GET /api/v1/accounts/{account.id}/saml_settings' do | ||||||
|  |     context 'when unauthenticated' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         get "/api/v1/accounts/#{account.id}/saml_settings" | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as administrator' do | ||||||
|  |       context 'when SAML settings exist' do | ||||||
|  |         let(:saml_settings) do | ||||||
|  |           create(:account_saml_settings, | ||||||
|  |                  account: account, | ||||||
|  |                  sso_url: 'https://idp.example.com/saml/sso', | ||||||
|  |                  role_mappings: { 'Admins' => { 'role' => 1 } }) | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         before do | ||||||
|  |           saml_settings # Ensure the record exists | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'returns the SAML settings' do | ||||||
|  |           get "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |               headers: administrator.create_new_auth_token, | ||||||
|  |               as: :json | ||||||
|  |  | ||||||
|  |           expect(response).to have_http_status(:success) | ||||||
|  |           expect(json_response[:sso_url]).to eq('https://idp.example.com/saml/sso') | ||||||
|  |           expect(json_response[:role_mappings]).to eq({ Admins: { role: 1 } }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'when SAML settings do not exist' do | ||||||
|  |         it 'returns default SAML settings' do | ||||||
|  |           get "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |               headers: administrator.create_new_auth_token, | ||||||
|  |               as: :json | ||||||
|  |  | ||||||
|  |           expect(response).to have_http_status(:success) | ||||||
|  |           expect(json_response[:role_mappings]).to eq({}) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as agent' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         get "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |             headers: agent.create_new_auth_token | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when SAML feature is not enabled' do | ||||||
|  |       before do | ||||||
|  |         account.disable_features('saml') | ||||||
|  |         account.save! | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns forbidden with feature not enabled message' do | ||||||
|  |         get "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |             headers: administrator.create_new_auth_token | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:forbidden) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'POST /api/v1/accounts/{account.id}/saml_settings' do | ||||||
|  |     let(:valid_params) do | ||||||
|  |       key = OpenSSL::PKey::RSA.new(2048) | ||||||
|  |       cert = OpenSSL::X509::Certificate.new | ||||||
|  |       cert.version = 2 | ||||||
|  |       cert.serial = 1 | ||||||
|  |       cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=test.example.com') | ||||||
|  |       cert.issuer = cert.subject | ||||||
|  |       cert.public_key = key.public_key | ||||||
|  |       cert.not_before = Time.zone.now | ||||||
|  |       cert.not_after = cert.not_before + (365 * 24 * 60 * 60) | ||||||
|  |       cert.sign(key, OpenSSL::Digest.new('SHA256')) | ||||||
|  |  | ||||||
|  |       { | ||||||
|  |         saml_settings: { | ||||||
|  |           sso_url: 'https://idp.example.com/saml/sso', | ||||||
|  |           certificate: cert.to_pem, | ||||||
|  |           idp_entity_id: 'https://idp.example.com/saml/metadata', | ||||||
|  |           role_mappings: { 'Admins' => { 'role' => 1 }, 'Users' => { 'role' => 0 } } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when unauthenticated' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         post "/api/v1/accounts/#{account.id}/saml_settings", params: valid_params | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as administrator' do | ||||||
|  |       context 'with valid parameters' do | ||||||
|  |         it 'creates SAML settings' do | ||||||
|  |           expect do | ||||||
|  |             post "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |                  params: valid_params, | ||||||
|  |                  headers: administrator.create_new_auth_token, | ||||||
|  |                  as: :json | ||||||
|  |           end.to change(AccountSamlSettings, :count).by(1) | ||||||
|  |  | ||||||
|  |           expect(response).to have_http_status(:success) | ||||||
|  |  | ||||||
|  |           saml_settings = AccountSamlSettings.find_by(account: account) | ||||||
|  |           expect(saml_settings.sso_url).to eq('https://idp.example.com/saml/sso') | ||||||
|  |           expect(saml_settings.role_mappings).to eq({ 'Admins' => { 'role' => 1 }, 'Users' => { 'role' => 0 } }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'with invalid parameters' do | ||||||
|  |         let(:invalid_params) do | ||||||
|  |           valid_params.tap do |params| | ||||||
|  |             params[:saml_settings][:sso_url] = nil | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |  | ||||||
|  |         it 'returns unprocessable entity' do | ||||||
|  |           post "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |                params: invalid_params, | ||||||
|  |                headers: administrator.create_new_auth_token, | ||||||
|  |                as: :json | ||||||
|  |  | ||||||
|  |           expect(response).to have_http_status(:unprocessable_entity) | ||||||
|  |           expect(AccountSamlSettings.count).to eq(0) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as agent' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         post "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |              params: valid_params, | ||||||
|  |              headers: agent.create_new_auth_token | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |         expect(AccountSamlSettings.count).to eq(0) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'PUT /api/v1/accounts/{account.id}/saml_settings' do | ||||||
|  |     let(:saml_settings) do | ||||||
|  |       create(:account_saml_settings, | ||||||
|  |              account: account, | ||||||
|  |              sso_url: 'https://old.example.com/saml') | ||||||
|  |     end | ||||||
|  |     let(:update_params) do | ||||||
|  |       key = OpenSSL::PKey::RSA.new(2048) | ||||||
|  |       cert = OpenSSL::X509::Certificate.new | ||||||
|  |       cert.version = 2 | ||||||
|  |       cert.serial = 3 | ||||||
|  |       cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=update.example.com') | ||||||
|  |       cert.issuer = cert.subject | ||||||
|  |       cert.public_key = key.public_key | ||||||
|  |       cert.not_before = Time.zone.now | ||||||
|  |       cert.not_after = cert.not_before + (365 * 24 * 60 * 60) | ||||||
|  |       cert.sign(key, OpenSSL::Digest.new('SHA256')) | ||||||
|  |  | ||||||
|  |       { | ||||||
|  |         saml_settings: { | ||||||
|  |           sso_url: 'https://new.example.com/saml/sso', | ||||||
|  |           certificate: cert.to_pem, | ||||||
|  |           role_mappings: { 'NewGroup' => { 'custom_role_id' => 5 } } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     before do | ||||||
|  |       saml_settings # Ensure the record exists | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when unauthenticated' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         put "/api/v1/accounts/#{account.id}/saml_settings", params: update_params | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as administrator' do | ||||||
|  |       it 'updates SAML settings' do | ||||||
|  |         put "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |             params: update_params, | ||||||
|  |             headers: administrator.create_new_auth_token, | ||||||
|  |             as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:success) | ||||||
|  |  | ||||||
|  |         saml_settings.reload | ||||||
|  |         expect(saml_settings.sso_url).to eq('https://new.example.com/saml/sso') | ||||||
|  |         expect(saml_settings.role_mappings).to eq({ 'NewGroup' => { 'custom_role_id' => 5 } }) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as agent' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         put "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |             params: update_params, | ||||||
|  |             headers: agent.create_new_auth_token | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'DELETE /api/v1/accounts/{account.id}/saml_settings' do | ||||||
|  |     let(:saml_settings) { create(:account_saml_settings, account: account) } | ||||||
|  |  | ||||||
|  |     before do | ||||||
|  |       saml_settings # Ensure the record exists | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when unauthenticated' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         delete "/api/v1/accounts/#{account.id}/saml_settings" | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as administrator' do | ||||||
|  |       it 'destroys SAML settings' do | ||||||
|  |         expect do | ||||||
|  |           delete "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |                  headers: administrator.create_new_auth_token | ||||||
|  |         end.to change(AccountSamlSettings, :count).by(-1) | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:no_content) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when authenticated as agent' do | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         delete "/api/v1/accounts/#{account.id}/saml_settings", | ||||||
|  |                headers: agent.create_new_auth_token | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |         expect(AccountSamlSettings.count).to eq(1) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										117
									
								
								spec/enterprise/models/account_saml_settings_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								spec/enterprise/models/account_saml_settings_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe AccountSamlSettings, type: :model do | ||||||
|  |   let(:account) { create(:account) } | ||||||
|  |   let(:saml_settings) { build(:account_saml_settings, account: account) } | ||||||
|  |  | ||||||
|  |   describe 'associations' do | ||||||
|  |     it { is_expected.to belong_to(:account) } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'validations' do | ||||||
|  |     it 'requires sso_url' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, sso_url: nil) | ||||||
|  |       expect(settings).not_to be_valid | ||||||
|  |       expect(settings.errors[:sso_url]).to include("can't be blank") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'requires certificate' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, certificate: nil) | ||||||
|  |       expect(settings).not_to be_valid | ||||||
|  |       expect(settings.errors[:certificate]).to include("can't be blank") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'requires idp_entity_id' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, idp_entity_id: nil) | ||||||
|  |       expect(settings).not_to be_valid | ||||||
|  |       expect(settings.errors[:idp_entity_id]).to include("can't be blank") | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#saml_enabled?' do | ||||||
|  |     it 'returns true when required fields are present' do | ||||||
|  |       settings = build(:account_saml_settings, | ||||||
|  |                        account: account, | ||||||
|  |                        sso_url: 'https://example.com/sso', | ||||||
|  |                        certificate: 'valid-certificate') | ||||||
|  |       expect(settings.saml_enabled?).to be true | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns false when sso_url is missing' do | ||||||
|  |       settings = build(:account_saml_settings, | ||||||
|  |                        account: account, | ||||||
|  |                        sso_url: nil, | ||||||
|  |                        certificate: 'valid-certificate') | ||||||
|  |       expect(settings.saml_enabled?).to be false | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns false when certificate is missing' do | ||||||
|  |       settings = build(:account_saml_settings, | ||||||
|  |                        account: account, | ||||||
|  |                        sso_url: 'https://example.com/sso', | ||||||
|  |                        certificate: nil) | ||||||
|  |       expect(settings.saml_enabled?).to be false | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'sp_entity_id auto-generation' do | ||||||
|  |     it 'automatically generates sp_entity_id when creating' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, sp_entity_id: nil) | ||||||
|  |       expect(settings).to be_valid | ||||||
|  |       settings.save! | ||||||
|  |       expect(settings.sp_entity_id).to eq("http://localhost:3000/saml/sp/#{account.id}") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'does not override existing sp_entity_id' do | ||||||
|  |       custom_id = 'https://custom.example.com/saml/sp/123' | ||||||
|  |       settings = build(:account_saml_settings, account: account, sp_entity_id: custom_id) | ||||||
|  |       settings.save! | ||||||
|  |       expect(settings.sp_entity_id).to eq(custom_id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#certificate_fingerprint' do | ||||||
|  |     let(:valid_cert_pem) do | ||||||
|  |       key = OpenSSL::PKey::RSA.new(2048) | ||||||
|  |       cert = OpenSSL::X509::Certificate.new | ||||||
|  |       cert.version = 2 | ||||||
|  |       cert.serial = 1 | ||||||
|  |       cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=test.example.com') | ||||||
|  |       cert.issuer = cert.subject | ||||||
|  |       cert.public_key = key.public_key | ||||||
|  |       cert.not_before = Time.zone.now | ||||||
|  |       cert.not_after = cert.not_before + (365 * 24 * 60 * 60) | ||||||
|  |       cert.sign(key, OpenSSL::Digest.new('SHA256')) | ||||||
|  |       cert.to_pem | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns fingerprint for valid certificate' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, certificate: valid_cert_pem) | ||||||
|  |       fingerprint = settings.certificate_fingerprint | ||||||
|  |  | ||||||
|  |       expect(fingerprint).to be_present | ||||||
|  |       expect(fingerprint).to match(/^[A-F0-9]{2}(:[A-F0-9]{2}){19}$/) # SHA1 fingerprint format | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns nil for blank certificate' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, certificate: '') | ||||||
|  |       expect(settings.certificate_fingerprint).to be_nil | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns nil for invalid certificate' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, certificate: 'invalid-cert-data') | ||||||
|  |       expect(settings.certificate_fingerprint).to be_nil | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'formats fingerprint correctly' do | ||||||
|  |       settings = build(:account_saml_settings, account: account, certificate: valid_cert_pem) | ||||||
|  |       fingerprint = settings.certificate_fingerprint | ||||||
|  |  | ||||||
|  |       # Should be uppercase with colons separating each byte | ||||||
|  |       expect(fingerprint).to match(/^[A-F0-9:]+$/) | ||||||
|  |       expect(fingerprint.count(':')).to eq(19) # 20 bytes = 19 colons | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										31
									
								
								spec/factories/account_saml_settings.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								spec/factories/account_saml_settings.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | FactoryBot.define do | ||||||
|  |   factory :account_saml_settings do | ||||||
|  |     account | ||||||
|  |     sso_url { 'https://idp.example.com/saml/sso' } | ||||||
|  |     certificate do | ||||||
|  |       key = OpenSSL::PKey::RSA.new(2048) | ||||||
|  |       cert = OpenSSL::X509::Certificate.new | ||||||
|  |       cert.version = 2 | ||||||
|  |       cert.serial = 1 | ||||||
|  |       cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=test.example.com') | ||||||
|  |       cert.issuer = cert.subject | ||||||
|  |       cert.public_key = key.public_key | ||||||
|  |       cert.not_before = Time.zone.now | ||||||
|  |       cert.not_after = cert.not_before + (365 * 24 * 60 * 60) | ||||||
|  |       cert.sign(key, OpenSSL::Digest.new('SHA256')) | ||||||
|  |       cert.to_pem | ||||||
|  |     end | ||||||
|  |     idp_entity_id { 'https://idp.example.com/saml/metadata' } | ||||||
|  |     role_mappings { {} } | ||||||
|  |  | ||||||
|  |     trait :with_role_mappings do | ||||||
|  |       role_mappings do | ||||||
|  |         { | ||||||
|  |           'Administrators' => { 'role' => 1 }, | ||||||
|  |           'Agents' => { 'role' => 0 }, | ||||||
|  |           'Custom-Team' => { 'custom_role_id' => 5 } | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra