Class: PatientLLM::AwsRequestSigner

Inherits:
Object
  • Object
show all
Defined in:
lib/patient_llm/aws_request_signer.rb

Overview

Signs outgoing requests with an AWS SigV4 signature. Instances are callable, so one can be registered directly as a PatientHttp request preprocessor for providers that require request signing (AWS Bedrock):

The credentials can be given as a credential chain (any object responding to resolve, e.g. Aws::CredentialProviderChain), a credentials provider (responding to credentials), or a static credentials object (responding to access_key_id and secret_access_key, e.g. Aws::Credentials). A chain is resolved lazily on the first request and the resolved provider is reused thereafter.

The signing service and region can be passed explicitly; when omitted they are derived from each request's URL host for standard <service>.<region>.<dns-suffix> endpoints, including dual-stack api.aws hosts (e.g. bedrock-runtime.us-east-1.amazonaws.com signs as service "bedrock" and bedrock-mantle.us-east-1.api.aws as "bedrock-mantle", both in region "us-east-1").

Only a whitelist of request headers is signed (see DEFAULT_SIGNED_HEADERS; override with signed_headers:) because intermediaries commonly rewrite other headers in transit — the Bedrock Mantle gateway, for one, replaces x-request-id before validating the signature. The signature also covers a host header derived from the request URL, but the host header itself is never set on the request — the HTTP client generates its own from the URL, and sending both would invalidate the signature.

The aws-sigv4 gem must be loaded for this class to work, but it is not a dependency of this gem; add it to your bundle (it is included with the aws-sdk-core gem).

Examples:

PatientHttp::Sidekiq.configure do |config|
  config.register_preprocessor(:aws_sigv4, PatientLLM::AwsRequestSigner.new(
    credentials: Aws::CredentialProviderChain.new
  ))
end

Constant Summary collapse

REGION_PATTERN =

Matches AWS region host labels like "us-east-1" or "us-gov-west-1".

/\A[a-z]{2}(?:-gov|-iso[a-z]?)?-[a-z]+-\d+\z/
AWS_DNS_SUFFIXES =

DNS suffixes used by AWS endpoint hosts: the standard and China suffixes plus their dual-stack equivalents.

[
  ".amazonaws.com",
  ".api.aws",
  ".amazonaws.com.cn",
  ".api.amazonwebservices.com.cn"
].freeze
SIGNING_NAME_ALIASES =

Endpoint host labels whose SigV4 signing name differs from the label. Note that bedrock-mantle is intentionally absent: the Mantle endpoint signs under its own "bedrock-mantle" service name.

{
  "bedrock-runtime" => "bedrock",
  "bedrock-agent-runtime" => "bedrock"
}.freeze
DEFAULT_SIGNED_HEADERS =

Request headers included in the signature by default. Signing is whitelist-based because proxies and gateways commonly rewrite other headers in transit (the Bedrock Mantle gateway replaces x-request-id with its own id before validating the signature), which would invalidate any signature that covers them. The signer itself always adds and signs host, x-amz-date, x-amz-content-sha256, and x-amz-security-token.

["content-type", "anthropic-version"].freeze

Instance Method Summary collapse

Constructor Details

#initialize(credentials:, service: nil, region: nil, signed_headers: DEFAULT_SIGNED_HEADERS) ⇒ AwsRequestSigner

Returns a new instance of AwsRequestSigner.

Parameters:

  • credentials (Object)

    a credential chain (responds to resolve), a credentials provider (responds to credentials), or a credentials object (responds to access_key_id and secret_access_key)

  • service (String, nil) (defaults to: nil)

    the SigV4 signing name (e.g. "bedrock"); derived from the request URL host when nil

  • region (String, nil) (defaults to: nil)

    the AWS region (e.g. "us-east-1"); derived from the request URL host when nil

  • signed_headers (Array<String>) (defaults to: DEFAULT_SIGNED_HEADERS)

    request header names included in the signature when present; defaults to DEFAULT_SIGNED_HEADERS

Raises:

  • (LoadError)

    if the aws-sigv4 gem is not loaded

  • (ArgumentError)

    if the credentials do not respond to any of the supported interfaces



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/patient_llm/aws_request_signer.rb', line 83

def initialize(credentials:, service: nil, region: nil, signed_headers: DEFAULT_SIGNED_HEADERS)
  unless defined?(Aws::Sigv4::Signer)
    raise LoadError, "#{self.class.name} requires the aws-sigv4 gem, which is not a dependency of patient_llm. Add `gem \"aws-sigv4\"` to your Gemfile and require \"aws-sigv4\" (the gem is included with aws-sdk-core)."
  end

  supported = credentials.respond_to?(:resolve) ||
    credentials.respond_to?(:credentials) ||
    (credentials.respond_to?(:access_key_id) && credentials.respond_to?(:secret_access_key))
  unless supported
    raise ArgumentError, "credentials must be a credential chain (responds to resolve), a credentials provider (responds to credentials), or a credentials object (responds to access_key_id and secret_access_key)"
  end

  @credentials = credentials
  @service = service
  @region = region
  @signed_headers = signed_headers.map { |name| name.to_s.downcase }
  @signer = nil
  @resolved_provider = nil
  @mutex = Mutex.new
end

Instance Method Details

#call(request) ⇒ void

This method returns an undefined value.

Sign the outgoing request, adding the SigV4 signature headers to it.

Parameters:

  • request (PatientHttp::OutgoingRequest)

    the request about to be sent

Raises:

  • (ArgumentError)

    if the service or region was not given and cannot be derived from the request URL host



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/patient_llm/aws_request_signer.rb', line 110

def call(request)
  signature = signer(request.url).sign_request(
    http_method: request.http_method.to_s.upcase,
    url: request.url,
    headers: request.headers.to_h.slice(*@signed_headers),
    body: request.body.to_s
  )
  # The signature covers a host header derived from the URL, but it must
  # not be set on the request: the HTTP client generates its own Host
  # header from the URL, and sending both makes the server canonicalize a
  # doubled value that can never match the signature.
  signature.headers.each do |name, value|
    request.headers[name] = value unless name == "host"
  end
end