Class: AceMQ::AMQP::Credentials

Inherits:
Object
  • Object
show all
Defined in:
lib/acemq/amqp/credentials.rb

Overview

A username and a secret the broker will accept.

This exists so a password does not have to live in a connection string. A URL is the one piece of configuration that gets printed: it goes in error messages, in structured logs, in ps output when it arrives as an argument, and in whatever the deployment tool echoes back. Once a password has been through any of those it has to be rotated, and the rotation is the expensive part. Credentials passed separately never take that trip.

mq = Connection.open("amqp://broker:5672",
                   credentials: Credentials.from_env)

The second reason this is a type rather than two strings is #inspect. A bare string reaches a log the moment anything dumps the object holding it — an exception, a p left in during debugging, a structured logger that serialises its context. This class renders as its username and the word [REDACTED], whichever of inspect, to_s or %p asks, so the accident produces something useless rather than something to rotate.

Constant Summary collapse

REDACTED =

What replaces the secret in every rendering of this object.

"[REDACTED]"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(username: nil, password: nil) ⇒ Credentials

Returns a new instance of Credentials.

Parameters:

  • username (String, nil) (defaults to: nil)
  • password (String, nil) (defaults to: nil) —

    the password or token



58
59
60
61
62
# File 'lib/acemq/amqp/credentials.rb', line 58

def initialize(username: nil, password: nil)
  @username = username.to_s
  @secret = password.to_s
  freeze
end

Instance Attribute Details

#secret ⇒ Object (readonly)

The password or token. Do not log the result.

Named secret rather than password because it is also where a bearer token lives, and a reader who sees password on a token is entitled to assume the token was the wrong thing to put there.



182
183
184
# File 'lib/acemq/amqp/credentials.rb', line 182

def secret
  @secret
end

#username ⇒ Object (readonly)

The account name, or an empty string for a bearer token.



54
55
56
# File 'lib/acemq/amqp/credentials.rb', line 54

def username
  @username
end

Class Method Details

.describe(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

A short description of a value for an error message, naming its class rather than printing it — the thing that was passed in place of credentials is quite likely to be the password itself.



154
155
156
# File 'lib/acemq/amqp/credentials.rb', line 154

def self.describe(value)
  value.nil? ? "nil" : "a #{value.class}"
end

.from_env(username_variable: "ACEMQ_USERNAME", password_variable: "ACEMQ_PASSWORD") ⇒ Object

Credentials read from the environment.

The environment is where an orchestrator puts a secret, and reading it here rather than interpolating it into a URL somewhere in a start-up script is the difference between a password that stays in the process and one that ends up in the command line.

Parameters:

  • username_variable (String) (defaults to: "ACEMQ_USERNAME") —

    the variable holding the account name

  • password_variable (String) (defaults to: "ACEMQ_PASSWORD") —

    the variable holding the secret

Raises:



85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/acemq/amqp/credentials.rb', line 85

def self.from_env(username_variable: "ACEMQ_USERNAME",
                  password_variable: "ACEMQ_PASSWORD")
  username = ENV.fetch(username_variable, nil)
  password = ENV.fetch(password_variable, nil)
  if username.nil? && password.nil?
    raise ConfigurationError,
          "neither #{username_variable} nor #{password_variable} is set in the " \
          "environment, so there are no credentials to read"
  end

  new(username: username, password: password)
end

.from_file(path, username: nil) ⇒ Object

Credentials read from a file, which is how a mounted Kubernetes secret or a Docker secret arrives.

The file holds the password alone, or username:password when it carries both. Trailing whitespace is trimmed, because a file written by an editor almost always ends in a newline, and a password with a newline on the end is refused by the broker with an error that says nothing about newlines.

Read now rather than remembered as a path: a caller that wants the file re-read on every connection — a sidecar rotating the secret underneath it — passes a block instead, which resolve calls at connection time.

Parameters:

  • path (String)
  • username (String, nil) (defaults to: nil) —

    when the file holds the secret alone

Raises:



114
115
116
117
118
119
120
121
122
# File 'lib/acemq/amqp/credentials.rb', line 114

def self.from_file(path, username: nil)
  contents = begin
    File.read(path)
  rescue SystemCallError, IOError => e
    raise ConfigurationError, "cannot read the credentials file #{path}: #{e.message}"
  end

  from_file_contents(contents.sub(/[ \t\r\n]+\z/, ""), path, username)
end

.of(username:, password:) ⇒ Object

A username and password.



65
66
67
# File 'lib/acemq/amqp/credentials.rb', line 65

def self.of(username:, password:)
  new(username: username, password: password)
end

.resolve(source) ⇒ Object

Whatever a caller supplied, as AceMQ::AMQP::Credentials or nil.

A block or anything else answering call is invoked here, at the moment the connection is made. That is the whole point of accepting one: a password rotated by a sidecar is only useful if something asks for it again rather than remembering what it read at start-up.

Parameters:

Raises:



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/acemq/amqp/credentials.rb', line 133

def self.resolve(source)
  case source
  when nil then nil
  when Credentials then source
  else
    resolved = source.respond_to?(:call) ? source.call : source
    unless resolved.is_a?(Credentials)
      raise ConfigurationError,
            "credentials must be an AceMQ::AMQP::Credentials, or something " \
            "answering #call that returns one, not #{describe(source)}"
    end

    resolved
  end
end

.token(token) ⇒ Object

A bearer token with no username, which is how OAuth 2 against RabbitMQ is presented: the token is the password and the username is ignored.



71
72
73
# File 'lib/acemq/amqp/credentials.rb', line 71

def self.token(token)
  new(password: token)
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?

Two credentials are the same when both halves match.



206
207
208
# File 'lib/acemq/amqp/credentials.rb', line 206

def ==(other)
  other.is_a?(Credentials) && other.username == username && other.secret == secret
end

#empty? ⇒ Boolean

Whether these carry nothing at all.

Returns:

  • (Boolean)


185
# File 'lib/acemq/amqp/credentials.rb', line 185

def empty? = username.empty? && secret.empty?

#hash ⇒ Object



211
# File 'lib/acemq/amqp/credentials.rb', line 211

def hash = [self.class, username, secret].hash

#inspect ⇒ Object

Never the secret. See the class comment for why this matters more than it looks like it should.



215
216
217
# File 'lib/acemq/amqp/credentials.rb', line 215

def inspect
  "#<AceMQ::AMQP::Credentials #{self}>"
end

#to_s ⇒ Object

Never the secret either. Both are overridden because interpolation uses one and p uses the other, and a leak needs only whichever was missed.



221
222
223
# File 'lib/acemq/amqp/credentials.rb', line 221

def to_s
  token? ? "token=#{REDACTED}" : "username=#{username.inspect} secret=#{REDACTED}"
end

#to_transport_options ⇒ Hash

The bunny options these describe.

The username is left out when there is none, rather than sent as an empty string: a token is authenticated by the token, and overwriting whatever the URL said with "" would take a working connection apart for no reason.

Returns:

  • (Hash)


198
199
200
201
202
203
# File 'lib/acemq/amqp/credentials.rb', line 198

def to_transport_options
  options = {}
  options[:username] = username unless username.empty?
  options[:password] = secret unless secret.empty?
  options
end

#token? ⇒ Boolean

Whether these are a token rather than an account.

Returns:

  • (Boolean)


188
# File 'lib/acemq/amqp/credentials.rb', line 188

def token? = username.empty? && !secret.empty?