mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 10:12:34 +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