3scale/porta

View on GitHub
lib/three_scale/middleware/multitenant.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# frozen_string_literal: true

module ThreeScale
  module Middleware

    class Multitenant
      def self.log(message)
        Rails.logger.debug "[MultiTenant] #{message}" if ENV['DEBUG']
      end

      class TenantChecker
        SKIPPED_CONTROLLERS = {
          "admin/api/objects".freeze => true, # this checks objects' tenant_id so we have to skip it
          "provider/domains".freeze => true,
        }.freeze
        private_constant :SKIPPED_CONTROLLERS

        attr_reader :original, :attribute

        class TenantLeak < StandardError
          def initialize(object, attribute, original)
            @object = object
            @attribute = attribute
            @original = original
          end

          def to_s
            "#{@object.class}:#{@object.id} has #{@attribute} #{@object.send(@attribute).inspect} but #{@original.inspect} already exists."
          end
        end

        def initialize(attribute, app, env)
          @original = nil
          @attribute = attribute
          @app = app
          @env = env
        end

        def verify!(object)
          return if SKIPPED_CONTROLLERS[@env["action_dispatch.request.path_parameters"][:controller]]

          # when the ActiveRecord object doesn't have tenant_id attribute at all
          unless object.respond_to?(attribute)
            # Multitenant.log "#{object} does not respond to: #{attribute}"
            return true
          end

          # reload object if it was partially loaded from db without the `tenant_id` attribute
          begin
            current = object.send(attribute)
          rescue ActiveRecord::MissingAttributeError
            # Multitenant.log("#{object} is missing #{attribute}. Reloading and trying again")
            fresh = object.class.unscoped.find(object.id, :select => attribute)
            current = fresh.send(attribute)
          end

          # this is when tenant_id is not set because of a bug or in older installations the master account has it nil
          return if current.nil?

          # in newer installations master account has a tenant_id same as its id, like other providers
          return if object.is_a?(::Account) && object.master

          # once initialized a legitimate AR object with a tenant_id, all others in the request should have the same
          @original ||= current

          return if current == original

          return if master?

          raise TenantLeak.new(object, attribute, original)
        end

        def master?
          return @is_master if defined?(@is_master)

          @master ||= ::Account.unscoped.master

          @is_master = session_of_master? || provider_key_of_master? || token_of_master?
        end

        def session_of_master?
          # this is supposed to match how we get the user_session in app/lib/authenticated_system/request.rb
          # on API calls cookies are not present though, so we need to use safe navigation
          user_session ||= UserSession.authenticate(@env['action_dispatch.cookies']&.signed&.public_send(:[], :user_session))
          user_session&.user&.account == @master
        end

        def provider_key_of_master?
          param_places = %w[action_dispatch.request.query_parameters action_dispatch.request.request_parameters]
          possible_keys = %w[provider_key api_key]
          param_places.product(possible_keys).any? { |params, key| @env[params][key] == @master.provider_key }
        end

        def token_of_master?
          token = @env["action_dispatch.request.query_parameters"]["access_token"] ||
            @env["action_dispatch.request.request_parameters"]["access_token"]
          @master.access_tokens.find_from_value(token)
        end
      end

      module EnforceTenant
        extend ActiveSupport::Concern

        included do
          after_initialize :enforce_tenant!
        end

        private

        def enforce_tenant!
          # Multitenant.log "initialized object #{self.class}:#{self.id}"
          Thread.current[:multitenant]&.verify!(self)
        end
      end

      def initialize(app, attribute)
        @app = app
        @attribute = attribute
        ActiveRecord::Base.send(:include, EnforceTenant)
      end

      def call(env)
        dup._call(env)
      end

      def _call(env)
        Thread.current[:multitenant] = TenantChecker.new(@attribute,@app,env)
        @app.call(env)
      ensure
        Thread.current[:multitenant] = nil
      end
    end
  end
end