From bfcc59d70f316fac5dd70d6cdc6439323a833064 Mon Sep 17 00:00:00 2001 From: Bernardo Anderson Date: Tue, 7 Oct 2025 12:35:50 -0500 Subject: [PATCH] CP-11289 - Add CloudFront signed URLs for secured document storage Implement compliance storage configuration using AWS CloudFront signed URLs for completed documents. This reuses the existing ATS infrastructure to provide secure, time-limited access to document storage while maintaining backward compatibility with legacy storage. - Add aws-sdk-cloudfront dependency for URL signing - Create DocumentSecurityService for CloudFront signed URL generation - Add secured storage service configuration in storage.yml - Update completed_documents model with storage_location tracking - Modify download controllers to use signed URLs for secured storage - Add compliance_storage.yml configuration for different environments - Update submitter completion job to track storage location BREAKING CHANGE: Requires SECURED_STORAGE_BUCKET and SECURED_STORAGE_REGION environment variables for staging/production environments --- .env.s3.template | 165 ++++++++++++++++++ Gemfile | 1 + Gemfile.lock | 4 + .../submissions_download_controller.rb | 34 ++-- .../submit_form_download_controller.rb | 13 +- app/jobs/process_submitter_completion_job.rb | 13 +- app/models/completed_document.rb | 41 ++++- app/services/document_security_service.rb | 61 +++++++ config/application.rb | 3 + config/initializers/secure_attachment.rb | 17 ++ config/shakapacker.yml | 2 +- config/storage.yml | 10 ++ ...storage_location_to_completed_documents.rb | 6 + .../generate_result_attachments.rb | 48 ++++- 14 files changed, 397 insertions(+), 21 deletions(-) create mode 100644 .env.s3.template create mode 100644 app/services/document_security_service.rb create mode 100644 config/initializers/secure_attachment.rb create mode 100644 db/migrate/20250930175543_add_storage_location_to_completed_documents.rb diff --git a/.env.s3.template b/.env.s3.template new file mode 100644 index 00000000..3e2c1e8a --- /dev/null +++ b/.env.s3.template @@ -0,0 +1,165 @@ +# ============================================================================= +# DOCUSEAL S3 CONFIGURATION TEMPLATE +# ============================================================================= +# Copy this file to .env and customize the values for your environment. +# Remove the .template extension after copying. +# +# SECURITY NOTE: Never commit actual credentials to version control! +# Use environment-specific .env files and add them to .gitignore. +# ============================================================================= + +# ============================================================================= +# AWS CREDENTIALS +# ============================================================================= +# Required: AWS access key ID for programmatic access +# Get this from AWS IAM console -> Users -> Security credentials +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE + +# Required: AWS secret access key for programmatic access +# Keep this confidential and never share or commit to version control +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# Optional: AWS session token for temporary credentials +# Only required when using temporary credentials (e.g., with AWS STS) +# AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE= + +# ============================================================================= +# AWS S3 CONFIGURATION +# ============================================================================= +# Required: AWS region where your S3 bucket is located +# Examples: us-east-1, us-west-2, eu-west-1, ap-southeast-1 +AWS_REGION=us-east-1 + +# Required: S3 bucket name for storing attachments +# Must be globally unique and follow S3 bucket naming rules +# Recommended format: your-company-docuseal-attachments-env +S3_ATTACHMENTS_BUCKET=your-company-docuseal-attachments-production + +# ============================================================================= +# S3 ACCESS CONTROL +# ============================================================================= +# Optional: Whether files should be publicly accessible via direct URLs +# Set to 'true' for public access, 'false' for private access +# Private files require presigned URLs for access (more secure) +# Default: false (recommended for production) +ACTIVE_STORAGE_PUBLIC=false + +# Optional: Expiration time for presigned URLs (in minutes) +# Only used when ACTIVE_STORAGE_PUBLIC=false +# Default: 240 minutes (4 hours) +PRESIGNED_URLS_EXPIRE_MINUTES=240 + +# ============================================================================= +# S3 SECURITY OPTIONS +# ============================================================================= +# Optional: Server-side encryption for uploaded files +# Options: +# - AES256 (S3-managed encryption) +# - aws:kms (KMS-managed encryption with AWS KMS key) +# - aws:kms:dsse (KMS-managed encryption with double server-side encryption) +# Uncomment the desired option below +# S3_SERVER_SIDE_ENCRYPTION=AES256 +# S3_SERVER_SIDE_ENCRYPTION=aws:kms + +# Optional: AWS KMS Key ID for KMS-managed encryption +# Only required when using aws:kms encryption with a specific KMS key +# S3_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 + +# Optional: Force path-style URLs instead of virtual-hosted-style URLs +# Set to 'true' for S3-compatible services (MinIO, DigitalOcean Spaces, etc.) +# or if you encounter DNS resolution issues +# Default: false +# S3_FORCE_PATH_STYLE=false + +# ============================================================================= +# S3 ENDPOINT CONFIGURATION (FOR S3-COMPATIBLE SERVICES) +# ============================================================================= +# Optional: Custom S3 endpoint URL +# Only required for S3-compatible services (MinIO, DigitalOcean Spaces, etc.) +# S3_ENDPOINT=https://nyc3.digitaloceanspaces.com + +# ============================================================================= +# ADVANCED S3 OPTIONS +# ============================================================================= +# Optional: S3 storage class for uploaded files +# Options: STANDARD, REDUCED_REDUNDANCY, STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, +# GLACIER, DEEP_ARCHIVE, OUTPOSTS, GLACIER_IR +# Default: STANDARD +# S3_STORAGE_CLASS=STANDARD + +# Optional: Cache control header for uploaded files +# Affects browser caching behavior for publicly accessible files +# Default: 'public, max-age=31536000' (1 year) +# S3_CACHE_CONTROL=public, max-age=31536000 + +# Optional: Content disposition for uploaded files +# Controls how browsers handle file downloads +# S3_CONTENT_DISPOSITION=attachment + +# ============================================================================= +# AWS IAM ROLE CONFIGURATION (ALTERNATIVE TO ACCESS KEYS) +# ============================================================================= +# Optional: Use IAM role instead of access keys (recommended for EC2/ECS) +# When using IAM roles, you don't need to set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY +# The role must have appropriate S3 permissions + +# Optional: AWS profile name for credential configuration +# Uses credentials from ~/.aws/credentials file +# AWS_PROFILE=default + +# Optional: AWS credentials file path +# Default: ~/.aws/credentials +# AWS_SHARED_CREDENTIALS_FILE=/path/to/credentials + +# Optional: AWS config file path +# Default: ~/.aws/config +# AWS_CONFIG_FILE=/path/to/config + +# ============================================================================= +# MONITORING AND DEBUGGING +# ============================================================================= +# Optional: Enable AWS SDK logging +# Set to 'true' for debug output, 'false' to disable +# Default: false +# AWS_SDK_LOGGING=false + +# Optional: AWS SDK log level +# Options: DEBUG, INFO, WARN, ERROR, FATAL +# Default: INFO +# AWS_SDK_LOG_LEVEL=INFO + +# ============================================================================= +# EXAMPLE CONFIGURATIONS +# ============================================================================= +# +# DEVELOPMENT (Local Disk Storage): +# Comment out all S3 variables above +# The application will use local disk storage automatically +# +# STAGING (Basic S3): +# AWS_ACCESS_KEY_ID=your_staging_access_key +# AWS_SECRET_ACCESS_KEY=your_staging_secret_key +# AWS_REGION=us-east-1 +# S3_ATTACHMENTS_BUCKET=your-company-docuseal-staging +# ACTIVE_STORAGE_PUBLIC=true +# +# PRODUCTION (Secure S3): +# AWS_ACCESS_KEY_ID=your_production_access_key +# AWS_SECRET_ACCESS_KEY=your_production_secret_key +# AWS_REGION=us-east-1 +# S3_ATTACHMENTS_BUCKET=your-company-docuseal-production +# ACTIVE_STORAGE_PUBLIC=false +# PRESIGNED_URLS_EXPIRE_MINUTES=60 +# S3_SERVER_SIDE_ENCRYPTION=AES256 +# S3_STORAGE_CLASS=STANDARD_IA +# +# MINIO (Self-hosted S3-compatible): +# AWS_ACCESS_KEY_ID=minioadmin +# AWS_SECRET_ACCESS_KEY=minioadmin +# AWS_REGION=us-east-1 +# S3_ENDPOINT=http://localhost:9000 +# S3_FORCE_PATH_STYLE=true +# S3_ATTACHMENTS_BUCKET=docuseal-minio +# ACTIVE_STORAGE_PUBLIC=true +# +# ============================================================================= \ No newline at end of file diff --git a/Gemfile b/Gemfile index b4352fa7..26541f3d 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ source 'https://rubygems.org' ruby '3.4.2' gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic' +gem 'aws-sdk-cloudfront', require: false gem 'aws-sdk-s3', require: false gem 'aws-sdk-secretsmanager', require: false gem 'azure-storage-blob', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 52baffbd..b1094317 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,6 +83,9 @@ GEM ast (2.4.2) aws-eventstream (1.3.0) aws-partitions (1.1027.0) + aws-sdk-cloudfront (1.108.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -597,6 +600,7 @@ DEPENDENCIES airbrake annotaterb arabic-letter-connector + aws-sdk-cloudfront aws-sdk-s3 aws-sdk-secretsmanager azure-storage-blob diff --git a/app/controllers/submissions_download_controller.rb b/app/controllers/submissions_download_controller.rb index f0f3778c..77c2256a 100644 --- a/app/controllers/submissions_download_controller.rb +++ b/app/controllers/submissions_download_controller.rb @@ -57,11 +57,16 @@ class SubmissionsDownloadController < ApplicationController key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value Submitters.select_attachments_for_download(submitter).map do |attachment| - ActiveStorage::Blob.proxy_url( - attachment.blob, - expires_at: FILES_TTL.from_now.to_i, - filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format) - ) + # Use signed URLs for secured storage + if uses_secured_storage?(attachment) + DocumentSecurityService.signed_url_for(attachment, expires_in: FILES_TTL) + else + ActiveStorage::Blob.proxy_url( + attachment.blob, + expires_at: FILES_TTL.from_now.to_i, + filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format) + ) + end end end @@ -75,10 +80,19 @@ class SubmissionsDownloadController < ApplicationController filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id, key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value - ActiveStorage::Blob.proxy_url( - attachment.blob, - expires_at: FILES_TTL.from_now.to_i, - filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format) - ) + # Use signed URLs for secured storage + if uses_secured_storage?(attachment) + DocumentSecurityService.signed_url_for(attachment, expires_in: FILES_TTL) + else + ActiveStorage::Blob.proxy_url( + attachment.blob, + expires_at: FILES_TTL.from_now.to_i, + filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format) + ) + end + end + + def uses_secured_storage?(attachment) + attachment.blob.service_name == 'aws_s3_secured' end end diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index 67815e5e..6cba869a 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -32,9 +32,20 @@ class SubmitFormDownloadController < ApplicationController end urls = attachments.map do |attachment| - ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i) + # Use signed URLs for secured storage + if uses_secured_storage?(attachment) + DocumentSecurityService.signed_url_for(attachment, expires_in: FILES_TTL) + else + ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i) + end end render json: urls end + + private + + def uses_secured_storage?(attachment) + attachment.blob.service_name == 'aws_s3_secured' + end end diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index c53baaf5..c20a6c28 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -58,10 +58,21 @@ class ProcessSubmitterCompletionJob submitter.documents.filter_map do |attachment| next if attachment.metadata['sha256'].blank? - CompletedDocument.find_or_create_by!(sha256: attachment.metadata['sha256'], submitter_id: submitter.id) + # Determine storage location based on service used + storage_location = determine_storage_location_for_attachment(attachment) + + CompletedDocument.find_or_create_by!(sha256: attachment.metadata['sha256'], submitter_id: submitter.id) do |doc| + doc.storage_location = storage_location + end end end + def determine_storage_location_for_attachment(attachment) + # Check if attachment is stored in secured storage + service_name = attachment.blob.service_name + service_name == 'aws_s3_secured' ? 'secured' : 'legacy' + end + def enqueue_completed_webhooks(submitter, is_all_completed: false) WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook| if webhook.events.include?('form.completed') diff --git a/app/models/completed_document.rb b/app/models/completed_document.rb index 5ec53ca4..798cf382 100644 --- a/app/models/completed_document.rb +++ b/app/models/completed_document.rb @@ -4,19 +4,46 @@ # # Table name: completed_documents # -# id :bigint not null, primary key -# sha256 :string not null -# created_at :datetime not null -# updated_at :datetime not null -# submitter_id :bigint not null +# id :bigint not null, primary key +# sha256 :string not null +# storage_location :string default("secured") +# created_at :datetime not null +# updated_at :datetime not null +# submitter_id :bigint not null # # Indexes # -# index_completed_documents_on_sha256 (sha256) -# index_completed_documents_on_submitter_id (submitter_id) +# index_completed_documents_on_sha256 (sha256) +# index_completed_documents_on_storage_location (storage_location) +# index_completed_documents_on_submitter_id (submitter_id) # class CompletedDocument < ApplicationRecord belongs_to :submitter, optional: true has_one :completed_submitter, primary_key: :submitter_id, inverse_of: :completed_documents, dependent: :destroy + + enum storage_location: { + legacy: 'legacy', # Fallback for development/testing + secured: 'secured' # Default secured storage (shared with ATS) + }, _suffix: true + + # Check if document uses secured storage (default for new documents) + def uses_secured_storage? + storage_location == 'secured' + end + + # Get appropriate Active Storage service name + def storage_service_name + uses_secured_storage? ? 'aws_s3_secured' : Rails.application.config.active_storage.service + end + + # Generate signed URL for secured documents (same system as ATS) + # @param attachment [ActiveStorage::Attachment] The attachment to generate URL for + # @param expires_in [ActiveSupport::Duration] How long the URL should be valid + # @return [String] Signed CloudFront URL or regular URL for legacy storage + def signed_url_for(attachment, expires_in: 1.hour) + return attachment.url unless uses_secured_storage? + + DocumentSecurityService.signed_url_for(attachment, expires_in: expires_in) + end end diff --git a/app/services/document_security_service.rb b/app/services/document_security_service.rb new file mode 100644 index 00000000..70b8b9bf --- /dev/null +++ b/app/services/document_security_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Service for handling secure document access with CloudFront signed URLs +# Reuses same infrastructure and key pairs as ATS +class DocumentSecurityService + class << self + # Generate signed URL for a secured attachment + # @param attachment [ActiveStorage::Attachment] The attachment to generate URL for + # @param expires_in [ActiveSupport::Duration] How long the URL should be valid + # @return [String] Signed CloudFront URL + def signed_url_for(attachment, expires_in: 1.hour) + return attachment.url unless cloudfront_configured? + + # Get the CloudFront URL for this attachment + cloudfront_url = build_cloudfront_url(attachment) + + # Generate signed URL using same system as ATS + signer = cloudfront_signer + signer.signed_url(cloudfront_url, expires: expires_in.from_now.to_i) + rescue StandardError => e + Rails.logger.error("Failed to generate signed URL: #{e.message}") + # Fallback to direct URL if signing fails + attachment.url + end + + private + + def cloudfront_configured? + cloudfront_base_url.present? && + cloudfront_key_pair_id.present? && + cloudfront_private_key.present? + end + + def cloudfront_signer + @cloudfront_signer ||= Aws::CloudFront::UrlSigner.new( + key_pair_id: cloudfront_key_pair_id, + private_key: cloudfront_private_key + ) + end + + def build_cloudfront_url(attachment) + # Convert S3 URL to CloudFront URL with DocuSeal prefix + s3_key = attachment.blob.key + # Ensure DocuSeal prefix for document organization + prefixed_key = s3_key.start_with?('docuseal/') ? s3_key : "docuseal/#{s3_key}" + "#{cloudfront_base_url}/#{prefixed_key}" + end + + def cloudfront_base_url + @cloudfront_base_url ||= Rails.configuration.x.compliance_storage&.dig(:cf_url) + end + + def cloudfront_key_pair_id + @cloudfront_key_pair_id ||= Rails.configuration.x.compliance_storage&.dig(:cf_key_pair) + end + + def cloudfront_private_key + @cloudfront_private_key ||= ENV['SECURE_ATTACHMENT_PRIVATE_KEY'] + end + end +end diff --git a/config/application.rb b/config/application.rb index dcfa1bfa..d045d892 100644 --- a/config/application.rb +++ b/config/application.rb @@ -44,6 +44,9 @@ module DocuSeal autoloaders.once.do_not_eager_load("#{Turbo::Engine.root}/app/channels") # https://github.com/hotwired/turbo-rails/issues/512 + # DocuSeal compliance storage configuration (reuses ATS infrastructure) + config.x.compliance_storage = config_for(:compliance_storage) + ActiveSupport.run_load_hooks(:application_config, self) end end diff --git a/config/initializers/secure_attachment.rb b/config/initializers/secure_attachment.rb new file mode 100644 index 00000000..d8a02745 --- /dev/null +++ b/config/initializers/secure_attachment.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'aws-sdk-secretsmanager' + +# Load CloudFront private key from AWS Secrets Manager (same as ATS) +key_secret = Rails.configuration.x.compliance_storage&.dig(:cf_key_secret) + +if key_secret.present? + begin + client = Aws::SecretsManager::Client.new + response = client.get_secret_value(secret_id: key_secret) + ENV['SECURE_ATTACHMENT_PRIVATE_KEY'] = response.secret_string + Rails.logger.info("Successfully loaded CloudFront private key from Secrets Manager") + rescue StandardError => e + Rails.logger.error("Failed to load CloudFront private key: #{e.message}") + end +end diff --git a/config/shakapacker.yml b/config/shakapacker.yml index 7a70c711..bc7bec89 100644 --- a/config/shakapacker.yml +++ b/config/shakapacker.yml @@ -21,7 +21,7 @@ development: dev_server: server: 'http' host: localhost - port: 3035 + port: 3036 hmr: false inline_css: true live_reload: false diff --git a/config/storage.yml b/config/storage.yml index c6fe78fd..1b642334 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -13,6 +13,16 @@ aws_s3: upload: cache_control: 'public, max-age=31536000' +# Secured storage service for completed documents (reuses ATS infrastructure) +# Uses IAM role for authentication in staging/production (no keys needed) +# Set SECURED_STORAGE_BUCKET and SECURED_STORAGE_REGION in Secrets Manager +aws_s3_secured: + service: S3 + bucket: <%= ENV['SECURED_STORAGE_BUCKET'] %> + region: <%= ENV['SECURED_STORAGE_REGION'] || 'us-east-1' %> + public: false + force_path_style: true + test: service: Disk root: <%= Rails.root.join("tmp/storage") %> diff --git a/db/migrate/20250930175543_add_storage_location_to_completed_documents.rb b/db/migrate/20250930175543_add_storage_location_to_completed_documents.rb new file mode 100644 index 00000000..335a3a87 --- /dev/null +++ b/db/migrate/20250930175543_add_storage_location_to_completed_documents.rb @@ -0,0 +1,6 @@ +class AddStorageLocationToCompletedDocuments < ActiveRecord::Migration[8.0] + def change + add_column :completed_documents, :storage_location, :string, default: 'secured' + add_index :completed_documents, :storage_location + end +end diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index bdfff77b..c8e653c6 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -624,8 +624,23 @@ module Submissions end end + # Determine storage service for secured storage + service_name = determine_storage_service + + # Create blob with appropriate service + blob = if service_name == 'aws_s3_secured' + # For secured storage, create blob with custom key including docuseal prefix + create_secured_blob(io.tap(&:rewind), "#{name}.pdf", service_name) + else + # For regular storage, use standard creation + ActiveStorage::Blob.create_and_upload!( + io: io.tap(&:rewind), + filename: "#{name}.pdf" + ) + end + ActiveStorage::Attachment.new( - blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"), + blob: blob, metadata: { original_uuid: uuid, analyzed: true, sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) }, @@ -830,6 +845,37 @@ module Submissions end end + def determine_storage_service + # Use secured storage by default unless explicitly disabled + return Rails.application.config.active_storage.service if Rails.env.development? && ENV['DOCUSEAL_DISABLE_SECURED_STORAGE'].present? + + # Use secured storage if compliance configuration is present + Rails.configuration.x.compliance_storage.present? ? 'aws_s3_secured' : Rails.application.config.active_storage.service + end + + def create_secured_blob(io, filename, service_name) + # Generate a unique key with docuseal prefix for document organization in shared bucket + key = "docuseal/#{SecureRandom.uuid}/#{filename}" + + # Create the blob with the custom key + blob = ActiveStorage::Blob.create_before_direct_upload!( + filename: filename, + byte_size: io.size, + checksum: Digest::MD5.base64digest(io.read), + content_type: 'application/pdf', + service_name: service_name + ) + + # Override the generated key with our custom prefixed key + blob.update_column(:key, key) + + # Upload the file to S3 with the custom key + io.rewind + blob.upload(io) + + blob + end + def h Rails.application.routes.url_helpers end