feat(internal-api): per-tenant account provisioning + token template create

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
shipeasy-ai 1 month ago
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

@ -48,6 +48,14 @@ 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
# Server-to-server endpoints used by the parent EHR app to provision a
# DocuSeal account per tenant and create templates scoped to it. The
# provision endpoint authenticates with its own HMAC token (not an API
# access token); the templates endpoint uses the standard X-Auth-Token.
namespace :internal do
resource :provision_account, only: %i[create], controller: 'provision_accounts'
resources :templates, only: %i[create]
end
end end
resources :verify_pdf_signature, only: %i[create] resources :verify_pdf_signature, only: %i[create]

@ -9,6 +9,9 @@ class ApiPathConsiderJsonMiddleware
if env['PATH_INFO'].starts_with?('/api') && if env['PATH_INFO'].starts_with?('/api') &&
(!env['PATH_INFO'].ends_with?('/documents') || env['REQUEST_METHOD'] != 'POST') && (!env['PATH_INFO'].ends_with?('/documents') || env['REQUEST_METHOD'] != 'POST') &&
!env['PATH_INFO'].ends_with?('/attachments') && !env['PATH_INFO'].ends_with?('/attachments') &&
# Internal template provisioning accepts multipart PDF uploads — let Rack
# parse the multipart body instead of forcing application/json.
!(env['PATH_INFO'].ends_with?('/internal/templates') && env['REQUEST_METHOD'] == 'POST') &&
!env['PATH_INFO'].ends_with?('/submitter_sms_clicks') && !env['PATH_INFO'].ends_with?('/submitter_sms_clicks') &&
!env['PATH_INFO'].ends_with?('/submitter_email_clicks') !env['PATH_INFO'].ends_with?('/submitter_email_clicks')
env['CONTENT_TYPE'] = 'application/json' env['CONTENT_TYPE'] = 'application/json'

@ -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…
Cancel
Save