deviseのdestroyアクションを実行すると何が起こるか調べてみた

こんにちは!kossyです!




今回は、deviseのdestroyアクションを実行すると何が起こるか調べてみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 3.0.3
Rails 6.0.4
devise 4.8.1



前準備

pry-railsとpry-byebugをGemfileに記載してbundleした後、

sessions_controller.rbのdestroyアクションにbinding.pryを仕込みます。

    25: def destroy
    26:   binding.pry
    27:   signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
    28:   set_flash_message! :notice, :signed_out if signed_out
    29:   yield if block_given?
    30:   respond_to_on_destroy
    31: end

その後、適当なアカウントでログインして、ログアウトボタンを押してREPLを起動できたらOKです。

Devise.sign_out_all_scopes

From: /app/app/controllers/devise/sessions_controller.rb:27 Devise::SessionsController#destroy:

    25: def destroy
    26:   binding.pry
 => 27:   signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name))
    28:   set_flash_message! :notice, :signed_out if signed_out
    29:   yield if block_given?
    30:   respond_to_on_destroy
    31: end

> Devise.sign_out_all_scopes
=> true

Devise.sign_out_all_scopesはconfigファイルで設定できる値かと思われるので、見に行ってみます。

# Set this configuration to false if you want /users/sign_out to sign out
# only the current scope. By default, Devise signs out all scopes.
# config.sign_out_all_scopes = true

もし users/sign_out でサインアウトさせたい場合は false にする必要があるようです。

デフォルト値は true のようです。

「Deviseはすべてのスコープをサインアウトします。」と記載があるため、

例えば管理者権限と一般ユーザー権限の2つのモデルを作成し、両方の権限でログインしてからログアウトすると、全ての権限でログアウトになると思われます。


sign_out

github.com

def sign_out(resource_or_scope = nil)
  return sign_out_all_scopes unless resource_or_scope
  scope = Devise::Mapping.find_scope!(resource_or_scope)
  user = warden.user(scope: scope, run_callbacks: false) # If there is no user

  warden.logout(scope)
  warden.clear_strategies_cache!(scope: scope)
  instance_variable_set(:"@current_#{scope}", nil)

  !!user
end

こちらはREPLで試しながら動作を確認してみます。

$ resource_or_scope
=> nil

このまま1行ずつ実行すると sign_out_all_scopes が実行されてしまうので、resource_or_scopeに値を入れた上で試してみます。

$ resource_or_scope = User.first

$ scope = Devise::Mapping.find_scope!(resource_or_scope)
=> :user

$ user = warden.user(scope: scope, run_callbacks: false)
=> #<User id: 1, ...>

$ warden.logout(scope)
=> nil

$ warden.clear_strategies_cache!(scope: scope)
=> {}

$  instance_variable_set(:"@current_#{scope}", nil)
=> nil

$ !!user
=> true

実際にsessionを削除するような処理はwardenの中で行っているようなので、wardenの処理にstepメソッドで入ってみたいと思います。

From: /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:267 Warden::Proxy#logout:

    266: def logout(*scopes)
 => 267:   if scopes.empty?
    268:     scopes = @users.keys
    269:     reset_session = true
    270:   end
    271:
    272:   scopes.each do |scope|
    273:     user = @users.delete(scope)
    274:     manager._run_callbacks(:before_logout, user, self, :scope => scope)
    275:
    276:     raw_session.delete("warden.user.#{scope}.session") unless raw_session.nil?
    277:     session_serializer.delete(scope, user)
    278:   end
    279:
    280:   reset_session! if reset_session
    281: end

> scopes.empty?
=> false

> user = @users.delete(scope)
=> #<User id: 1, ...>

> raw_session
=> #<ActionDispatch::Request::Session:0x00007fb208019c00 ...>

raw_sessionおよびsession_serializerからsessionをdeleteして、

Railsのreset_session!メソッドを呼び出しているようです。

railsdoc.com

と思いきや、warden_compat.rbのメソッドらしいです。

> method(:reset_session!).source_location
=> ["/usr/local/bundle/gems/devise-4.8.0/lib/devise/rails/warden_compat.rb", 8]
module Warden::Mixins::Common
  def request
    @request ||= ActionDispatch::Request.new(env)
  end

  def reset_session!
    request.reset_session
  end

  def cookies
    request.cookie_jar
  end
end

実際には ActionDispatch::Request クラスのreset_sessionメソッドが呼ばれているようです。

reset_session (ActionController::Base) - APIdock

raw_sessionについての知見が皆無なので調べてみます。

raw_session

先ほどstepで入った処理の中で色々実行してみました。

> raw_session.class
=> ActionDispatch::Request::Session

> raw_session.methods
=> [:loaded?, :to_hash, :delete, :clear, :exists?, :to_h, ...]

> raw_session.to_h
=>  {"session_id"=>"b71549ad67aa9d67e27aa04fea2c0b37",
 "warden.user.user.key"=>[[1], "$2a$12$oLbqXNXAJ3P17TM.7bfaiu"],
 "warden.user.user.session"=>{"unique_session_id"=>"jj2nrehY8TNM-f_nrryY"},
 "_csrf_token"=>"KbKDaYOjWm7jMLPfqctmdhG3ImpMGoQ4y9JX89plxLc="}

> raw_session.method(:to_h).source_location
=> ["/usr/local/bundle/gems/actionpack-6.0.4.1/lib/action_dispatch/request/session.rb", 143]

action_dispatch/request/session.rb を見に行けばよさそう。

github.com

読んでもよくわからなかったので公式ドキュメントを見てみます、、、

Class: ActionDispatch::Request::Session — Documentation for rails (6.0.2.1)

Session is responsible for lazily loading the session from store.

セッションは、ストアからセッションを遅延ロードする責任があります。

出典: https://www.rubydoc.info/docs/rails/ActionDispatch/Request/Session

raw_sessionの役割についてはわかりましたが、定義元がよくわからないので調べてみます。

> method(:raw_session).source_location
=> ["/usr/local/bundle/gems/warden-1.2.9/lib/warden/mixins/common.rb", 9]

github.com

# Convenience method to access the session
# :api: public
def session
  env['rack.session']
end # session

# Alias :session to :raw_session since the former will be user API for storing scoped data.
alias :raw_session :session

env['rack.session']の返り値だったみたいです。

rack.sessionに値が書き込まれるタイミングはいつなのか?が気になりますが、返り値も定義元も役割もわかったので一旦ここまでにします。

session_serializerもいまいちわからんので調べます。

session_serializer

> session_serializer
=> #<Warden::SessionSerializer:0x00007f99501d5790 ...

> method(:session_serializer).source_location
=> ["/usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb", 50]

github.com

def session_serializer
  @session_serializer ||= Warden::SessionSerializer.new(@env)
end

コメントアウト部分を訳してみます。

Points to a SessionSerializer instance responsible for handling everything related with storing, fetching and removing the user session.

ユーザーセッションの保存、フェッチ、および削除に関連するすべての処理を担当するSessionSerializerインスタンスを指します。

出典: https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L46

CRUD全てを担当するクラスっぽいですね。定義元を見に行ってみます。

github.com

deleteメソッドの中身を見てみます。

def delete(scope, user=nil)
  session.delete(key_for(scope))
end

# We can't cache this result because the session can be lazy loaded(セッションを遅延ロードするためにキャッシュを行いません)
def session
  env["rack.session"] || {}
end

def key_for(scope)
  "warden.user.#{scope}.key"
end

deleteメソッドはsessionから warden.user.user.key のようなkeyをdeleteするメソッドでした。

先ほど、「rack.sessionに値が書き込まれるタイミングはいつなのか?」と疑問を書いていましたが、storeメソッドが書き込んでいそうなので処理を追ってみます。

def store(user, scope)
  return unless user
  method_name = "#{scope}_serialize"
  specialized = respond_to?(method_name)
  session[key_for(scope)] = specialized ? send(method_name, user) : serialize(user)
end

storeメソッドの呼び出し元がこちらでした。

github.com

def set_user(user, opts = {})
  scope = (opts[:scope] ||= @config.default_scope)

  # Get the default options from the master configuration for the given scope
  opts = (@config[:scope_defaults][scope] || {}).merge(opts)
  opts[:event] ||= :set_user
  @users[scope] = user

  if opts[:store] != false && opts[:event] != :fetch
    options = env[ENV_SESSION_OPTIONS]
    if options
      if options.frozen?
        env[ENV_SESSION_OPTIONS] = options.merge(:renew => true).freeze
      else
        options[:renew] = true
      end
    end
    session_serializer.store(user, scope)
  end

  run_callbacks = opts.fetch(:run_callbacks, true)
  manager._run_callbacks(:after_set_user, user, self, opts) if run_callbacks

  @users[scope]
end

set_userは_perform_authenticationというprivateメソッドで呼ばれています。

github.com

なので、「rack.sessionに値が書き込まれるタイミングはいつなのか?」という問いは、「_perform_authenticationメソッドでset_userが呼ばれた時」が一つの解かと思います。

ログアウトのことを調査していたのにログイン時の挙動の調査をしてしまいました、、、寄り道はここまでにします。

まとめ

destroyアクションが呼ばれると、

「Devise.sign_out_all_scopesが true なら、

全てのdeviseを使っている認証中のモデルのwardernのsessionを削除し、

falseなら引数に与えられたresourceのモデルのwardenのsessionを削除」

していました。

「wardenのsessionを削除」は、Warden::Proxy#logout メソッドが呼ばれていて、

内部では request.env['rack.session'].clear を呼んでsessionの中身をnilにすることでログアウトを実現してました。

今までログアウトについて、cookie初期化してsession削除してるんだろうな、程度の曖昧な理解に留まっていましたが、実際に処理を追ってみることで、

ログアウトの詳細な動作とwardenの仕組みの一端を理解することができました。

認証周りのコードを読めば読むほど、自分で認証周りを自作してはいけない気持ちになりますね。

devise-securityのparanoid_verificationのソースコードを追ってみた

こんにちは!kossyです!




今回はdevise-securityのparanoid_verificationのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 3.0.3
Rails 6.0.4
devise-security 0.16.0



paranoid_verificationってなに?

paranoid_verification モジュールは、「アプリケーションの管理者がいつでも、ユーザーが自分自身を確認するように強制できる」機能です。

以下のPRが paranoid_verification モジュールが実装された時のものです。

github.com

devise_security gem は メンテの止まった devise_security_extension Gemから公式にforkされたGemのため、blameしてもPRまで遡るのができないコードがあります。

paranoid_verification はform前の実装だったので、devise-security-extension gemの過去のPRを見たところ、以下の記載がありました。

Basically I got requirement for one application that "reset password" links should be additional verified after user set his Password.
He should call application support team and they will give him verification code. (hardcore security)

But another usage of this feature is that at any point admin of application can enforce that user should verify himself.

so the feature: Generate (paranoid) verification code and enforce user to fill in verification code.
Until then user wont be able to use the application (similar functionality of expired password)


基本的に、ユーザーがパスワードを設定した後、「パスワードのリセット」リンクを追加で確認する必要があるという1つのアプリケーションの要件がありました。
ユーザーはアプリケーションサポートチームに電話する必要があり、サポートチームはユーザーに確認コードを与えます。 (ハードコアセキュリティ)

ただし、この機能のもう1つの使用法は、アプリケーションの管理者がいつでも、ユーザーが自分自身を確認するように強制できることです。

そのため、機能: paranoid verification code を生成し、ユーザーに検証コードの入力を強制します。
それまでは、ユーザーはアプリケーションを使用できません(期限切れのパスワードと同様の機能)

出典: https://github.com/phatworx/devise_security_extension/pull/117

「アプリケーションの管理者がいつでも、ユーザーが自分自身を確認するように強制したい」という要件がある場合は、このモジュールを有効活用できそうですね。



コードリーディング

上記の機能をどのように実現しているのでしょうか。実際にコードを読んでコンソールで実行しつつ仕様の理解を進めてみます。


need_paranoid_verification?

github.com

def need_paranoid_verification?
  !!paranoid_verification_code
end

paranoid_verificationを使う際にテーブルに追加する必要のあるカラムである paranoid_verification_code の値の有無をBooleanで返却するメソッドでした。

用途としてはメソッド命名的に検証コードを実行する必要があるかどうか?を判定するために用いるためかと。


generate_paranoid_code

github.com

def generate_paranoid_code
  update_without_password paranoid_verification_code: Devise.verification_code_generator.call(),
                          paranoid_verification_attempt: 0
end

名前の通りparanoid_codeをgenerateし、レシーバーに保存するメソッドのようです。(updateするなら!をメソッド名につけるのが慣習的にいいと思うが)

Devise.verification_code_generator.call()の返り値は以下のように、5文字のランダムな文字列が返るようです。

$ Devise.verification_code_generator.call()
=> "c8e93"
$ Devise.verification_code_generator.call()
=> "98102"

定義元は以下でした。

github.com

# captcha integration for confirmation form
mattr_accessor :verification_code_generator
@@verification_code_generator = -> { SecureRandom.hex[0..4] }

captcha向けに5文字の文字列を返しているんですね。なぜ5文字なんだろう、普通一時的な認証コードって6文字が一般的では?と思ったんですが、captcha向けなら納得です。


paranoid_attempts_remaining

github.com

def paranoid_attempts_remaining
  Devise.paranoid_code_regenerate_after_attempt - paranoid_verification_attempt
end

あと何回検証コードの実行ができるかを返却するメソッドのように見えますが、コンソールで試してみます。

$ user = User.first

$ user.paranoid_verification_attempt
=> 1

$ user.paranoid_attempts_remaining
=> 9

# config/devise-security.rbで設定できる値です(デフォルトは10)
$ Devise.paranoid_code_regenerate_after_attempt
=> 10

verify_code

github.com

def verify_code(code)
  attempt = paranoid_verification_attempt

  if (attempt += 1) >= Devise.paranoid_code_regenerate_after_attempt
    generate_paranoid_code
  elsif code == paranoid_verification_code
    attempt = 0
    update_without_password paranoid_verification_code: nil,
                            paranoid_verified_at: Time.now,
                            paranoid_verification_attempt: attempt
  else
    update_without_password paranoid_verification_attempt: attempt
  end
end

Devise.paranoid_code_regenerate_after_attemptで設定した値を上回っていない場合は generate_paranoid_code を実行して、

引数のcodeとparanoid_verification_codeが一致した場合は、

paranoid_verification_codeをnilで更新
paranoid_verified_atに現在時刻で更新
paranoid_verification_attemptに0で更新

をパスワード抜きで行っていました。

wikiを見てみる

github.com

wikiを見ると、locakbleで提供されているメソッドをオーバーライドして使うことを推奨しているようです。

lock after reset password
One example of usage could be that after a user resets their password they need to contact support for the verification code. Just add to your authentication resource code similar to this:

class User < ActiveRecord::Base
  # ...
  def unlock_access!
    generate_paranoid_code
    super
  end
end

他にも管理者アカウントでロックする方法や検証コードの認証試行回数を表示する方法などが記載されていました。




勉強になりました。

Railsのbuild_associationの挙動がよくわからなかったので調べてみた

こんにちは!kossyです!




今回は、Railsのbuild_associationの挙動がよくわからなかったので調べてみました。



Ruby 2.7.6
Rails 6.0.5.1




前提として、userがreservation(予約)を1つ持つという関連が組まれていることとします。この場合、userクラスのインスタンスメソッドとして自動的にbuild_reservationメソッドが定義されます。


ソースコードを見る

build_associationを定義しているのは singular_association.rb でした。

github.com

    # Defines the (build|create)_association methods for belongs_to or has_one association
    def self.define_constructors(mixin, name)
      mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
        def build_#{name}(*args, &block)
          association(:#{name}).build(*args, &block)
        end
        def create_#{name}(*args, &block)
          association(:#{name}).create(*args, &block)
        end
        def create_#{name}!(*args, &block)
          association(:#{name}).create!(*args, &block)
        end
      CODE
    end

class_evalで動的にメソッド定義を行ってますね。

では実際に呼び出される時はどのような挙動になるのでしょうか。試しにuserクラスに適当なインスタンスメソッドを定義して、binding.pryでdebugしてみましょう。

class User
  has_one :reservation

  def test_method
    binding.pry

    build_reservation
  end
end

この状態でコンソールを開いて、userインスタンスに対してtest_methodを実行します。するとREPLが起動するので、stepメソッドでメソッド内部に移動すると、

lib/active_record/associations/builder/singular_association.rb:29

    28: def build_#{name}(*args, &block)
 => 29:   association(:#{name}).build(*args, &block)
    30: end

動的にメソッド定義を行っている場所に辿り着きました。buildメソッドでインスタンス定義を行なっていると思われるので、処理を見てみます。

lib/active_record/associations/singular_association.rb:21

    20: def build(attributes = {}, &block)
 => 21:   record = build_record(attributes, &block)
    22:   set_new_record(record)
    23:   record
    24: end

build_recordは実際に関連先のインスタンスを生成する処理かと思います。set_new_recordの処理も見てみます。

lib/active_record/associations/has_one_association.rb:75

    74: def set_new_record(record)
 => 75:   replace(record, false)
    76: end

replaceメソッドの中身はこちら。

    42: def replace(record, save = true)
 => 43:   raise_on_type_mismatch!(record) if record
    44:
    45:   return target unless load_target || record
    46:
    47:   assigning_another_record = target != record
    48:   if assigning_another_record || record.has_changes_to_save?
    49:     save &&= owner.persisted?
    50:
    51:     transaction_if(save) do
    52:       remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record
    53:
    54:       if record
    55:         set_owner_attributes(record)
    56:         set_inverse_instance(record)
    57:
    58:         if save && !record.save
    59:           nullify_owner_attributes(record)
    60:           set_owner_attributes(target) if target
    61:           raise RecordNotSaved, "Failed to save the new associated #{reflection.name}."
    62:         end
    63:       end
    64:     end
    65:   end
    66:
    67:   self.target = record
    68: end

いくつかのバリデーションを行った後、target(関連先のレコード)が既に存在していて、関連先のレコードが削除されておらず、新しい関連先のレコードがある場合、

remove_target!メソッドを呼んでいます。メソッド名から察するに、既に存在している関連先のレコードを削除するメソッドだと思いますが、処理を追ってみます。

    78: def remove_target!(method)
 => 79:   case method
    80:   when :delete
    81:     target.delete
    82:   when :destroy
    83:     target.destroyed_by_association = reflection
    84:     target.destroy
    85:   else
    86:     nullify_owner_attributes(target)
    87:     remove_inverse_instance(target)
    88:
    89:     if target.persisted? && owner.persisted? && !target.save
    90:       set_owner_attributes(target)
    91:       raise RecordNotSaved, "Failed to remove the existing associated #{reflection.name}. " \
    92:                             "The record failed to save after its foreign key was set to nil."
    93:     end
    94:   end
    95: end

私の実行環境だと引数のmethodは:destroyでした。

82行目で関連先のレコードに組まれたAssociationのレコードを削除し、83行目で関連先のレコードを削除しています。


まとめ

build_associationメソッドは、既に関連先のレコード(今回だとreservation)が存在する場合は、そのレコードを削除して新たなインスタンスを生成していました。更新時にbuild_associationを呼ぶ場合は挙動を頭に入れた上で実行した方がよさそうですね。(思わぬバグを誘発するかも)

Railsでddtraceを使う際に、Datadogのログに残すリクエストをフィルタリングしたい

こんにちは!kossyです!




さて、今回はDatadog の Ruby 用トレースクライアントであるddtrace gemで、Datadogのトレース対象とするリクエストをフィルタリングする方法について、ブログに残してみたいと思います。




環境
Rails 6.1.4
Ruby 2.7.6
ddtrace 1.4.1




実装方法

結論、以下のコードをconfig/initializers/datadog.rbに記載すればOKです。

Datadog::Pipeline.before_flush(
  Datadog::Pipeline::SpanFilter.new { |span| span.resource =~ /フィルタリングしたいControllerクラス/ },
)

例えばサーバーヘルスチェック用のAPIをトレース対象から除外したい場合は、

Datadog::Pipeline.before_flush(
  Datadog::Pipeline::SpanFilter.new { |span| span.resource =~ /HealthcheckController/ },
)

のように記載すればOKです。


複数のAPIをフィルタリングしたい場合

複数のクラスをトレース対象から除外したい場合は、「|」で繋いで記載すればOKです。

Datadog::Pipeline.before_flush(
  Datadog::Pipeline::SpanFilter.new { |span| span.resource =~ /HealthcheckController|PingController/ },
)

意図通り動くか、正規表現部分のチェックをしてみましょう。

# マッチした場合は0が返る
"HealthcheckController" =~ /PingController|HealthcheckController/
=> 0

# マッチしない場合はnilが返る
"HealthcheckController" =~ /PingController/
=> nil

"HealthcheckController" =~ /HealthcheckController/
=> 0

大いに参考にさせていただいたサイト

docs.datadoghq.com
s3.amazonaws.com

Dockerを使った開発時にpryやirbの履歴を保存したい

こんにちは!kossyです!

今回は開発環境にDockerを使ってRailsアプリを開発する際に、pryやirbの履歴を保存する方法についてブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.0.3
docker-compose version 1.27.0



docker-compose.ymlの編集

まずはdocker-compose.ymlを編集します。

# docker-compose.yml

volumes:
  - history:/usr/local/history

environment:
  IRB_HISTORY_PATH: /usr/local/history/.irb_history
  PRY_HISTORY_PATH: /usr/local/history/.pry_history

environmentにirbもpryも指定していますが、「普段使うデバッガだけ記載する」でもOKです。(複数人で開発する場合はいろんなデバッガを使われる可能性があるので複数記載するでもOK)

.irbrcの作成

irbをメインのデバッガとして使いたい場合は、アプリケーションのルートディレクトリ直下に.irbrcを作成します。

# .irbrc

path = ENV['IRB_HISTORY_PATH']
if path
  IRB.conf[:HISTORY_FILE] = path
end

ENV定数からirbの履歴を保存するパスを取得し、もしパスが設定されていれば、IRBクラスのconfigにパスを指定しています。

.pryrcの作成

pryをメインのデバッガとして使いたい場合は、アプリケーションのルートディレクトリ直下に.pryrcを作成します。

# .pryrc

path = ENV['PRY_HISTORY_PATH']
if path
  Pry.config.history_file = path
end

こちらも.irbrcとやっていることはほとんど一緒で、参照するパスと扱うクラスが違うだけですね。

IRB.confのソースコードを読んでみる

せっかくなので、IRBクラスのソースコードを読んでみましょうか。

docker-composeコマンドでコンテナを立てて rails c でコンソールを立ち上げて検証してみます。

# railsという名前のコンテナを立てる
docker-compose run --rm rails bundle exec rails c
IRB.method(:conf).source_location
=> ["/usr/local/lib/ruby/2.6.0/irb.rb", 350]

source_locationを実行したところ、irb.rbの350行目にconfメソッドが生えているようです。

github.com

コメント部分の記述が詳しかったので、こちらを読んでみます。

# === History
#
# By default, irb will store the last 1000 commands you used in
# <code>IRB.conf[:HISTORY_FILE]</code> (<code>~/.irb_history</code> by default).
#
# If you want to disable history, add the following to your +.irbrc+:
#
#     IRB.conf[:SAVE_HISTORY] = nil
#

デフォルトでは1000コマンドまで保存できて、IRB.conf[:HISTORY_FILE]に保存されるとのこと。(デフォルトは~/.irb_history)

また、履歴の保存をしたくない場合は、.irbrcに IRB.conf[:SAVE_HISTORY] = nil と記載すればOKとのこと。

irb.rbは約900行ほどのファイルなのですが、1/3はコメントが記載されてました。何ができるか知りたい場合は公式ドキュメントに加えて、コメント部分も読んでみるとよさそうです。


Pry.configのソースコードを読んでみる

次はPry.configのソースコードを確認してみましょうか。

Pry.config.method(:history_file).source_location
=> ["/bundle/ruby/2.6.0/gems/pry-0.13.1/lib/pry/config/attributable.rb", 13]

実際にhistory_fileの記載があったのはこちらのファイルでした。

github.com

# @return [String]
attribute :history_file

# 省略

history_file: Pry::History.default_file,

Pry::History.default_fileの定義はこちら。

    def self.default_file
      history_file =
        if (xdg_home = Pry::Env['XDG_DATA_HOME'])
          # See XDG Base Directory Specification at
          # https://standards.freedesktop.org/basedir-spec/basedir-spec-0.8.html
          xdg_home + '/pry/pry_history'
        elsif File.exist?(File.expand_path('~/.pry_history'))
          '~/.pry_history'
        else
          '~/.local/share/pry/pry_history'
        end
      File.expand_path(history_file)
    end

ざっくり言うと基本的にはpry_historyというファイルに履歴が格納されていくコードになっていました。

もう一度.pryrcのコードを見てみます。

# .pryrc

path = ENV['PRY_HISTORY_PATH']
if path
  Pry.config.history_file = path
end

history_fileがattribute として定義されていて書き込みが可能になっているので、default_fileメソッドで定義されたファイルパスを.pryrcで上書きしていることになりますね。

おわりに

dockerの場合だとvolumeの設定をしないとコマンドの実行履歴が揮発してしまうので、一手間加えないとコマンドの実行履歴が保存されないんですね。(コンテナの中で全て完結するんだからそれはそう)

IRBやPryのソースコードを読むことがほとんどなかったので、いい勉強になりました。

大いに参考にさせていただいた記事

素晴らしい記事の執筆、誠にありがとうございます。

r7kamura.com

複数のフラグを1つのカラムで管理できるGem「flag_shih_tzu」の使い方とユースケース

こんにちは!kossyです!

今回は、複数のフラグを1つのカラムで管理できるGem「flag_shih_tzu」の使い方とユースケースについて、

備忘録としてブログに残してみたいと思います。

環境

flag_shih_tzu とは

Booleanを含んだ配列を、一つの整数として格納できるActiveRecordのエクステンションです。MySQL, PostgreSQLSQLite等、DBを問わず動作します。

使い方は簡単で、has_flagメソッドに、管理したいフラグ名を数値と共に渡すだけで実現できます。

ちなみに、「shih_tzu」の由来は犬の品種の一つである、「シーズー」から命名したらしいです。

使い方

まずはApplicationRecordを継承したモデルに FlagShihTzu モジュールをincludeします。

class Admin < ActiveRecord::Base
  include FlagShihTzu
end

次に、フラグを格納するカラムを作成するマイグレーションファイルを用意します。

  def change
    add_column :admins, :function_flags, :integer, default: 0, null: false, comment: '機能利用フラグ'
  end

次に、has_flagメソッドに数値と管理したいフラグ名をシンボルで渡します。

columnにカラム名を渡せば、渡したカラムでフラグを管理することができます。

class Admin < ActiveRecord::Base
  include FlagShihTzu

  has_flag(
    1 => :use_announcement,
    2 => :use_term_of_service_version,
    3 => :use_privacy_policy,
    :column => 'function_flags'
  )
end

これで準備完了です。

各種メソッド

上記の定義を行った場合、以下のインスタンスメソッドが定義されます。

Admin#all_features # [:use_announcement, :use_term_of_service_version, :use_privacy_policy]
Admin#selected_features
Admin#select_all_features
Admin#unselect_all_features
Admin#selected_features=

Admin#use_announcement
Admin#use_announcement?
Admin#use_announcement=
Admin#not_use_announcement
Admin#not_use_announcement?
Admin#not_use_announcement=
Admin#use_announcement_changed?
Admin#has_use_announcement?

Admin#use_term_of_service_version
Admin#use_term_of_service_version?
Admin#use_term_of_service_version=
Admin#not_use_term_of_service_version
Admin#not_use_term_of_service_version?
Admin#not_use_term_of_service_version=
Admin#use_term_of_service_version_changed?
Admin#has_use_term_of_service_version?

Admin#use_privacy_policy
Admin#use_privacy_policy?
Admin#use_privacy_policy=
Admin#not_use_privacy_policy
Admin#not_use_privacy_policy?
Admin#not_use_privacy_policy=
Admin#use_privacy_policy_changed?
Admin#use_privacy_policy?

また、以下のクラスメソッドも定義されます。

Admin.use_announcement         # :conditions => "(admins.function_flags in (1,3,5,7))"
Admin.not_use_announcement     # :conditions => "(admins.function_flags not in (1,3,5,7))"
Admin.use_term_of_service_version           # :conditions => "(admins.function_flags in (2,3,6,7))"
Admin.not_use_term_of_service_version       # :conditions => "(admins.function_flags not in (2,3,6,7))"
Admin.use_privacy_policy      # :conditions => "(admins.function_flags in (4,5,6,7))"
Admin.not_use_privacy_policy  # :conditions => "(admins.function_flags not in (4,5,6,7))"

他にも、callbackメソッドやフラグをupdateするメソッドも定義されますので、

より詳しく知りたい方はReadMeをご覧ください。

github.com

ユースケース

複数のフラグを1つのカラムで管理したくなる時がいつか?という観点で考えてみました。

1. 機能利用フラグを管理したい時

例えば管理者権限があるとして、管理者の中でも権限を弱くしたい、といったビジネス要求は往々にしてあります。

そういった際に機能ごとに使用権限を管理することになることが多いと思いますが、機能ごとにフラグのカラムを用意していくと、

機能が追加されるたびにフラグのカラムを追加することになり、管理コストが増します。

そういったケースで、このGemが役に立つのではないでしょうか。

他にもあると思うので随時更新します。

おわりに

実際に開発中のプロダクトでも、際限なく機能利用フラグが増えていっているので、すぐにでも導入したいと思いました。

既にたくさんのフラグが生えているテーブルからの移行については知見がまだないので、そういったケースでどう移行を実現するのか?は需要がありそうな気がしています。

外来診療管理システムのデータモデルを考えてみた

こんにちは!kossyです。

今回は、外来診療管理システムのデータモデルの例が掲載されている書籍がありまして、そちらの内容を自分なりに咀嚼してRDBMSに落とし込んでみたので、備忘録としてブログに残してみたいと思います。

WIPですので随時更新します。

登場人物

  • 患者
  • 医師
  • システム管理者

ユーザーストーリー

患者

DB設計

テーブルは全てid、created_at、updated_atカラムが定義されているものとします。

patients

患者テーブル

name type options description
number string null: false 患者番号
born_on date null: false 生年月日
sex integer null: false 性別
job_id bigint foreign_key: true, index: true, optional: true 職業(外部キー)

jobs

職業テーブル

name type options description
name string null: false, index: true 職業名

insurance_cards

保険証テーブル

name type options description
symbol string null: false 記号
number string null: false 番号
delivery_on date null: false 交付日
acquisition_on date 資格取得日
relationship integer null: false 続柄
expires_on date 有効期限
insurer_number string null: false, 6桁or8桁 保険者番号

備考

  • 被用者保険か国民健康保険かで記載してある項目にバラつきがあり、必ずnullになることもあるが、許容する

departments

診療科テーブル

name type options description
name string null: false 診療科名

doctors

医師テーブル

name type options description
last_name string null: false
first_name string null: false
last_name_kana string null: false 姓(カナ)
first_name_kana string null: false 名(カナ)

doctor_departments

医師と診療科の中間テーブル

name type options description
doctor_id bigint null: false, index: true, foreign_key: true 外部キー
department_id bigint null: false, index: true, foreign_key: true 外部キー

consultations

受診履歴テーブル

name type options description
patient_id bigint null: false, index: true, foreign_key: true 外部キー
doctor_id bigint null: false, index: true, foreign_key: true 外部キー
visited_on date null: false 受診日
self_pay_ratio integer null: false 自己負担割合
chief_complaint text null: false 主訴
findings text null: false 所見
visit_category integer null: false 受診区分。初診または再診
status integer null: false 受診状況

medical_histories

既往歴テーブル

name type options description
patient_id bigint null: false, index: true, foreign_key: true 外部キー
age integer null: false 罹患時年齢
name string 傷病名
descriptions text 摘要

consultation_schedules

診察日程テーブル

name type options description
department_id bigint null: false, index: true, foreign_key: true 外部キー
schedule_on date null: false, index: true 診察日
time_category integer null: false 午前/午後区分
patient_count integer null: false, default: 0 診察患者数
max_wait_patient_count integer null: false, default: 0 最大待ち患者数

doctor_schedules

医師日程テーブル

name type options description
doctor_id bigint null: false, index: true, foreign_key: true 外部キー
consultation_schedule_id bigint null: false, index: true, foreign_key: true 外部キー
patient_count integer null: false, default: 0 診察患者数

business_times

診察受付日時テーブル

name type options description
am_start_at datetime null: false 午前診察開始日時
am_start_at datetime null: false 午前診察終了日時
pm_start_at datetime null: false 午後診察開始日時
pm_start_at datetime null: false 午後診察終了日時

sy_masters

傷病マスターテーブル

name type options description
code string null: false, index: true 傷病コード
name string null: false, index: true 傷病名

consultation_sy_details

傷病明細テーブル

name type options description
code string null: false, index: true 傷病コード
consultation_id bigint null: false, index: true, foreign_key: true 外部キー
outcome_category integer null: false 転帰区分
is_main_disease boolean null: false, default: false 主傷病フラグ

treatment_masters

処置マスターテーブル

name type options description
code string null: false 処置コード
name string null: false 処置名
category integer null: false 処置区分(処置/注射/点滴)

treatment_details

処置明細テーブル

name type options description
code string null: false 処置コード
consultation_id bigint null: false, index: true, foreign_key: true 外部キー
descriptions text null: false 摘要

medicine_masters

薬品マスターテーブル

name type options description
code string null: false 薬品コード
name string null: false 薬品名
unit string null: false 単位

consultation_medicine_details

投薬明細テーブル

name type options description
code string null: false 薬品コード
dosing string null: false 服用方法
per_time integer null: false 1回あたり服用量
doses integer null: false 服用回数
way string null: false 投薬方法

Time-based One-Time Passwordの仕組みを提供するGem「rotp」をサクッと試して内部実装を把握する

こんにちは!kossyです!



今回はTime-based One-Time Passwordの仕組みを提供するGem「rotp」をサクッと試して内部実装を把握してみたので、ブログに残してみたいと思います。




環境
Ruby 2.6.6
Rails 6.0.3
MacOS catalina
rotp 6.2.0



まずはコンソールでサクッと試す

github.com

何はともあれGemfileの編集とbundle install。

#  Gemfile

gem 'rotp'
your_app $ bundle

インストールが終了したらReadMeを見ながらコンソールで試してみます。

$ totp = ROTP::TOTP.new("kossywebengineer", issuer: "Test Service")

$ totp.now
=> 388204

$ totp.verify('388204')
=> 1622295690

# 30秒後に再度実行

$ totp.verify('388204')
=> nil

# 前回実行時から6桁の認証コードが変わっている

$ totp.now
=> 197355

30秒で認証コードが更新されていることがわかります。
verifyメソッドを実行してnilが返った場合は、ワンタイムパスワードが間違っていて認証に失敗したことを表していると思われます。

ちなみにQRコードを生成したい場合は、provisioning_uriメソッドを使います。

$ uri = totp.provisioning_uri('kossywebengineer@example.com')
=> "otpauth://totp/Test%20Service:kossywebengineer%40example.com?secret=kossy-web-engineer&issuer=Test%20Service

このURIQRコード生成サイトでQRコード化します。

qr.quel.jp

生成したQRコードスマホの GoogleAuthenticator 等のアプリで読み取ります。

play.google.com

GoogleAuthenticatorに6桁の認証コードが表示されますので、そのコードをvefiryメソッドに渡してみます。

$ totp.verify('651353')
=> 1622295720

認証に成功しました。

サクッと実行して挙動を確認してみましたが、rotpでは認証の部分のみの実装のため、もしWebアプリ内で2要素認証を組み込むとなると、

QRコードの生成」
「2要素認証を有効にしているかどうか」
「secret_keyの管理(newの引数に渡す認証keyです)」
「そのsecret_keyは有効になっているものかどうか」

等を管理する必要があります。上記(QRコードの生成は別ですが)をよしなに行ってくれているのが、devise_two_factorというGemになります。

github.com


rotpの内部実装を追ってみる

ここからはrotpの内部実装をソースコードを実行しながら追ってみたいと思います。

ROTP::TOTP.new

initializeメソッドから見ていきます。

module ROTP
  DEFAULT_INTERVAL = 30
  class TOTP < OTP
    attr_reader :interval, :issuer

    # @option options [Integer] interval (30) the time interval in seconds for OTP
    #     This defaults to 30 which is standard.
    def initialize(s, options = {})
      @interval = options[:interval] || DEFAULT_INTERVAL
      @issuer = options[:issuer]
      super
    end

  # 省略
end

引数で渡されたintervalとissuerをインスタンス変数として定義し、superクラス(OTPクラス)のinitializeを呼び出す実装になっていました。

コンソールで試してみます。

$ totp = ROTP::TOTP.new("kossywebengineer", interval: 15, issuer: "Test Service")
=> #<ROTP::TOTP:0x000056128fa78698
 @digest="sha1",
 @digits=6,
 @interval=15,
 @issuer="Test Service",
 @secret="kossy-web-engineer">

$ totp.now
=> 086478

# 15秒後に再度実行。認証コードが変わっている

$ totp.now
=> 800896

インターバルを15秒に設定したので、15秒で認証コードが更新されています。

OTPクラスのinitializeメソッドも見てみましょう。

module ROTP
  class OTP
    attr_reader :secret, :digits, :digest
    DEFAULT_DIGITS = 6

    # @param [String] secret in the form of base32
    # @option options digits [Integer] (6)
    #     Number of integers in the OTP.
    #     Google Authenticate only supports 6 currently
    # @option options digest [String] (sha1)
    #     Digest used in the HMAC.
    #     Google Authenticate only supports 'sha1' currently
    # @returns [OTP] OTP instantiation
    def initialize(s, options = {})
      @digits = options[:digits] || DEFAULT_DIGITS
      @digest = options[:digest] || 'sha1'
      @secret = s
    end
end

デフォルトのdigits(認証コードの桁数)は「6」で、ハッシュ関数は「sha1」となっています。

こちらもコンソールで試してみましょう。

$ totp = ROTP::TOTP.new("kossywebengineer", digits: 8, issuer: "Test Service")
=>  #<ROTP::TOTP:0x000056128a41b228
 @digest="sha1",
 @digits=8,
 @interval=30,
 @issuer="Test Service",
 @secret="kossywebengineer">

$ totp.now
=> "95928414"

$ totp = ROTP::TOTP.new("kossywebengineer", digest: 'md5', issuer: "Test Service")
=> #<ROTP::TOTP:0x000056128d676b78
 @digest="md5",
 @digits=6,
 @interval=30,
 @issuer="Test Service",
 @secret="kossywebengineer">

# サポートされていないdigestを指定するとnow実行時に例外が上がる
$ ROTP::TOTP.new("kossywebengineer", digest: 'sha0', issuer: "Test Service").now
=> RuntimeError: Unsupported digest algorithm (sha0).: first num too large

$ ROTP::TOTP.new("kossywebengineer", digest: 'sha128', issuer: "Test Service").now
=> RuntimeError: Unsupported digest algorithm (sha128).: first num too large

認証アプリにGoogleAuthenticatorを用いた場合、サポートしている桁数が6桁なので、8桁のdigitsには対応していないので注意が必要とのこと。(2021年5月時点)

同様にdigestも「sha1」のみのサポートとのこと。

残りの処理はsecret_keyのインスタンス変数化処理でした。

initializeメソッドも一通り把握できたので、次はnowメソッドを見てみます。

ROTP::TOTP#now

# Generate the current time OTP
# @return [Integer] the OTP as an integer
def now
  generate_otp(timecode(Time.now))
end

# generate_otpメソッドはROTP::OTPクラスに定義されています。

# @param [Integer] input the number used seed the HMAC
# @option padded [Boolean] (false) Output the otp as a 0 padded string
# Usually either the counter, or the computed integer
# based on the Unix timestamp
def generate_otp(input)
  hmac = OpenSSL::HMAC.digest(
    OpenSSL::Digest.new(digest),
    byte_secret,
    int_to_bytestring(input)
  )

  offset = hmac[-1].ord & 0xf
  code = (hmac[offset].ord & 0x7f) << 24 |
          (hmac[offset + 1].ord & 0xff) << 16 |
          (hmac[offset + 2].ord & 0xff) << 8 |
          (hmac[offset + 3].ord & 0xff)
  (code % 10**digits).to_s.rjust(digits, '0')
end

generate_otpに渡しているtimecodeメソッドを見てみます。

def timecode(time)
  timeint(time) / interval # intervalはinitialize時にセットされた値です。デフォルトはintで「30」
end

# Ensure UTC int
def timeint(time)
  return time.to_i unless time.class == Time

  time.utc.to_i
end

引数がTimeクラスの値でなければ引数をto_iしたものを返却して、そうでない場合は.utc.to_iした値を返却していました。

返却された値を、initialize時に設定したintervalの値で割った値を返却するメソッドでした。

generate_otpの処理は、引数の数値とdigits、secret_keyをBase32でdecodeした値を元に、ワンタイムパスワードを生成しています。このワンタイムパスワードがnowメソッドの返り値となります。

次にverifyメソッドを見てみます。

ROTP::TOTP#verify

# Verifies the OTP passed in against the current time OTP
# and adjacent intervals up to +drift+.  Excludes OTPs
# from `after` and earlier.  Returns time value of
# matching OTP code for use in subsequent call.
# @param otp [String] the one time password to verify
# @param drift_behind [Integer] how many seconds to look back
# @param drift_ahead [Integer] how many seconds to look ahead
# @param after [Integer] prevent token reuse, last login timestamp
# @param at [Time] time at which to generate and verify a particular
#   otp. default Time.now
# @return [Integer, nil] the last successful timestamp
#   interval
def verify(otp, drift_ahead: 0, drift_behind: 0, after: nil, at: Time.now)
  timecodes = get_timecodes(at, drift_behind, drift_ahead)

  timecodes = timecodes.select { |t| t > timecode(after) } if after

  result = nil
  timecodes.each do |t|
    result = t * interval if super(otp, generate_otp(t))
  end
  result
end

コメントアウト部分を訳してみます。

渡された OTP を、現在時刻の OTP および +drift+ までの隣接する間隔に対して検証します。 「後」以前の OTP を除外します。
後続の呼び出しで使用するために、一致する OTP コードの時間値を返します。

@param otp [String] 検証するワンタイムパスワード
@param drift_behind [Integer] 遡って何秒か
@param drift_ahead [Integer] 先読みする秒数
@param after [Integer] は、トークンの再利用、最終ログインのタイムスタンプを防止します
@param at [Time] 特定の

driftは認証コードが有効な期間を設定できるものです。例えばSMSで認証コードを伝える場合、デフォルトの30秒のままだと受け取ってからすぐに認証期限が切れてしまいますが、
driftの設定を長めに設定することで、この問題を回避することができます。

Google翻訳なので一部日本語がおかしいところもありますが、概ね内容は理解できました。

まずはget_timecodesメソッドから読んでみます。

# Get back an array of timecodes for a period
def get_timecodes(at, drift_behind, drift_ahead)
  now = timeint(at)
  timecode_start = timecode(now - drift_behind)
  timecode_end = timecode(now + drift_ahead)
  (timecode_start..timecode_end).step(1).to_a
end

先ほど読んだtimeintメソッドとtimecodeメソッドを使いながら、引数のdrift値を使ってRangeオブジェクトから配列を生成して返却するメソッドになっています。

返り値は以下のような値になります。

=> [54078254, 54078255, 54078256, 54078257, 54078258, ...]

このtimecodesを引数のafterが渡されていれば、afterで指定された秒数で計算したtimecodeよりも数値が大きいものをselectして、新たなtimecodesを生成しています。

読み進めるとsuperメソッドでOTPクラスのverifyが呼ばれているので、こちらも読んでみます。

def verify(input, generated)
  raise ArgumentError, '`otp` should be a String' unless
      input.is_a?(String)

  time_constant_compare(input, generated)
end

# constant-time compare the strings
def time_constant_compare(a, b)
  return false if a.empty? || b.empty? || a.bytesize != b.bytesize

  l = a.unpack "C#{a.bytesize}"
  res = 0
  b.each_byte { |byte| res |= byte ^ l.shift }
  res == 0
end

こちらもコンソールで試してみます。

$ totp = ROTP::TOTP.new("kossywebengineer", issuer: "Test Service")

$ a, b = [totp.now] * 2 # 今回のコードは435752です

$ a.empty? || b.empty? || a.bytesize != b.bytesize
=> false

$ l = a.unpack "C#{a.bytesize}" # a.bytesizeの返り値は6です
=> [52, 51, 53, 55, 53, 50]

$ b.each_byte { |byte| res |= byte ^ l.shift }
=> "435752"

$ res
=> 0

# ここからeach_bytesの挙動の確認
$ l = a.unpack "C#{a.bytesize}"

$ res |= b.bytes[0] ^ l.shift
=> 0
$ res |= b.bytes[1] ^ l.shift
=> 0
$  res |= b.bytes[2] ^ l.shift
=> 0
$  res |= b.bytes[3] ^ l.shift
=> 0
$  res |= b.bytes[4] ^ l.shift
=> 0
$  res |= b.bytes[5] ^ l.shift
=> 0

$ l
=> []

引数で渡されたワンタイムパスワードと、現在のワンタイムパスワードをバイナリにしてビット演算し、両者が一致(0だったら)していれば TRUE が返る処理になっていました。

なぜこのような処理になっているかはrotp内では言及されていませんでしたが、deviseに似たような処理があり、以下のブログで、
「これは、認証用の文字列 (ハッシュされたパスワードか API トークンかに関係なく) を比較するときに本当に実行したいことです。
これにより、アプリケーションでタイミング攻撃を使用することがはるかに難しくなります。」

と言及されていました。なので、Timing Attackを軽減させるための実装なのかもしれません。

spazidigitali.com

寄り道が長くなってしまいました。

OTPクラスのverifyが TRUE だった場合、timecodeとintervalを乗算した値が返り値となります。

一度もvefiryが TRUE にならなかった場合は、nilが返り値となります。


まとめ

Tokenの生成やビット演算など、普段Webアプリの実装をしているとあまり見かけないコードにお目にかかることができました。

2要素認証の機能をごくごく簡単に実装できる素晴らしいGemだと思うので、機会があれば使ってみようかと思います。

メールのFromに特殊文字を入れるとBounceすることがある

こんにちは!kossyです!




さて、今回はメール送信時にFromに特殊文字を入れてBounceする事案に遭遇したので、
ブログに残してみたいと思います。



[Sample] Sampleメールです

上記のように、送信元の文字の始まりが 半角の [ の場合、メールがBounceされてしまうことがあります。

Ruby On Rails のActionMailer等でメールを送信している場合、以下のような記述になっていると、問題が発生する可能性があります。

mail from: "[Sample] <no-reply@sample.com>", subject: 'Sample'

どうしてもFromに差出人名を入れたい場合は、半角カッコではなく、「【 」の ような全角カッコを使うようにしましょう。

mail from: "【Sample】 <no-reply@sample.com>", subject: 'Sample'

ActionMailerでHeaderの中身を確認したい

こんにちは!kossyです!




今回はActionMailerで送信するメールのHeaderの中身をコンソールから確認する方法について、

ブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.1.4
sendgrid



deliver_now を使う

Jobでメール送信をQueueする場合はdeliver_laterを使うのが一般的だと思いますが、

deliver_nowを使うと、JobではなくWebサーバー上で同期的にメールを送信することができます。

mail = SampleMailer.send_mail.deliver_now
<Mail::Message:99301180,
  Multipart: false,
  Headers:
    <Date: Thu, 03 Feb 2022 18:01:39 +0900>,
    <From: sample <no-reply-qa@mail.sample.com>>,
    <To: ["samplestaff001@sample.co.jp", "samplestaff002@sample.co.jp"]>,
    <Bcc: ["sample.co.jp"]>,
    <Message-ID: <sample@ip-10-1-3-173.ap-northeast-1.compute.internal.mail>>,
    <Subject: 【sample】サンプルメールです。>,
    <Mime-Verscomn: 1.0>,
    <Content-Type: text/html>,
    <Content-Transfer-Encoding: quoted-printable>,
    <categories: www.sample.xyz, sample>,
    <send-at: 1643878898>,
    <custom-args: {:send_at=>1643878898, :environment=>"www.sample.xyz",:mailer=>"sample"}>
>

Headersの部分がメール送信時のHeaderになります。

ちなみにdeliver_nowの返り値は Mail::Message オブジェクトになっているので、 headerメソッドのfield_summaryというメソッドが使えます。

github.com

field_summaryメソッドを使うと、Header部分をinspectした値を得られます。

mail.header.field_summary
=> "<Date: Thu, 03 Feb 2022 18:01:39 +0900>,
    <From: Sample <no-reply-sample@mail.sample.xyz>>,
    <To: [\"sample1@sample.co.jp\", \"sample2@sample.co.jp\"]>,
    <Bcc: [\"sample3@sample.co.jp\"]>,
    <Message-ID: <sample@ip-10-1-3-173.ap-northeast-1.compute.internal.mail>>,
    <Subject: 【sample】サンプルメールです。>,
    <Mime-Version: 1.0>,
    <Content-Type: text/html>,
    <Content-Transfer-Encoding: quoted-printable>,
    <categories: www.sample.xyz, sample>,
    <send-at: 1643878898>,
    <custom-args: {:send_at=>1643878898, :environment=>\"www.sample.xyz\",
      :mailer=>\"sample\"}>"

勉強になりました。