Authorization for ActiveStorage DirectUpload
This post is a part of YAMAP Engineers Advent Calendar 2023.
Introduction
ActiveStorage's DirectUpload is a fantastic feature that enables users to upload files directly to a storage service like S3, bypassing the need to route them through your backend server. This not only reduces the bandwidth and resource usage of your servers but also avoids the payload size limit issue with API Gateways. However, the default DirectUpload system lacks authentication and authorization, leaving a gap that could potentially allow User A to pilfer User B's files. In this post, I'll detail how I addressed this challenge.
DirectUpload workflow
Let's kick off by understanding the DirectUpload workflow. Imagine allowing users to upload related documents when initiating a service contract.
Rails conveniently provides a default direct_uploads API.
However, this API lacks authentication. To identify the file uploader, you must construct your own API.
# config/routes.rb
resources :direct_uploads, only: %i[create]
# app/controllers/direct_uploads_controller.rb
class DirectUploadsController < ActiveStorage::DirectUploadsController
skip_forgery_protection
before_action :require_login
end
Adding blob ownership
Now that you know who wants to upload the file, the challenge is persisting this information beyond the request lifetime. I opted to store it in the database.
The DirectUploadsController generates an ActiveStorage::Blob record in your database, representing the uploaded file. Typically, for uploader persistence, you'd introduce a belongs_to :uploader, class_name: 'User'
association to the model. However, the model code is embedded in Rails, including the table schema. To incorporate a belongs_to
association, you'd have to modify the active_storage_blobs
table and monkey-patch the model class. Personally, I'm not a fan of altering tables from libraries, so I chose to use a has_one
association.
# app/models/blob_ownership.rb
class BlobOwnership < ApplicationRecord
belongs_to :active_storage_blob, class_name: 'ActiveStorage::Blob'
belongs_to :user
end
The monkey-patch remains a necessary step.
# config/initializers/active_storage_blob.rb
Rails.configuration.to_prepare do
ActiveStorage::Blob.class_eval do
has_one :blob_ownership, dependent: :destroy, foreign_key: :active_storage_blob_id
has_one :user, through: :blob_ownership
end
end
Now, within the controller, you can save the blob owner.
# app/controllers/direct_uploads_controller.rb
class DirectUploadsController < ActiveStorage::DirectUploadsController
skip_forgery_protection
before_action :require_login
def create
blob = ApplicationRecord.transaction do
ActiveStorage::Blob.create_before_direct_upload!(**blob_args).tap do |as_blob|
BlobOwnership.create!(active_storage_blob: as_blob, user: current_user)
end
end
render json: direct_upload_json(blob)
end
end
Authorize blob attachments
Moving forward, the next step involves authorizing users during file attachments. Consider having a Contract model and a ContractsController.
# app/models/contract.rb
class Contract < ApplicationRecord
has_many_attached :documents
end
# app/controllers/contracts_controller.rb
class ContractsController < ApplicationController
before_action :require_login
def create
contract = Contract.new(contract_params.merge(documents:))
if contract.save
head :created
else
head :bad_request
end
end
private
def contract_params
params.require(:contract).permit(:name)
end
def documents
params[:blob_signed_ids]
end
end
Before attaching the documents, check if the blob_signed_ids
correspond to the current user. I use Pundit for my app's authorization of my app, so I create a policy for blobs.
# app/policies/active_storage/blob_policy.rb
module ActiveStorage
class BlobPolicy < ApplicationPolicy
def attach?
user == record.user
end
end
end
Then use it in the controller.
def documents
params[:blob_signed_ids].map |signed_id|
ActiveStorage::Blob.find_signed(signed_id).tap |as_blob|
authorize(blob, :attach?)
end
end
end
With this setup, even if User A somehow obtains User B's blob signed IDs, they still can't attach the file to their contract, ensuring the protection of User B's files.
Conclusion
In my view, considering file ownership is crucial when implementing direct upload APIs, although I found limited information on this aspect. I hope sharing my experience aids you in seamlessly incorporating this functionality into your services.
Discussion