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:
Shivam Mishra
2025-09-04 02:00:42 +05:30
committed by GitHub
parent b46c07519a
commit 33058b5f3f
17 changed files with 590 additions and 1 deletions

View File

@@ -204,3 +204,7 @@
enabled: false
premium: true
chatwoot_internal: true
- name: saml
display_name: SAML
enabled: false
premium: true

View File

@@ -55,6 +55,8 @@ en:
failed: Signup failed
assignment_policy:
not_found: Assignment policy not found
saml:
feature_not_enabled: SAML feature not enabled for this account
data_import:
data_type:
invalid: Invalid data type

View File

@@ -69,6 +69,7 @@ Rails.application.routes.draw do
end
resources :documents, only: [:index, :show, :create, :destroy]
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
post :reset_access_token, on: :member

View 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

View File

@@ -10,7 +10,7 @@
#
# 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
enable_extension "pg_stat_statements"
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
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|
t.bigint "account_id"
t.bigint "user_id"

View File

@@ -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

View 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

View File

@@ -27,4 +27,8 @@ module Enterprise::Account
def unmark_for_deletion
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
def saml_enabled?
saml_settings&.saml_enabled? || false
end
end

View File

@@ -13,5 +13,7 @@ module Enterprise::Concerns::Account
has_many :copilot_threads, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
end
end

View 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

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings

View File

@@ -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

View File

@@ -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

View 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

View 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