From 7e670bd139f24261a9f8730bfd03223e885a665a Mon Sep 17 00:00:00 2001 From: shipeasy-ai Date: Sun, 21 Jun 2026 10:55:58 -0400 Subject: [PATCH] feat(internal-api): per-tenant account provisioning + token template create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/provision_accounts_controller.rb | 56 +++++++++ .../api/internal/templates_controller.rb | 67 +++++++++++ config/routes.rb | 8 ++ lib/api_path_consider_json_middleware.rb | 3 + lib/provision_token.rb | 46 ++++++++ .../api/internal/provision_accounts_spec.rb | 106 ++++++++++++++++++ spec/requests/api/internal/templates_spec.rb | 86 ++++++++++++++ 7 files changed, 372 insertions(+) create mode 100644 app/controllers/api/internal/provision_accounts_controller.rb create mode 100644 app/controllers/api/internal/templates_controller.rb create mode 100644 lib/provision_token.rb create mode 100644 spec/requests/api/internal/provision_accounts_spec.rb create mode 100644 spec/requests/api/internal/templates_spec.rb diff --git a/app/controllers/api/internal/provision_accounts_controller.rb b/app/controllers/api/internal/provision_accounts_controller.rb new file mode 100644 index 00000000..afdfeb64 --- /dev/null +++ b/app/controllers/api/internal/provision_accounts_controller.rb @@ -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 diff --git a/app/controllers/api/internal/templates_controller.rb b/app/controllers/api/internal/templates_controller.rb new file mode 100644 index 00000000..ebff62b5 --- /dev/null +++ b/app/controllers/api/internal/templates_controller.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 1b85a68e..5e620b45 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,6 +48,14 @@ Rails.application.routes.draw do resources :form_events, only: %i[index], path: 'form/:type' resources :submission_events, only: %i[index], path: 'submission/:type' 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 resources :verify_pdf_signature, only: %i[create] diff --git a/lib/api_path_consider_json_middleware.rb b/lib/api_path_consider_json_middleware.rb index 013c2ee4..f21d9c66 100644 --- a/lib/api_path_consider_json_middleware.rb +++ b/lib/api_path_consider_json_middleware.rb @@ -9,6 +9,9 @@ class ApiPathConsiderJsonMiddleware if env['PATH_INFO'].starts_with?('/api') && (!env['PATH_INFO'].ends_with?('/documents') || env['REQUEST_METHOD'] != 'POST') && !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_email_clicks') env['CONTENT_TYPE'] = 'application/json' diff --git a/lib/provision_token.rb b/lib/provision_token.rb new file mode 100644 index 00000000..80a2f056 --- /dev/null +++ b/lib/provision_token.rb @@ -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 diff --git a/spec/requests/api/internal/provision_accounts_spec.rb b/spec/requests/api/internal/provision_accounts_spec.rb new file mode 100644 index 00000000..0b0c3cfd --- /dev/null +++ b/spec/requests/api/internal/provision_accounts_spec.rb @@ -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 diff --git a/spec/requests/api/internal/templates_spec.rb b/spec/requests/api/internal/templates_spec.rb new file mode 100644 index 00000000..d42d6d52 --- /dev/null +++ b/spec/requests/api/internal/templates_spec.rb @@ -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