mirror of https://github.com/docusealco/docuseal
Add two internal endpoints so an embedding app can run one DocuSeal account per tenant, authenticated entirely by tokens (no shared Devise admin session): - POST /api/internal/provision_account — HMAC-signed (DOCUSEAL_PROVISION_SECRET) handoff that idempotently creates an Account + owner User + access token and returns them. Fails closed when the secret is unset. - POST /api/internal/templates — X-Auth-Token-authed create-from-PDF that builds a template under the caller's account via the same service the web uploader uses, idempotent by external_id (scoped per account). EmbedBuilderController/EmbedScoped already scope to user.account.templates, so giving each tenant its own account makes that a real isolation boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/697/head
parent
463eeabc5f
commit
7e670bd139
@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module Internal
|
||||
# Server-to-server endpoint the parent EHR app calls to provision a DocuSeal
|
||||
# Account + owner User + access token for a tenant. Idempotent by owner email.
|
||||
#
|
||||
# Auth is the HMAC `X-Provision-Token` (see ProvisionToken), NOT an API access
|
||||
# token — so this controller skips Devise auth, CanCan, and CSRF entirely.
|
||||
class ProvisionAccountsController < ActionController::API
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
wrap_parameters false
|
||||
|
||||
before_action :authenticate_provision_token!
|
||||
|
||||
def create
|
||||
user = User.find_by(email: @payload['email'])
|
||||
|
||||
user ||= ApplicationRecord.transaction { provision_account_and_owner!(@payload) }
|
||||
|
||||
render json: {
|
||||
email: user.email,
|
||||
access_token: user.access_token.token,
|
||||
account_uuid: user.account.uuid
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provision_account_and_owner!(payload)
|
||||
account = Account.create!(
|
||||
name: payload['name'].presence || 'EHR Account',
|
||||
timezone: 'UTC',
|
||||
locale: 'en-US'
|
||||
)
|
||||
|
||||
Rails.logger.info("[provision_account] est=#{payload['est'].inspect} account_uuid=#{account.uuid}")
|
||||
|
||||
account.users.create!(
|
||||
email: payload['email'],
|
||||
first_name: 'EHR',
|
||||
last_name: 'Owner',
|
||||
role: User::ADMIN_ROLE,
|
||||
password: SecureRandom.hex(16)
|
||||
)
|
||||
end
|
||||
|
||||
def authenticate_provision_token!
|
||||
@payload = ProvisionToken.verify(request.headers['X-Provision-Token'].to_s)
|
||||
|
||||
render(json: { error: 'unauthorized' }, status: :unauthorized) if @payload.blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module Internal
|
||||
# Creates a Template under the calling account from one or more uploaded PDFs,
|
||||
# idempotent by `external_id`. Authenticates with the standard X-Auth-Token
|
||||
# (inherited from Api::ApiBaseController), so the template is always scoped to
|
||||
# `current_user.account`.
|
||||
#
|
||||
# The PDF-to-Template mechanism is the exact one the web uploader uses
|
||||
# (Templates::CreateAttachments), so the resulting template is identical to a
|
||||
# web-uploaded one and opens normally in the embed builder.
|
||||
class TemplatesController < Api::ApiBaseController
|
||||
def create
|
||||
existing = current_account.templates.find_by(external_id: params[:external_id])
|
||||
|
||||
if existing
|
||||
authorize!(:read, existing)
|
||||
|
||||
return render(json: { id: existing.id, external_id: existing.external_id })
|
||||
end
|
||||
|
||||
template = build_template!
|
||||
|
||||
render json: { id: template.id, external_id: template.external_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_template!
|
||||
files = Array.wrap(params[:files]).compact_blank
|
||||
|
||||
template = Template.new(
|
||||
account: current_account,
|
||||
author: current_user,
|
||||
external_id: params[:external_id],
|
||||
name: File.basename(files.first.original_filename, '.*')
|
||||
)
|
||||
|
||||
authorize!(:create, template)
|
||||
|
||||
Templates.maybe_assign_access(template)
|
||||
|
||||
Template.transaction do
|
||||
template.folder = TemplateFolders.find_or_create_by_name(current_user, nil)
|
||||
template.save!
|
||||
|
||||
documents, = Templates::CreateAttachments.call(template, { files: }, extract_fields: true)
|
||||
template.schema = documents.map { |doc| { 'attachment_uuid' => doc.uuid, 'name' => doc.filename.base } }
|
||||
|
||||
if template.fields.blank?
|
||||
template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
|
||||
|
||||
template.schema.each { |item| item['pending_fields'] = true } if template.fields.present?
|
||||
end
|
||||
|
||||
template.save!
|
||||
end
|
||||
|
||||
WebhookUrls.enqueue_events(template, 'template.created')
|
||||
SearchEntries.enqueue_reindex(template)
|
||||
|
||||
template
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Verifier for the HMAC-signed provisioning token minted by the parent EHR app.
|
||||
#
|
||||
# Token format: `body + "." + sig` where
|
||||
# body = Base64.urlsafe_encode64(payload_json, padding: false) (compact JSON)
|
||||
# sig = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
|
||||
#
|
||||
# The shared secret lives in ENV["DOCUSEAL_PROVISION_SECRET"]. Fail closed on
|
||||
# everything: a blank secret, a malformed/missing token, a signature mismatch,
|
||||
# or an expired `exp` claim all return nil — never a half-trusted payload.
|
||||
module ProvisionToken
|
||||
ENV_KEY = 'DOCUSEAL_PROVISION_SECRET'
|
||||
|
||||
module_function
|
||||
|
||||
def secret
|
||||
ENV[ENV_KEY].presence
|
||||
end
|
||||
|
||||
# Returns the decoded payload Hash (string keys) when the token is valid and
|
||||
# unexpired, otherwise nil.
|
||||
def verify(token, secret: self.secret)
|
||||
return if secret.blank?
|
||||
return if token.blank?
|
||||
|
||||
body, sig = token.split('.', 2)
|
||||
return if body.blank? || sig.blank?
|
||||
|
||||
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
|
||||
return unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)
|
||||
|
||||
payload = JSON.parse(Base64.urlsafe_decode64(body))
|
||||
return unless payload.is_a?(Hash)
|
||||
|
||||
exp = payload['exp']
|
||||
return unless exp.is_a?(Integer)
|
||||
return if exp <= Time.now.to_i
|
||||
|
||||
payload
|
||||
rescue ArgumentError, JSON::ParserError
|
||||
# Base64.urlsafe_decode64 raises ArgumentError on malformed input;
|
||||
# JSON.parse raises JSON::ParserError. Both mean "not a valid token".
|
||||
nil
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec::Matchers.define_negated_matcher :not_change, :change
|
||||
|
||||
describe 'Internal Provision Account API' do
|
||||
let(:secret) { 'test-provision-secret' }
|
||||
|
||||
before { allow(ENV).to receive(:[]).and_call_original }
|
||||
|
||||
def sign_token(payload, sign_with: secret)
|
||||
body = Base64.urlsafe_encode64(payload.to_json, padding: false)
|
||||
sig = OpenSSL::HMAC.hexdigest('SHA256', sign_with, body)
|
||||
"#{body}.#{sig}"
|
||||
end
|
||||
|
||||
def valid_payload(overrides = {})
|
||||
{
|
||||
'est' => SecureRandom.uuid,
|
||||
'email' => 'owner@example.com',
|
||||
'name' => 'Acme Clinic',
|
||||
'exp' => 5.minutes.from_now.to_i
|
||||
}.merge(overrides)
|
||||
end
|
||||
|
||||
describe 'POST /api/internal/provision_account' do
|
||||
context 'with the secret configured' do
|
||||
before { allow(ENV).to receive(:[]).with('DOCUSEAL_PROVISION_SECRET').and_return(secret) }
|
||||
|
||||
it 'creates an account, owner, and access token' do
|
||||
expect do
|
||||
post '/api/internal/provision_account',
|
||||
headers: { 'X-Provision-Token': sign_token(valid_payload) }
|
||||
end.to change(Account, :count).by(1).and change(User, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
user = User.find_by(email: 'owner@example.com')
|
||||
expect(user.account.name).to eq('Acme Clinic')
|
||||
expect(user.account.timezone).to eq('UTC')
|
||||
expect(user.account.locale).to eq('en-US')
|
||||
expect(user.role).to eq(User::ADMIN_ROLE)
|
||||
|
||||
body = response.parsed_body
|
||||
expect(body['email']).to eq('owner@example.com')
|
||||
expect(body['access_token']).to eq(user.access_token.token)
|
||||
expect(body['account_uuid']).to eq(user.account.uuid)
|
||||
end
|
||||
|
||||
it 'is idempotent — repeated calls reuse the same user, account, and token' do
|
||||
post '/api/internal/provision_account', headers: { 'X-Provision-Token': sign_token(valid_payload) }
|
||||
first = response.parsed_body
|
||||
|
||||
expect do
|
||||
post '/api/internal/provision_account', headers: { 'X-Provision-Token': sign_token(valid_payload) }
|
||||
end.to not_change(Account, :count).and not_change(User, :count)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['access_token']).to eq(first['access_token'])
|
||||
expect(response.parsed_body['account_uuid']).to eq(first['account_uuid'])
|
||||
end
|
||||
|
||||
it 'rejects a token signed with the wrong secret' do
|
||||
post '/api/internal/provision_account',
|
||||
headers: { 'X-Provision-Token': sign_token(valid_payload, sign_with: 'wrong-secret') }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body).to eq('error' => 'unauthorized')
|
||||
expect(User.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'rejects an expired token' do
|
||||
post '/api/internal/provision_account',
|
||||
headers: { 'X-Provision-Token': sign_token(valid_payload('exp' => 5.minutes.ago.to_i)) }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body).to eq('error' => 'unauthorized')
|
||||
end
|
||||
|
||||
it 'rejects a malformed token' do
|
||||
post '/api/internal/provision_account', headers: { 'X-Provision-Token': 'not-a-real-token' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'rejects a missing token' do
|
||||
post '/api/internal/provision_account'
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body).to eq('error' => 'unauthorized')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the secret is not configured' do
|
||||
before { allow(ENV).to receive(:[]).with('DOCUSEAL_PROVISION_SECRET').and_return(nil) }
|
||||
|
||||
it 'fails closed with 401 even for an otherwise-valid token' do
|
||||
post '/api/internal/provision_account',
|
||||
headers: { 'X-Provision-Token': sign_token(valid_payload) }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body).to eq('error' => 'unauthorized')
|
||||
expect(User.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe 'Internal Templates API' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:user) { create(:user, account:) }
|
||||
let(:api_key) { user.access_token.token }
|
||||
|
||||
def pdf_upload(name = 'sample-document.pdf')
|
||||
Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/sample-document.pdf'), 'application/pdf', false,
|
||||
original_filename: name)
|
||||
end
|
||||
|
||||
describe 'POST /api/internal/templates' do
|
||||
it 'creates a template under the caller account from a single PDF' do
|
||||
expect do
|
||||
post '/api/internal/templates',
|
||||
params: { external_id: 'consent-1', files: [pdf_upload] },
|
||||
headers: { 'x-auth-token': api_key }
|
||||
end.to change(account.templates, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
template = account.templates.find_by(external_id: 'consent-1')
|
||||
expect(template).to be_present
|
||||
expect(template.author).to eq(user)
|
||||
expect(template.documents.count).to eq(1)
|
||||
expect(response.parsed_body).to eq('id' => template.id, 'external_id' => 'consent-1')
|
||||
end
|
||||
|
||||
it 'bundles multiple PDFs as documents preserving upload order' do
|
||||
post '/api/internal/templates',
|
||||
params: { external_id: 'multi-1', files: [pdf_upload('a.pdf'), pdf_upload('b.pdf')] },
|
||||
headers: { 'x-auth-token': api_key }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
template = account.templates.find_by(external_id: 'multi-1')
|
||||
expect(template.schema.size).to eq(2)
|
||||
expect(template.schema.pluck('name')).to eq(%w[a b])
|
||||
end
|
||||
|
||||
it 'is idempotent by external_id — returns the existing template, no duplicate' do
|
||||
post '/api/internal/templates',
|
||||
params: { external_id: 'dup-1', files: [pdf_upload] },
|
||||
headers: { 'x-auth-token': api_key }
|
||||
first_id = response.parsed_body['id']
|
||||
|
||||
expect do
|
||||
post '/api/internal/templates',
|
||||
params: { external_id: 'dup-1', files: [pdf_upload] },
|
||||
headers: { 'x-auth-token': api_key }
|
||||
end.not_to change(account.templates, :count)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(first_id)
|
||||
end
|
||||
|
||||
it 'returns 401 without an auth token' do
|
||||
post '/api/internal/templates', params: { external_id: 'noauth', files: [pdf_upload] }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.templates.find_by(external_id: 'noauth')).to be_nil
|
||||
end
|
||||
|
||||
it 'never returns or reuses another account template with the same external_id' do
|
||||
post '/api/internal/templates',
|
||||
params: { external_id: 'shared-ext', files: [pdf_upload] },
|
||||
headers: { 'x-auth-token': api_key }
|
||||
first_template = account.templates.find_by(external_id: 'shared-ext')
|
||||
|
||||
other_account = create(:account)
|
||||
other_user = create(:user, account: other_account)
|
||||
|
||||
post '/api/internal/templates',
|
||||
params: { external_id: 'shared-ext', files: [pdf_upload] },
|
||||
headers: { 'x-auth-token': other_user.access_token.token }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
other_template = other_account.templates.find_by(external_id: 'shared-ext')
|
||||
expect(other_template).to be_present
|
||||
expect(other_template.id).not_to eq(first_template.id)
|
||||
expect(response.parsed_body['id']).to eq(other_template.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue