Merge branch 'main' into CP-10361

pull/544/head
Bernardo Anderson 4 months ago committed by GitHub
commit d4a0dd379a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -3,4 +3,4 @@ DB_PASSWORD=postgres
DB_PORT=5432 DB_PORT=5432
DB_USERNAME=postgres DB_USERNAME=postgres
REDIS_URL=redis://host.docker.internal:6379/7 REDIS_URL=redis://host.docker.internal:6379/7
PORT=3000 PORT=3001

@ -0,0 +1,15 @@
DB_HOST=
DB_POOL=25
DB_PORT=5432
DB_SSLCERT=/config/rds-combined-ca-bundle.pem
DB_SSLMODE=verify-full
REDIS_URL=
PORT=3000
S3_ATTACHMENTS_BUCKET=
ACTIVE_STORAGE_PUBLIC=true
FORCE_SSL=true
AIRBRAKE_ID=
AIRBRAKE_KEY=
NEWRELIC_LICENSE_KEY=
NEWRELIC_APP_NAME=
WEB_CONCURRENCY=2

@ -8,3 +8,9 @@ PORT=3000
S3_ATTACHMENTS_BUCKET= S3_ATTACHMENTS_BUCKET=
ACTIVE_STORAGE_PUBLIC=true ACTIVE_STORAGE_PUBLIC=true
FORCE_SSL=true FORCE_SSL=true
AIRBRAKE_ID=
AIRBRAKE_KEY=
NEWRELIC_LICENSE_KEY=
NEWRELIC_APP_NAME=
NEWRELIC_MONITOR_MODE=
WEB_CONCURRENCY=2

@ -79,6 +79,7 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./ COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version COPY .version ./public/version
COPY ./.env.staging ./.env.staging COPY ./.env.staging ./.env.staging
COPY ./.env.production ./.env.production
COPY ./config/rds-combined-ca-bundle.pem /config/rds-combined-ca-bundle.pem COPY ./config/rds-combined-ca-bundle.pem /config/rds-combined-ca-bundle.pem
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts

@ -44,6 +44,9 @@ gem 'turbo-rails'
gem 'twitter_cldr', require: false gem 'twitter_cldr', require: false
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'airbrake'
gem 'newrelic_rpm'
group :development, :test do group :development, :test do
gem 'better_html' gem 'better_html'
gem 'bullet' gem 'bullet'

@ -74,6 +74,10 @@ GEM
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
airbrake (13.0.5)
airbrake-ruby (~> 6.0)
airbrake-ruby (6.2.2)
rbtree3 (~> 0.6)
annotaterb (4.14.0) annotaterb (4.14.0)
arabic-letter-connector (0.1.1) arabic-letter-connector (0.1.1)
ast (2.4.2) ast (2.4.2)
@ -336,6 +340,7 @@ GEM
timeout timeout
net-smtp (0.5.0) net-smtp (0.5.0)
net-protocol net-protocol
newrelic_rpm (9.17.0)
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.18.8) nokogiri (1.18.8)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
@ -433,6 +438,7 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rbtree3 (0.7.1)
rdoc (6.10.0) rdoc (6.10.0)
psych (>= 4.0.0) psych (>= 4.0.0)
redis-client (0.23.0) redis-client (0.23.0)
@ -588,6 +594,7 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
airbrake
annotaterb annotaterb
arabic-letter-connector arabic-letter-connector
aws-sdk-s3 aws-sdk-s3
@ -618,6 +625,7 @@ DEPENDENCIES
jwt jwt
letter_opener_web letter_opener_web
lograge lograge
newrelic_rpm
oj oj
pagy pagy
pg pg

@ -4,7 +4,7 @@ module Api
class ActiveStorageBlobsProxyController < ApiBaseController class ActiveStorageBlobsProxyController < ApiBaseController
include ActiveStorage::Streaming include ActiveStorage::Streaming
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
before_action :set_cors_headers before_action :set_cors_headers

@ -4,7 +4,7 @@ module Api
class ActiveStorageBlobsProxyLegacyController < ApiBaseController class ActiveStorageBlobsProxyLegacyController < ApiBaseController
include ActiveStorage::Streaming include ActiveStorage::Streaming
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
before_action :set_cors_headers before_action :set_cors_headers

@ -12,7 +12,7 @@ module Api
wrap_parameters false wrap_parameters false
before_action :authenticate_user! before_action :authenticate_via_token!
check_authorization check_authorization
rescue_from Params::BaseValidator::InvalidParameterError do |e| rescue_from Params::BaseValidator::InvalidParameterError do |e|
@ -81,7 +81,7 @@ module Api
result result
end end
def authenticate_user! def authenticate_via_token!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end end

@ -0,0 +1,32 @@
# frozen_string_literal: true
module Api
class ExternalAuthController < Api::ApiBaseController
skip_before_action :authenticate_via_token!
skip_authorization_check
def user_token
account = Account.find_or_create_by_external_id(
params[:account][:external_id]&.to_i,
name: params[:account][:name],
locale: params[:account][:locale] || 'en-US',
timezone: params[:account][:timezone] || 'UTC'
)
user = User.find_or_create_by_external_id(
account,
params[:user][:external_id]&.to_i,
email: params[:user][:email],
first_name: params[:user][:first_name],
last_name: params[:user][:last_name],
role: 'admin'
)
render json: { access_token: user.access_token.token }
rescue StandardError => e
Rails.logger.error("External auth error: #{e.message}")
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Internal server error' }, status: :internal_server_error
end
end
end

@ -2,7 +2,7 @@
module Api module Api
class SubmitterEmailClicksController < ApiBaseController class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def create def create

@ -2,7 +2,7 @@
module Api module Api
class SubmitterFormViewsController < ApiBaseController class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def create def create

@ -6,14 +6,12 @@ class ApplicationController < ActionController::Base
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
include Pagy::Backend include Pagy::Backend
before_action :ensure_demo_user_signed_in
check_authorization unless: :devise_controller? check_authorization unless: :devise_controller?
around_action :with_locale around_action :with_locale
# before_action :sign_in_for_demo, if: -> { Docuseal.demo? } # before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
before_action :maybe_redirect_to_setup, unless: :signed_in? before_action :maybe_authenticate_via_token
before_action :authenticate_user!, unless: :devise_controller? before_action :authenticate_via_token!, unless: :devise_controller?
helper_method :button_title, helper_method :button_title,
:current_account, :current_account,
@ -102,34 +100,42 @@ class ApplicationController < ActionController::Base
current_user&.account current_user&.account
end end
def maybe_redirect_to_setup def maybe_authenticate_via_token
# Skip setup redirect for iframe embedding - create demo user instead return if signed_in?
return if ensure_demo_user_signed_in
redirect_to setup_index_path unless User.exists? # Check for token in params, session, or X-Auth-Token header
end token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token']
return if token.blank?
# Try to find user by token and sign them in
sha256 = Digest::SHA256.hexdigest(token)
user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 })
def ensure_demo_user_signed_in return unless user
return true if signed_in?
user = find_or_create_demo_user
sign_in(user) sign_in(user)
true session[:auth_token] = token
end end
def find_or_create_demo_user # Enhanced authentication that tries token auth and fails with error if no user found
User.find_by(email: 'demo@docuseal.local') || begin # Use this when you need to enforce authentication with better token handling
account = Account.create!(name: 'Demo Account', locale: 'en', timezone: 'UTC') def authenticate_via_token!
User.create!( return if signed_in?
email: 'demo@docuseal.local',
password: 'password123', token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token']
password_confirmation: 'password123',
first_name: 'Demo', if token.present?
last_name: 'User', sha256 = Digest::SHA256.hexdigest(token)
account: account, user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 })
role: 'admin'
) if user
sign_in(user)
session[:auth_token] = token
return
end
end end
render json: { error: 'Authentication required. Please provide a valid auth_token.' }, status: :unauthorized
end end
def button_title(title: I18n.t('submit'), disabled_with: I18n.t('submitting'), title_class: '', icon: nil, def button_title(title: I18n.t('submit'), disabled_with: I18n.t('submitting'), title_class: '', icon: nil,

@ -0,0 +1,37 @@
# frozen_string_literal: true
module IframeAuthentication
extend ActiveSupport::Concern
private
# Custom authentication for iframe context
# AJAX requests from Vue components don't include the auth token that's in the iframe URL,
# so we extract it from the HTTP referer header as a fallback
def authenticate_from_referer
return if signed_in?
token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token']
# If no token found, extract from referer URL (iframe page has the token)
if token.blank? && request.referer.present?
referer_uri = URI.parse(request.referer)
referer_params = CGI.parse(referer_uri.query || '')
token = referer_params['auth_token']&.first
end
if token.present?
sha256 = Digest::SHA256.hexdigest(token)
user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 })
return unless user
sign_in(user)
session[:auth_token] = token
return
end
Rails.logger.error "#{self.class.name}: Authentication failed"
render json: { error: 'Authentication required' }, status: :unauthorized
end
end

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ConsoleRedirectController < ApplicationController class ConsoleRedirectController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def index def index

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class DashboardController < ApplicationController class DashboardController < ApplicationController
skip_before_action :authenticate_user!, only: %i[index] skip_before_action :authenticate_via_token!, only: %i[index]
before_action :maybe_redirect_product_url before_action :maybe_redirect_product_url
before_action :maybe_render_landing before_action :maybe_render_landing

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class EnquiriesController < ApplicationController class EnquiriesController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def create def create

@ -4,7 +4,6 @@ require 'faraday'
class ExportController < ApplicationController class ExportController < ApplicationController
skip_authorization_check skip_authorization_check
skip_before_action :maybe_redirect_to_setup
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
# Send template to third party. # Send template to third party.

@ -3,7 +3,7 @@
class SendSubmissionEmailController < ApplicationController class SendSubmissionEmailController < ApplicationController
layout 'form' layout 'form'
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
skip_authorization_check skip_authorization_check

@ -1,8 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SetupController < ApplicationController class SetupController < ApplicationController
skip_before_action :maybe_redirect_to_setup skip_before_action :authenticate_via_token!
skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
before_action :redirect_to_root_if_signed, if: :signed_in? before_action :redirect_to_root_if_signed, if: :signed_in?

@ -3,7 +3,7 @@
class StartFormController < ApplicationController class StartFormController < ApplicationController
layout 'form' layout 'form'
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
around_action :with_browser_locale, only: %i[show completed] around_action :with_browser_locale, only: %i[show completed]

@ -3,7 +3,7 @@
class SubmissionsDebugController < ApplicationController class SubmissionsDebugController < ApplicationController
layout 'plain' layout 'plain'
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def index def index

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmissionsDownloadController < ApplicationController class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
TTL = 40.minutes TTL = 40.minutes

@ -2,7 +2,7 @@
class SubmissionsPreviewController < ApplicationController class SubmissionsPreviewController < ApplicationController
around_action :with_browser_locale around_action :with_browser_locale
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
prepend_before_action :maybe_redirect_com, only: %i[show completed] prepend_before_action :maybe_redirect_com, only: %i[show completed]

@ -4,7 +4,7 @@ class SubmitFormController < ApplicationController
layout 'form' layout 'form'
around_action :with_browser_locale, only: %i[show completed success] around_action :with_browser_locale, only: %i[show completed success]
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
skip_before_action :verify_authenticity_token, only: :update skip_before_action :verify_authenticity_token, only: :update

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmitFormDeclineController < ApplicationController class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def create def create

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmitFormDownloadController < ApplicationController class SubmitFormDownloadController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
FILES_TTL = 5.minutes FILES_TTL = 5.minutes

@ -4,7 +4,7 @@ class SubmitFormDrawSignatureController < ApplicationController
layout false layout false
around_action :with_browser_locale, only: %i[show] around_action :with_browser_locale, only: %i[show]
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def show def show

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmitFormInviteController < ApplicationController class SubmitFormInviteController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def create def create

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmitFormValuesController < ApplicationController class SubmitFormValuesController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_via_token!
skip_authorization_check skip_authorization_check
def index def index

@ -1,8 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmittersRequestChangesController < ApplicationController class SubmittersRequestChangesController < ApplicationController
before_action :load_submitter include IframeAuthentication
skip_before_action :verify_authenticity_token, only: :request_changes skip_before_action :verify_authenticity_token, only: :request_changes
skip_before_action :authenticate_via_token!, only: :request_changes
before_action :authenticate_from_referer, only: :request_changes
before_action :load_submitter
def request_changes def request_changes
if request.get? || request.head? if request.get? || request.head?
@ -48,9 +52,9 @@ class SubmittersRequestChangesController < ApplicationController
end end
def can_request_changes? def can_request_changes?
# Only the user who created the submission can request changes # Only the template author (manager) can request changes from submitters
# Only for completed submissions that haven't been declined # Only for completed submissions that haven't been declined
current_user == @submitter.submission.created_by_user && current_user == @submitter.submission.template.author &&
@submitter.completed_at? && @submitter.completed_at? &&
!@submitter.declined_at? && !@submitter.declined_at? &&
!@submitter.changes_requested_at? !@submitter.changes_requested_at?

@ -1,8 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplateDocumentsController < ApplicationController class TemplateDocumentsController < ApplicationController
include IframeAuthentication
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
load_and_authorize_resource :template skip_before_action :authenticate_via_token!
before_action :authenticate_from_referer
load_and_authorize_resource :template, id_param: :template_id
def create def create
if params[:blobs].blank? && params[:files].blank? if params[:blobs].blank? && params[:files].blank?

@ -2,12 +2,13 @@
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
include PrefillFieldsHelper include PrefillFieldsHelper
include IframeAuthentication
skip_before_action :maybe_redirect_to_setup
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
skip_before_action :authenticate_via_token!, only: [:update]
before_action :authenticate_from_referer, only: [:update]
load_and_authorize_resource :template load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create] before_action :load_base_template, only: %i[new create]
def show def show
@ -67,6 +68,7 @@ class TemplatesController < ApplicationController
name: params.dig(:template, :name), name: params.dig(:template, :name),
folder_name: params[:folder_name]) folder_name: params[:folder_name])
else else
@template = Template.new(template_params) if @template.nil?
@template.author = current_user @template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end end

@ -1,10 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplatesDashboardController < ApplicationController class TemplatesDashboardController < ApplicationController
before_action :ensure_demo_user_signed_in
skip_before_action :authenticate_user!
skip_before_action :maybe_redirect_to_setup
load_and_authorize_resource :template_folder, parent: false load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false load_and_authorize_resource :template, parent: false

@ -4,18 +4,20 @@
# #
# Table name: accounts # Table name: accounts
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# archived_at :datetime # archived_at :datetime
# locale :string not null # locale :string not null
# name :string not null # name :string not null
# timezone :string not null # timezone :string not null
# uuid :string not null # uuid :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# external_account_id :integer
# #
# Indexes # Indexes
# #
# index_accounts_on_uuid (uuid) UNIQUE # index_accounts_on_external_account_id (external_account_id) UNIQUE
# index_accounts_on_uuid (uuid) UNIQUE
# #
class Account < ApplicationRecord class Account < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
@ -53,8 +55,15 @@ class Account < ApplicationRecord
attribute :timezone, :string, default: 'UTC' attribute :timezone, :string, default: 'UTC'
attribute :locale, :string, default: 'en-US' attribute :locale, :string, default: 'en-US'
validates :external_account_id, uniqueness: true, allow_nil: true
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
def self.find_or_create_by_external_id(external_id, attributes = {})
find_by(external_account_id: external_id) ||
create!(attributes.merge(external_account_id: external_id))
end
def testing? def testing?
linked_account_account&.testing? linked_account_account&.testing?
end end

@ -29,11 +29,13 @@
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer not null # account_id :integer not null
# external_user_id :integer
# #
# Indexes # Indexes
# #
# index_users_on_account_id (account_id) # index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE # index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE # index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE # index_users_on_uuid (uuid) UNIQUE
@ -74,6 +76,17 @@ class User < ApplicationRecord
scope :admins, -> { where(role: ADMIN_ROLE) } scope :admins, -> { where(role: ADMIN_ROLE) }
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ } validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
validates :external_user_id, uniqueness: true, allow_nil: true
def self.find_or_create_by_external_id(account, external_id, attributes = {})
account.users.find_by(external_user_id: external_id) ||
account.users.create!(
attributes.merge(
external_user_id: external_id,
password: SecureRandom.hex(16)
)
)
end
def access_token def access_token
super || build_access_token.tap(&:save!) super || build_access_token.tap(&:save!)

@ -235,7 +235,7 @@
<%= button_to t('resubmit'), submitters_resubmit_path(submitter), method: :put, class: 'btn btn-sm btn-primary w-full', form: { target: '_blank' }, data: { turbo: false } %> <%= button_to t('resubmit'), submitters_resubmit_path(submitter), method: :put, class: 'btn btn-sm btn-primary w-full', form: { target: '_blank' }, data: { turbo: false } %>
</div> </div>
<% end %> <% end %>
<% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.created_by_user %> <% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.template.author %>
<div class="mt-2 mb-1"> <div class="mt-2 mb-1">
<%= link_to 'Request Changes', request_changes_submitter_path(submitter.slug), <%= link_to 'Request Changes', request_changes_submitter_path(submitter.slug),
class: 'btn btn-sm btn-warning w-full', class: 'btn btn-sm btn-warning w-full',

@ -0,0 +1,271 @@
#!/bin/sh -e
echo "=== CP Docuseal Production Startup ==="
# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ]; then
LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit)
export LD_PRELOAD
fi
check_aws_setup() {
if [ -z "$AWS_REGION" ]; then
echo "ERROR: AWS_REGION environment variable is not set"
exit 1
fi
if ! command -v aws &> /dev/null; then
echo "ERROR: AWS CLI is not installed. Please install it to proceed."
exit 1
fi
}
# Function to fetch secrets from AWS Secrets Manager
fetch_db_credentials() {
echo "Fetching database credentials from AWS Secrets Manager..."
if [ -z "$DB_SECRETS_NAME" ]; then
echo "ERROR: DB_SECRETS_NAME environment variable is not set"
exit 1
fi
# Fetch the secret
echo "Retrieving secret: $DB_SECRETS_NAME"
SECRET_JSON=$(aws secretsmanager get-secret-value \
--region "$AWS_REGION" \
--secret-id "$DB_SECRETS_NAME" \
--query SecretString \
--output text)
if [ $? -ne 0 ]; then
echo "ERROR: Failed to retrieve secrets from AWS Secrets Manager"
exit 1
fi
# Parse JSON and export environment variables
export DB_USERNAME=$(echo "$SECRET_JSON" | jq -r '.username')
export DB_PASSWORD=$(echo "$SECRET_JSON" | jq -r '.password')
# Validate that we got the credentials
if [ "$DB_USERNAME" = "null" ] || [ "$DB_PASSWORD" = "null" ] || [ -z "$DB_USERNAME" ] || [ -z "$DB_PASSWORD" ]; then
echo "ERROR: Failed to parse database credentials from secrets"
echo "Expected JSON format: {\"username\": \"...\", \"password\": \"...\"}"
exit 1
fi
# Write credentials to .env.production file
echo "Writing database credentials to .env.production..."
# Remove existing DB_USERNAME and DB_PASSWORD lines if they exist
if [ -f "./.env.production" ]; then
echo "Removing existing DB_USERNAME and DB_PASSWORD from .env.production"
grep -v "^DB_USERNAME=" ./.env.production > ./.env.production.tmp || true
grep -v "^DB_PASSWORD=" ./.env.production.tmp > ./.env.production || true
rm -f ./.env.production.tmp
fi
# Append the new credentials
echo "DB_USERNAME=$DB_USERNAME" >> ./.env.production
echo "DB_PASSWORD=$DB_PASSWORD" >> ./.env.production
echo "✓ Database credentials successfully retrieved and written to .env.production"
}
# Function to fetch encryption key from AWS Secrets Manager and write to config/master.key
fetch_encryption_key() {
echo "Fetching encryption key from AWS Secrets Manager..."
ENCRYPTION_SECRET_NAME="cpdocuseal/encryption_key"
if [ -z "$AWS_REGION" ]; then
echo "ERROR: AWS_REGION environment variable is not set"
exit 1
fi
# Fetch the secret value (assume it's a plain string, not JSON)
ENCRYPTION_KEY=$(aws secretsmanager get-secret-value \
--region "$AWS_REGION" \
--secret-id "$ENCRYPTION_SECRET_NAME" \
--query SecretString \
--output text)
if [ $? -ne 0 ] || [ -z "$ENCRYPTION_KEY" ] || [ "$ENCRYPTION_KEY" = "null" ]; then
echo "ERROR: Failed to retrieve encryption key from AWS Secrets Manager"
exit 1
fi
# Write the key to config/master.key
echo -n "$ENCRYPTION_KEY" > config/master.key
chmod 600 config/master.key
echo "✓ Encryption key written to config/master.key"
}
# Function to fetch allowed hosts values
fetch_allowed_hosts() {
echo "Fetching allowed hosts from AWS Secrets Manager..."
if [ -z "$ALLOWED_HOSTS_NAME" ]; then
echo "ERROR: ALLOWED_HOSTS_NAME environment variable is not set"
exit 1
fi
# Fetch the secret value, assume kept as JSON array
ALLOWED_HOSTS_JSON=$(aws secretsmanager get-secret-value \
--region "$AWS_REGION" \
--secret-id "$ALLOWED_HOSTS_NAME" \
--query SecretString \
--output text)
if [ $? -ne 0 ] || [ -z "$ALLOWED_HOSTS_JSON" ] || [ "$ALLOWED_HOSTS_JSON" = "null" ]; then
echo "ERROR: Failed to retrieve allowed hosts from AWS Secrets Manager"
exit 1
fi
# Extract the array and convert to comma-separated string
ALLOWED_HOSTS=$(echo "$ALLOWED_HOSTS_JSON" | jq -r '.allowed_hosts | join(",")')
if [ -z "$ALLOWED_HOSTS" ] || [ "$ALLOWED_HOSTS" = "null" ]; then
echo "ERROR: Failed to parse allowed hosts from secrets. Check that the secret contains 'allowed_hosts' key."
exit 1
fi
# Write allowed hosts to .env.production file
echo "Writing allowed hosts to .env.production..."
echo "ALLOWED_HOSTS=$ALLOWED_HOSTS" >> ./.env.production
echo "✓ Allowed hosts successfully retrieved and written to .env.production"
}
# Function to fetch various environment variables and write to .env file for use by app
fetch_env_variables() {
echo "Fetching environment variables from AWS Secrets Manager..."
if [ -z "$CP_VARIABLES_NAME" ]; then
echo "ERROR: CP_VARIABLES_NAME environment variable is not set"
exit 1
fi
# Fetch the secret
echo "Retrieving secret: $CP_VARIABLES_NAME"
SECRET_JSON=$(aws secretsmanager get-secret-value \
--region "$AWS_REGION" \
--secret-id "$CP_VARIABLES_NAME" \
--query SecretString \
--output text)
if [ $? -ne 0 ]; then
echo "ERROR: Failed to retrieve secrets from AWS Secrets Manager"
exit 1
fi
export DB_HOST=$(echo "$SECRET_JSON" | jq -r '.host')
export REDIS_URL=$(echo "$SECRET_JSON" | jq -r '.redis_url')
export S3_ATTACHMENTS_BUCKET=$(echo "$SECRET_JSON" | jq -r '.s3_attachments_bucket')
export AIRBRAKE_ID=$(echo "$SECRET_JSON" | jq -r '.airbrake_id')
export AIRBRAKE_KEY=$(echo "$SECRET_JSON" | jq -r '.airbrake_key')
export NEWRELIC_LICENSE_KEY=$(echo "$SECRET_JSON" | jq -r '.newrelic_license_key')
export NEWRELIC_APP_NAME=$(echo "$SECRET_JSON" | jq -r '.newrelic_app_name')
export NEWRELIC_MONITOR_MODE=$(echo "$SECRET_JSON" | jq -r '.newrelic_monitor_mode')
# Validate that we got the values
if [ "$DB_HOST" = "null" ] || [ "$REDIS_URL" = "null" ] || [ "$S3_ATTACHMENTS_BUCKET" = "null" ] || [ -z "$DB_HOST" ] || [ -z "$REDIS_URL" ] || [ -z "$S3_ATTACHMENTS_BUCKET" ]; then
echo "ERROR: Failed to parse variables from secrets"
echo "Expected JSON format: {\"key\": \"...\", ...}"
exit 1
fi
# Validate license keys exist for logging
if [ "$AIRBRAKE_ID" = "null" ] || [ "$AIRBRAKE_KEY" = "null" ] || [ "$NEWRELIC_LICENSE_KEY" = "null" ] || [ "$NEWRELIC_APP_NAME" = "null" ]; then
echo "ERROR: One or more monitor/logging license keys are missing"
exit 1
fi
# Write variables to .env.production file
echo "Writing environment variables to .env.production..."
# Remove existing DB_HOST, REDIS_URL, and S3_ATTACHMENTS_BUCKET lines if they exist
if [ -f "./.env.production" ]; then
echo "Removing existing variables from .env.production"
grep -v "^DB_HOST=" ./.env.production > ./.env.production.tmp || true
grep -v "^REDIS_URL=" ./.env.production.tmp > ./.env.production || true
grep -v "^S3_ATTACHMENTS_BUCKET=" ./.env.production.tmp > ./.env.production || true
grep -v "^AIRBRAKE_ID=" ./.env.production.tmp > ./.env.production || true
grep -v "^AIRBRAKE_KEY=" ./.env.production.tmp > ./.env.production || true
grep -v "^NEWRELIC_LICENSE_KEY=" ./.env.production.tmp > ./.env.production || true
grep -v "^NEWRELIC_APP_NAME=" ./.env.production.tmp > ./.env.production || true
grep -v "^NEWRELIC_MONITOR_MODE=" ./.env.production.tmp > ./.env.production || true
rm -f ./.env.production.tmp
fi
# Append the new credentials
echo "DB_HOST=$DB_HOST" >> ./.env.production
echo "REDIS_URL=$REDIS_URL" >> ./.env.production
echo "S3_ATTACHMENTS_BUCKET=$S3_ATTACHMENTS_BUCKET" >> ./.env.production
echo "AIRBRAKE_ID=$AIRBRAKE_ID" >> ./.env.production
echo "AIRBRAKE_KEY=$AIRBRAKE_KEY" >> ./.env.production
echo "NEWRELIC_LICENSE_KEY=$NEWRELIC_LICENSE_KEY" >> ./.env.production
echo "NEWRELIC_APP_NAME=$NEWRELIC_APP_NAME" >> ./.env.production
echo "NEWRELIC_MONITOR_MODE=$NEWRELIC_MONITOR_MODE" >> ./.env.production
echo "✓ Environment variables successfully retrieved and written to .env.production"
}
# Function to setup database
setup_database() {
echo "Running database migrations..."
./bin/rails db:migrate
if [ $? -eq 0 ]; then
echo "✓ Database migrations completed successfully"
else
echo "ERROR: Database migrations failed"
exit 1
fi
}
set_environment() {
if [ -f "./.env.production" ]; then
echo "Setting environment variables from .env.production"
set -a
. ./.env.production
set +a
fi
}
# Main execution
main() {
cd ../../app/
set_environment
check_aws_setup
echo "Starting CP Docuseal in production mode..."
echo "Rails Environment: ${RAILS_ENV:-production}"
# Fetch database credentials from Secrets Manager
fetch_db_credentials
# Fetch encryption key and write to config/master.key
fetch_encryption_key
# Fetch allowed hosts from Secrets Manager
fetch_allowed_hosts
# Fetch other environment variables from Secrets Manager
fetch_env_variables
# Load updated environment variables
set_environment
# Setup and migrate database
setup_database
echo "=== Startup Complete - Starting Rails Server ==="
echo "Database Host: ${DB_HOST:-not set}"
echo "Database Port: ${DB_PORT:-not set}"
# Start the Rails server
exec ./bin/rails server -b 0.0.0.0 -p "${PORT:-3000}"
}
# Execute main function
main "$@"

@ -71,6 +71,46 @@ fetch_db_credentials() {
echo "✓ Database credentials successfully retrieved and written to .env.staging" echo "✓ Database credentials successfully retrieved and written to .env.staging"
} }
# Function to fetch allowed hosts from AWS Secrets Manager and write to .env.staging
fetch_allowed_hosts() {
echo "Fetching allowed hosts from AWS Secrets Manager..."
if [ -z "$ALLOWED_HOSTS_NAME" ]; then
echo "ERROR: ALLOWED_HOSTS_NAME environment variable is not set"
exit 1
fi
# Fetch the secret value, assume kept as JSON array
ALLOWED_HOSTS_JSON=$(aws secretsmanager get-secret-value \
--region "$AWS_REGION" \
--secret-id "$ALLOWED_HOSTS_NAME" \
--query SecretString \
--output text)
if [ $? -ne 0 ] || [ -z "$ALLOWED_HOSTS_JSON" ] || [ "$ALLOWED_HOSTS_JSON" = "null" ]; then
echo "ERROR: Failed to retrieve allowed hosts from AWS Secrets Manager"
exit 1
fi
# Extract the array and convert to comma-separated string
ALLOWED_HOSTS=$(echo "$ALLOWED_HOSTS_JSON" | jq -r '.allowed_hosts | join(",")')
if [ -z "$ALLOWED_HOSTS" ] || [ "$ALLOWED_HOSTS" = "null" ]; then
echo "ERROR: Failed to parse allowed hosts from secrets. Check that the secret contains 'allowed_hosts' key."
exit 1
fi
# Remove existing ALLOWED_HOSTS line if it exists
if [ -f "./.env.staging" ]; then
grep -v "^ALLOWED_HOSTS=" ./.env.staging > ./.env.staging.tmp || true
mv ./.env.staging.tmp ./.env.staging
fi
# Append the new allowed hosts
echo "ALLOWED_HOSTS=$ALLOWED_HOSTS" >> ./.env.staging
echo "✓ Allowed hosts successfully retrieved and written to .env.staging"
}
# Function to fetch encryption key from AWS Secrets Manager and write to config/master.key # Function to fetch encryption key from AWS Secrets Manager and write to config/master.key
fetch_encryption_key() { fetch_encryption_key() {
echo "Fetching encryption key from AWS Secrets Manager..." echo "Fetching encryption key from AWS Secrets Manager..."
@ -123,6 +163,12 @@ fetch_env_variables() {
export DB_HOST=$(echo "$SECRET_JSON" | jq -r '.host') export DB_HOST=$(echo "$SECRET_JSON" | jq -r '.host')
export REDIS_URL=$(echo "$SECRET_JSON" | jq -r '.redis_url') export REDIS_URL=$(echo "$SECRET_JSON" | jq -r '.redis_url')
export S3_ATTACHMENTS_BUCKET=$(echo "$SECRET_JSON" | jq -r '.s3_attachments_bucket') export S3_ATTACHMENTS_BUCKET=$(echo "$SECRET_JSON" | jq -r '.s3_attachments_bucket')
export AIRBRAKE_ID=$(echo "$SECRET_JSON" | jq -r '.airbrake_id')
export AIRBRAKE_KEY=$(echo "$SECRET_JSON" | jq -r '.airbrake_key')
export NEWRELIC_LICENSE_KEY=$(echo "$SECRET_JSON" | jq -r '.newrelic_license_key')
export NEWRELIC_APP_NAME=$(echo "$SECRET_JSON" | jq -r '.newrelic_app_name')
export NEWRELIC_MONITOR_MODE=$(echo "$SECRET_JSON" | jq -r '.newrelic_monitor_mode')
# Validate that we got the values # Validate that we got the values
if [ "$DB_HOST" = "null" ] || [ "$REDIS_URL" = "null" ] || [ "$S3_ATTACHMENTS_BUCKET" = "null" ] || [ -z "$DB_HOST" ] || [ -z "$REDIS_URL" ] || [ -z "$S3_ATTACHMENTS_BUCKET" ]; then if [ "$DB_HOST" = "null" ] || [ "$REDIS_URL" = "null" ] || [ "$S3_ATTACHMENTS_BUCKET" = "null" ] || [ -z "$DB_HOST" ] || [ -z "$REDIS_URL" ] || [ -z "$S3_ATTACHMENTS_BUCKET" ]; then
@ -130,6 +176,12 @@ fetch_env_variables() {
echo "Expected JSON format: {\"key\": \"...\", ...}" echo "Expected JSON format: {\"key\": \"...\", ...}"
exit 1 exit 1
fi fi
# Validate license keys exist for logging
if [ "$AIRBRAKE_ID" = "null" ] || [ "$AIRBRAKE_KEY" = "null" ] || [ "$NEWRELIC_LICENSE_KEY" = "null" ] || [ "$NEWRELIC_APP_NAME" = "null" ]; then
echo "ERROR: One or more monitor/logging license keys are missing"
exit 1
fi
# Write variables to .env.staging file # Write variables to .env.staging file
echo "Writing environment variables to .env.staging..." echo "Writing environment variables to .env.staging..."
@ -140,6 +192,11 @@ fetch_env_variables() {
grep -v "^DB_HOST=" ./.env.staging > ./.env.staging.tmp || true grep -v "^DB_HOST=" ./.env.staging > ./.env.staging.tmp || true
grep -v "^REDIS_URL=" ./.env.staging.tmp > ./.env.staging || true grep -v "^REDIS_URL=" ./.env.staging.tmp > ./.env.staging || true
grep -v "^S3_ATTACHMENTS_BUCKET=" ./.env.staging.tmp > ./.env.staging || true grep -v "^S3_ATTACHMENTS_BUCKET=" ./.env.staging.tmp > ./.env.staging || true
grep -v "^AIRBRAKE_ID=" ./.env.staging.tmp > ./.env.staging || true
grep -v "^AIRBRAKE_KEY=" ./.env.staging.tmp > ./.env.staging || true
grep -v "^NEWRELIC_LICENSE_KEY=" ./.env.staging.tmp > ./.env.staging || true
grep -v "^NEWRELIC_APP_NAME=" ./.env.staging.tmp > ./.env.staging || true
grep -v "^NEWRELIC_MONITOR_MODE=" ./.env.staging.tmp > ./.env.staging || true
rm -f ./.env.staging.tmp rm -f ./.env.staging.tmp
fi fi
@ -147,6 +204,11 @@ fetch_env_variables() {
echo "DB_HOST=$DB_HOST" >> ./.env.staging echo "DB_HOST=$DB_HOST" >> ./.env.staging
echo "REDIS_URL=$REDIS_URL" >> ./.env.staging echo "REDIS_URL=$REDIS_URL" >> ./.env.staging
echo "S3_ATTACHMENTS_BUCKET=$S3_ATTACHMENTS_BUCKET" >> ./.env.staging echo "S3_ATTACHMENTS_BUCKET=$S3_ATTACHMENTS_BUCKET" >> ./.env.staging
echo "AIRBRAKE_ID=$AIRBRAKE_ID" >> ./.env.staging
echo "AIRBRAKE_KEY=$AIRBRAKE_KEY" >> ./.env.staging
echo "NEWRELIC_LICENSE_KEY=$NEWRELIC_LICENSE_KEY" >> ./.env.staging
echo "NEWRELIC_APP_NAME=$NEWRELIC_APP_NAME" >> ./.env.staging
echo "NEWRELIC_MONITOR_MODE=$NEWRELIC_MONITOR_MODE" >> ./.env.staging
echo "✓ Environment variables successfully retrieved and written to .env.staging" echo "✓ Environment variables successfully retrieved and written to .env.staging"
} }
@ -190,6 +252,9 @@ main() {
# Fetch encryption key and write to config/master.key # Fetch encryption key and write to config/master.key
fetch_encryption_key fetch_encryption_key
# Fetch allowed hosts from Secrets Manager
fetch_allowed_hosts
# Fetch other environment variables from Secrets Manager # Fetch other environment variables from Secrets Manager
fetch_env_variables fetch_env_variables

@ -40,10 +40,6 @@ Rails.application.configure do
config.active_storage.service = config.active_storage.service =
if ENV['S3_ATTACHMENTS_BUCKET'].present? if ENV['S3_ATTACHMENTS_BUCKET'].present?
:aws_s3 :aws_s3
elsif ENV['GCS_BUCKET'].present?
:google
elsif ENV['AZURE_CONTAINER'].present?
:azure
else else
:disk :disk
end end
@ -57,10 +53,10 @@ Rails.application.configure do
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
# Assume all access to the app is happening through a SSL-terminating reverse proxy. # Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = ENV['FORCE_SSL'].present? && ENV['FORCE_SSL'] != 'false' config.assume_ssl = true
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = ENV['FORCE_SSL'].present? && ENV['FORCE_SSL'] != 'false' config.force_ssl = true
# Include generic and useful information about system operation, but avoid logging too much # Include generic and useful information about system operation, but avoid logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII). # information to avoid inadvertent exposure of personally identifiable information (PII).
@ -162,4 +158,12 @@ Rails.application.configure do
raid: resource.try(:account_id) raid: resource.try(:account_id)
} }
end end
config.host_authorization = { exclude: ->(request) { request.path == '/up' } }
# Load allowed hosts from environment variable
allowed_hosts = ENV['ALLOWED_HOSTS']&.split(',')&.map(&:strip) || ['.*\\.careerplug\\.com\\Z']
config.host_authorization = { exclude: ->(request) { request.path == '/up' } }
allowed_hosts.each { |host_pattern| config.hosts << Regexp.new(host_pattern) }
end end

@ -95,9 +95,28 @@ Rails.application.configure do
# require "syslog/logger" # require "syslog/logger"
# config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name")
logger = ActiveSupport::Logger.new($stdout) # logger = ActiveSupport::Logger.new($stdout)
logger.formatter = config.log_formatter # logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger) # config.logger = ActiveSupport::TaggedLogging.new(logger)
# NEWRELIC_MONITOR_MODE enables stdout logger sync for worker/web via NR APM
if ENV['NEWRELIC_MONITOR_MODE'].presence
config.logger = ActiveSupport::TaggedLogging.new(
Logger.new($stdout)
)
config.active_job.logger = ActiveSupport::TaggedLogging.new(
Logger.new($stdout)
)
else
config.logger = ActiveSupport::TaggedLogging.new(
Syslog::Logger.new('rails-main')
)
config.active_job.logger = ActiveSupport::TaggedLogging.new(
Syslog::Logger.new('rails-sidekiq')
)
end
encryption_secret = ENV['ENCRYPTION_SECRET'].presence || Digest::SHA256.hexdigest(ENV['SECRET_KEY_BASE'].to_s) encryption_secret = ENV['ENCRYPTION_SECRET'].presence || Digest::SHA256.hexdigest(ENV['SECRET_KEY_BASE'].to_s)
@ -154,11 +173,9 @@ Rails.application.configure do
} }
end end
# Load allowed hosts from environment variable
allowed_hosts = ENV['ALLOWED_HOSTS']&.split(',')&.map(&:strip) || ['.*\\.careerplug\\.com\\Z']
config.host_authorization = { exclude: ->(request) { request.path == '/up' } } config.host_authorization = { exclude: ->(request) { request.path == '/up' } }
[ allowed_hosts.each { |host_pattern| config.hosts << Regexp.new(host_pattern) }
/.*\.careerplug\.org\Z/,
/.*\.careerplug\.com\Z/,
/.*\.cpstaging\d\.click\Z/,
/.*\.cpstaging\d+\.name\Z/
].each { |hrexp| config.hosts << hrexp }
end end

@ -0,0 +1,11 @@
# frozen_string_literal: true
unless ENV['DOCKER_BUILD'] || ENV['CI_BUILD']
Airbrake.configure do |config|
config.project_key = ENV['AIRBRAKE_KEY'] # rubocop:disable Style/FetchEnvVar
config.project_id = ENV['AIRBRAKE_ID'] # rubocop:disable Style/FetchEnvVar
config.environment = Rails.env
config.ignore_environments = %w[development test]
config.root_directory = '/var/cpd/app'
end
end

File diff suppressed because it is too large Load Diff

@ -8,7 +8,7 @@
require_relative 'dotenv' require_relative 'dotenv'
max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 15) max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
threads min_threads_count, max_threads_count threads min_threads_count, max_threads_count
@ -39,7 +39,7 @@ if ENV['WEB_CONCURRENCY_AUTO'] == 'true'
workers Etc.nprocessors workers Etc.nprocessors
else else
workers ENV.fetch('WEB_CONCURRENCY', 0) workers ENV.fetch('WEB_CONCURRENCY', 1)
end end
# Use the `preload_app!` method when specifying a `workers` number. # Use the `preload_app!` method when specifying a `workers` number.

@ -56,6 +56,11 @@ Rails.application.routes.draw do
resources :form_events, only: %i[index], path: 'form/:type' resources :form_events, only: %i[index], path: 'form/:type'
resources :submission_events, only: %i[index], path: 'submission/:type' resources :submission_events, only: %i[index], path: 'submission/:type'
end end
resources :external_auth, only: [] do
collection do
post :user_token
end
end
end end
resources :export, controller: 'export' do resources :export, controller: 'export' do

@ -0,0 +1,9 @@
class AddExternalIdsToAccountsAndUsers < ActiveRecord::Migration[8.0]
def change
add_column :accounts, :external_account_id, :integer
add_column :users, :external_user_id, :integer
add_index :accounts, :external_account_id, unique: true
add_index :users, :external_user_id, unique: true
end
end

@ -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[8.0].define(version: 2025_08_11_211829) do ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -62,6 +62,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "uuid", null: false t.string "uuid", null: false
t.datetime "archived_at" t.datetime "archived_at"
t.integer "external_account_id"
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
t.index ["uuid"], name: "index_accounts_on_uuid", unique: true t.index ["uuid"], name: "index_accounts_on_uuid", unique: true
end end
@ -440,8 +442,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_11_211829) do
t.string "otp_secret" t.string "otp_secret"
t.integer "consumed_timestep" t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false t.boolean "otp_required_for_login", default: false, null: false
t.integer "external_user_id"
t.index ["account_id"], name: "index_users_on_account_id" t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["external_user_id"], name: "index_users_on_external_user_id", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
t.index ["uuid"], name: "index_users_on_uuid", unique: true t.index ["uuid"], name: "index_users_on_uuid", unique: true

@ -0,0 +1,75 @@
# frozen_string_literal: true
describe IframeAuthentication do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:token) { user.access_token.token }
let(:controller_class) do
Class.new(ApplicationController) do
include IframeAuthentication
end
end
let(:controller) { controller_class.new }
let(:request_double) { instance_double(ActionDispatch::Request, headers: {}, referer: nil) }
before do
allow(controller).to receive_messages(
request: request_double,
params: {},
session: {},
signed_in?: false,
sign_in: nil,
render: nil
)
allow(Rails.logger).to receive(:error)
end
describe '#authenticate_from_referer' do
it 'does nothing when already signed in' do
allow(controller).to receive(:signed_in?).and_return(true)
controller.send(:authenticate_from_referer)
expect(controller).not_to have_received(:sign_in)
end
it 'authenticates with valid params token' do
allow(controller).to receive(:params).and_return({ auth_token: token })
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'authenticates with valid session token' do
allow(controller).to receive(:session).and_return({ auth_token: token })
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'authenticates with valid header token' do
allow(request_double).to receive(:headers).and_return({ 'X-Auth-Token' => token })
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'authenticates with token from referer URL' do
allow(request_double).to receive(:referer).and_return("https://example.com?auth_token=#{token}")
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:sign_in).with(user)
end
it 'does nothing with invalid token' do
allow(controller).to receive(:params).and_return({ auth_token: 'invalid' })
controller.send(:authenticate_from_referer)
expect(controller).not_to have_received(:sign_in)
expect(controller).not_to have_received(:render)
end
it 'renders error with no token' do
controller.send(:authenticate_from_referer)
expect(controller).to have_received(:render).with(
json: { error: 'Authentication required' },
status: :unauthorized
)
end
end
end

@ -0,0 +1,59 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Account do
describe 'validations' do
it 'is valid with valid attributes' do
account = build(:account)
expect(account).to be_valid
end
it 'validates uniqueness of external_account_id when present' do
create(:account, external_account_id: 123)
duplicate = build(:account, external_account_id: 123)
expect(duplicate).not_to be_valid
end
end
describe '.find_or_create_by_external_id' do
let(:external_id) { 123 }
let(:attributes) { { name: 'Test Account' } }
it 'finds existing account by external_account_id' do
existing_account = create(:account, external_account_id: external_id)
result = described_class.find_or_create_by_external_id(external_id, attributes)
expect(result).to eq(existing_account)
end
it 'creates new account when none exists' do
result = described_class.find_or_create_by_external_id(external_id, attributes)
expect(result.external_account_id).to eq(external_id)
expect(result.name).to eq('Test Account')
end
end
describe '#testing?' do
let(:account) { create(:account) }
it 'delegates to linked_account_account' do
linked_account_account = instance_double(AccountLinkedAccount, testing?: true)
allow(account).to receive(:linked_account_account).and_return(linked_account_account)
expect(account.testing?).to be true
end
end
describe '#default_template_folder' do
it 'creates default folder when none exists' do
account = create(:account)
create(:user, account: account)
expect do
folder = account.default_template_folder
expect(folder.name).to eq(TemplateFolder::DEFAULT_NAME)
expect(folder).to be_persisted
end.to change(account.template_folders, :count).by(1)
end
end
end

@ -1,5 +1,45 @@
# frozen_string_literal: true # frozen_string_literal: true
# == Schema Information
#
# Table name: submitters
#
# id :bigint not null, primary key
# changes_requested_at :datetime
# completed_at :datetime
# declined_at :datetime
# email :string
# ip :string
# metadata :text not null
# name :string
# opened_at :datetime
# phone :string
# preferences :text not null
# sent_at :datetime
# slug :string not null
# timezone :string
# ua :string
# uuid :string not null
# values :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# external_id :string
# submission_id :integer not null
#
# Indexes
#
# index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_completed_at_and_account_id (completed_at,account_id)
# index_submitters_on_email (email)
# index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id)
#
# Foreign Keys
#
# fk_rails_... (submission_id => submissions.id)
#
require 'rails_helper' require 'rails_helper'
RSpec.describe Submitter do RSpec.describe Submitter do

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe User do
describe 'validations' do
it 'is valid with valid attributes' do
user = build(:user)
expect(user).to be_valid
end
it 'validates email format' do
user = build(:user, email: 'invalid-email')
expect(user).not_to be_valid
end
it 'validates uniqueness of external_user_id when present' do
account = create(:account)
create(:user, account: account, external_user_id: 123)
duplicate = build(:user, account: account, external_user_id: 123)
expect(duplicate).not_to be_valid
end
end
describe '.find_or_create_by_external_id' do
let(:account) { create(:account) }
let(:external_id) { 123 }
let(:attributes) { { first_name: 'Test', last_name: 'User', email: 'test@example.com' } }
it 'finds existing user by external_user_id' do
existing_user = create(:user, account: account, external_user_id: external_id)
result = described_class.find_or_create_by_external_id(account, external_id, attributes)
expect(result).to eq(existing_user)
end
it 'creates new user when none exists' do
result = described_class.find_or_create_by_external_id(account, external_id, attributes)
expect(result.external_user_id).to eq(external_id)
expect(result.first_name).to eq('Test')
expect(result.email).to eq('test@example.com')
expect(result.password).to be_present
end
end
describe '#active_for_authentication?' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
it 'returns true when user and account are active' do
expect(user.active_for_authentication?).to be true
end
it 'returns false when user is archived' do
user.update!(archived_at: 1.day.ago)
expect(user.active_for_authentication?).to be false
end
it 'returns false when account is archived' do
account.update!(archived_at: 1.day.ago)
expect(user.active_for_authentication?).to be false
end
end
describe '#initials' do
it 'returns initials from first and last name' do
user = build(:user, first_name: 'John', last_name: 'Doe')
expect(user.initials).to eq('JD')
end
it 'handles missing names' do
user = build(:user, first_name: 'John', last_name: nil)
expect(user.initials).to eq('J')
end
end
describe '#full_name' do
it 'combines first and last name' do
user = build(:user, first_name: 'John', last_name: 'Doe')
expect(user.full_name).to eq('John Doe')
end
it 'handles missing names' do
user = build(:user, first_name: 'John', last_name: nil)
expect(user.full_name).to eq('John')
end
end
describe '#friendly_name' do
it 'returns formatted name with email when full name present' do
user = build(:user, first_name: 'John', last_name: 'Doe', email: 'john@example.com')
expect(user.friendly_name).to eq('"John Doe" <john@example.com>')
end
it 'returns just email when no full name' do
user = build(:user, first_name: nil, last_name: nil, email: 'john@example.com')
expect(user.friendly_name).to eq('john@example.com')
end
end
end

@ -0,0 +1,98 @@
# frozen_string_literal: true
describe 'ApplicationController' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:token) { user.access_token.token }
describe 'token authentication methods' do
let(:controller) { ApplicationController.new }
let(:request_double) { instance_double(ActionDispatch::Request, headers: {}) }
before do
allow(controller).to receive_messages(
request: request_double,
params: {},
session: {},
signed_in?: false
)
end
describe '#maybe_authenticate_via_token' do
it 'signs in user with valid token in header' do
request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token })
allow(controller).to receive(:request).and_return(request_double_with_token)
allow(controller).to receive(:sign_in)
controller.send(:maybe_authenticate_via_token)
expect(controller).to have_received(:sign_in).with(user)
end
it 'does nothing with invalid token' do
request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' })
allow(controller).to receive(:request).and_return(request_double_with_invalid)
allow(controller).to receive(:sign_in)
controller.send(:maybe_authenticate_via_token)
expect(controller).not_to have_received(:sign_in)
end
end
describe '#authenticate_via_token!' do
it 'renders error with no token' do
allow(controller).to receive(:render)
controller.send(:authenticate_via_token!)
expect(controller).to have_received(:render).with(
json: { error: 'Authentication required. Please provide a valid auth_token.' },
status: :unauthorized
)
end
it 'renders error with invalid token' do
request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' })
allow(controller).to receive(:request).and_return(request_double_with_invalid)
allow(controller).to receive(:render)
controller.send(:authenticate_via_token!)
expect(controller).to have_received(:render).with(
json: { error: 'Authentication required. Please provide a valid auth_token.' },
status: :unauthorized
)
end
it 'does not render error with valid token' do
request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token })
allow(controller).to receive(:request).and_return(request_double_with_token)
allow(controller).to receive_messages(sign_in: nil, render: nil)
controller.send(:authenticate_via_token!)
expect(controller).not_to have_received(:render)
expect(controller).to have_received(:sign_in).with(user)
end
end
end
describe 'API authentication' do
context 'with valid token' do
it 'authenticates user' do
get '/api/submissions', headers: { 'X-Auth-Token': token }
expect(response).to have_http_status(:ok)
end
end
context 'with invalid token' do
it 'returns API-specific error message' do
get '/api/submissions', headers: { 'X-Auth-Token': 'invalid_token' }
expect(response).to have_http_status(:unauthorized)
expect(response.parsed_body).to eq({ 'error' => 'Not authenticated' })
end
end
end
end

@ -0,0 +1,36 @@
# frozen_string_literal: true
describe 'External Auth API' do
describe 'POST /api/external_auth/user_token' do
let(:valid_params) do
{
account: {
external_id: '123',
name: 'Test Company'
},
user: {
external_id: '456',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe'
}
}
end
it 'returns success with access token' do
post '/api/external_auth/user_token', params: valid_params, as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to have_key('access_token')
end
it 'returns error when params cause exception' do
allow(Account).to receive(:find_or_create_by_external_id).and_raise(StandardError.new('Test error'))
post '/api/external_auth/user_token', params: valid_params, as: :json
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body).to eq({ 'error' => 'Internal server error' })
end
end
end

@ -59,4 +59,18 @@ module SigningFormHelper
def template_field(template, field_name) def template_field(template, field_name)
template.fields.find { |f| f['name'] == field_name || f['title'] == field_name } || {} template.fields.find { |f| f['name'] == field_name || f['title'] == field_name } || {}
end end
# Waits for a job to be queued in Sidekiq for the specified job class.
def wait_for_job_to_queue(job_class, timeout: 5)
initial_count = job_class.jobs.size
Timeout.timeout(timeout) do
loop do
break if job_class.jobs.size > initial_count
sleep 0.1
end
end
rescue Timeout::Error
# If timeout occurs, just continue - the test will fail with a more descriptive message
end
end end

@ -654,6 +654,7 @@ RSpec.describe 'Signing Form' do
expect do expect do
click_on 'Sign and Complete' click_on 'Sign and Complete'
wait_for_job_to_queue(ProcessSubmitterCompletionJob)
end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1) end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1)
end end
end end

Loading…
Cancel
Save