🎃

Authorization for ActiveStorage DirectUpload

2023/12/25に公開

This post is a part of YAMAP Engineers Advent Calendar 2023.
https://qiita.com/advent-calendar/2023/yamap-engineers

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.

YAMAP テックブログ

Discussion