Class: ApiKeys::Restrictions

Inherits:
Object
  • Object
show all
Defined in:
lib/api_keys/restrictions.rb

Overview

Value object describing where an API key may be used from: a list of web origins (hosts, with optional *. subdomain wildcards) and a list of IP addresses or CIDR ranges.

Restrictions are plain data stored in the restrictions JSON column:

{ "origins" => ["example.com", "*.example.com"],
"ips"     => ["203.0.113.7", "10.0.0.0/8", "2001:db8::/32"] }

Matching semantics (normative):

  • Within a list: OR. Any entry that matches admits the request.
  • Across lists: AND. Every list that is present and non-empty must pass.
  • Empty (or absent) restrictions mean unrestricted. Presence is the toggle.
  • Every failure mode fails closed: a locked list plus an unreadable request context refuses the request.

The object is immutable and has no Active Record dependency. Malformed persisted values are represented explicitly and deny authentication; model validations keep them out during ordinary writes.

Constant Summary collapse

KINDS =

The restriction kinds this gem understands. Anything else stored in the column is a validation error rather than a silently ignored key.

%i[origins ips].freeze
KIND_NAMES =
KINDS.map(&:to_s).freeze
ENTRY_SEPARATOR =

Entries are split on commas, whitespace, and newlines so that a single text field can hold a whole list ("example.com, *.example.com").

/[\s,;]+/
DNS_LABEL =

A bare host, optionally prefixed with a *. subdomain wildcard. * alone is deliberately invalid: an empty list already means "anywhere".

/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/
ORIGIN_ENTRY_PATTERN =
/\A(?:\*\.)?#{DNS_LABEL}(?:\.#{DNS_LABEL})*\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(origins: [], ips: [], extras: {}, malformed: false) ⇒ Restrictions

Returns a new instance of Restrictions.

Parameters:

  • origins (Array<String>) (defaults to: [])

    Already-coerced origin entries.

  • ips (Array<String>) (defaults to: [])

    Already-coerced IP entries.

  • extras (Hash) (defaults to: {})

    Unrecognized keys, preserved so validation sees them.

  • malformed (Boolean) (defaults to: false)

    Whether coercion itself found an invalid shape.



253
254
255
256
257
258
259
260
261
# File 'lib/api_keys/restrictions.rb', line 253

def initialize(origins: [], ips: [], extras: {}, malformed: false)
  @origins = deep_copy(origins, freeze_copy: true)
  @ips = deep_copy(ips, freeze_copy: true)
  @extras = deep_copy(extras, freeze_copy: true)
  @malformed = malformed || @extras.any? ||
               @origins.any? { |entry| !self.class.valid_origin_entry?(entry) } ||
               @ips.any? { |entry| !self.class.valid_ip_entry?(entry) }
  freeze
end

Instance Attribute Details

#extrasObject (readonly)

Returns the value of attribute extras.



42
43
44
# File 'lib/api_keys/restrictions.rb', line 42

def extras
  @extras
end

#ipsObject (readonly)

Returns the value of attribute ips.



42
43
44
# File 'lib/api_keys/restrictions.rb', line 42

def ips
  @ips
end

#originsObject (readonly)

Returns the value of attribute origins.



42
43
44
# File 'lib/api_keys/restrictions.rb', line 42

def origins
  @origins
end

Class Method Details

.coerce_list(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.

Coerces one stored list into an array of entries, preserving anything that is not a string so validations can report it instead of the value disappearing silently.



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/api_keys/restrictions.rb', line 197

def coerce_list(value)
  entries = case value
            when nil then []
            when String then value.split(ENTRY_SEPARATOR)
            when Array then value
            else [value]
            end

  entries.filter_map do |entry|
    next entry unless entry.is_a?(String)

    trimmed = entry.strip.downcase
    trimmed unless trimmed.empty?
  rescue ArgumentError
    entry
  end
end

.extract_origin_host(request) ⇒ String?

Extracts the host the browser claims the request came from: the Origin header when present, the Referer header otherwise. Returns nil when neither is present or parseable, which callers must treat as a refusal.

Parameters:

  • request (ActionDispatch::Request, #headers, nil)

Returns:

  • (String, nil)

    Bare lowercase host.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/api_keys/restrictions.rb', line 111

def extract_origin_host(request)
  headers = request.headers if request.respond_to?(:headers)
  return nil unless headers.respond_to?(:[])

  origin = headers["Origin"]
  unless origin.nil? || (origin.is_a?(String) && origin.strip.empty?)
    # Origin has precedence over Referer. A present-but-invalid Origin
    # (including the browser's opaque `null` origin) must not be rescued
    # by a friendlier Referer value.
    return host_from_url(origin)
  end

  host_from_url(headers["Referer"])
rescue StandardError
  # A hostile or exotic request object must never take an endpoint down;
  # an unreadable origin is simply an origin that matches nothing.
  nil
end

.host_from_url(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.

Pulls the host out of a full URL, tolerating garbage.



179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/api_keys/restrictions.rb', line 179

def host_from_url(value)
  return nil unless value.is_a?(String)

  trimmed = value.strip
  return nil if trimmed.empty?

  host = URI.parse(trimmed).host
  return nil if host.nil? || host.empty?

  host.delete_prefix("[").delete_suffix("]").downcase
rescue URI::Error, ArgumentError
  nil
end

.noneApiKeys::Restrictions

The shared empty instance: no origins, no IPs, no restrictions at all.



72
73
74
# File 'lib/api_keys/restrictions.rb', line 72

def none
  @none ||= new(origins: [], ips: [], extras: {}).freeze
end

.normalize_ips(value) ⇒ Array<String>

Forgiving parser for IP/CIDR input. String entries are kept verbatim (lowercased) so validation, not the parser, reports malformed ranges. Non-string entries are likewise preserved for validation.

Parameters:

  • value (String, Array, nil)

    Raw user input.

Returns:

  • (Array<String>)

    Normalized IP entries.



101
102
103
# File 'lib/api_keys/restrictions.rb', line 101

def normalize_ips(value)
  tokenize(value).map { |entry| entry.is_a?(String) ? entry.downcase : entry }.uniq
end

.normalize_origins(value) ⇒ Array

Forgiving parser for the raw string a dashboard text field submits. Accepts full URLs, bare hosts, commas, newlines, and stray whitespace; returns bare lowercase hosts, de-duplicated, order preserved.

normalize_origins("https://Shop.example/, *.app.example\n x")
# => ["shop.example", "*.app.example", "x"]

Non-string entries are preserved so validation can report malformed programmatic input instead of silently erasing a requested policy.

Parameters:

  • value (String, Array, nil)

    Raw user input.

Returns:

  • (Array)

    Normalized origin entries.



87
88
89
90
91
92
93
# File 'lib/api_keys/restrictions.rb', line 87

def normalize_origins(value)
  tokenize(value).map do |token|
    next token unless token.is_a?(String)

    origin_host(token) || token.strip.downcase
  end.uniq
end

.origin_host(entry) ⇒ 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.

Reduces a single user-supplied entry to a bare lowercase host. Full URLs give up their host; bare hosts keep everything before the first slash, colon, or question mark. Returns nil when nothing is left.



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/api_keys/restrictions.rb', line 152

def origin_host(entry)
  candidate = entry.to_s.strip
  return nil if candidate.empty?

  if candidate.include?("//")
    host = host_from_url(candidate)
    return host
  end

  if (address = parse_ip(candidate)) && !candidate.include?("/")
    return address.to_s.downcase
  end

  if candidate.start_with?("[")
    host = host_from_url("http://#{candidate}")
    return host if host
  end

  host = candidate.split(%r{[/?#]}).first.to_s
  host = host.sub(/:\d*\z/, "") # Strip a trailing port ("example.com:3000").
  host = host.delete_prefix("[").delete_suffix("]") # IPv6 literals.
  host = host.downcase
  host.empty? ? nil : host
end

.parse_ip(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.

Parses an address or range with stdlib IPAddr. A bare address is a /32 (or /128), so IPAddr#include? answers exact matches and range matches through a single code path.



236
237
238
239
240
241
242
243
244
245
246
# File 'lib/api_keys/restrictions.rb', line 236

def parse_ip(value)
  return nil unless value.is_a?(String)

  trimmed = value.strip
  return nil if trimmed.empty?

  address = IPAddr.new(trimmed)
  address.ipv6? && address.ipv4_mapped? ? address.native : address
rescue IPAddr::Error
  nil
end

.tokenize(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.

Splits raw input into candidate entries without interpreting them.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/api_keys/restrictions.rb', line 132

def tokenize(value)
  entries = case value
            when nil then []
            when String then value.split(ENTRY_SEPARATOR)
            when Array then value.flat_map { |entry| entry.is_a?(String) ? entry.split(ENTRY_SEPARATOR) : [entry] }
            else [value]
            end

  entries.filter_map do |entry|
    next entry unless entry.is_a?(String)

    trimmed = entry.strip
    trimmed unless trimmed.empty?
  end
end

.valid_ip_entry?(entry) ⇒ Boolean

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.

Whether a stored IP entry is a single address or a CIDR range.

Returns:

  • (Boolean)


228
229
230
# File 'lib/api_keys/restrictions.rb', line 228

def valid_ip_entry?(entry)
  parse_ip(entry) ? true : false
end

.valid_origin_entry?(entry) ⇒ Boolean

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.

Whether a stored origin entry is shaped like a host or *.host.

Returns:

  • (Boolean)


217
218
219
220
221
222
223
224
# File 'lib/api_keys/restrictions.rb', line 217

def valid_origin_entry?(entry)
  return false unless entry.is_a?(String)
  return true if !entry.include?("/") && parse_ip(entry)

  entry.bytesize <= 253 && entry.match?(ORIGIN_ENTRY_PATTERN)
rescue ArgumentError
  false
end

.wrap(value) ⇒ ApiKeys::Restrictions

Coerces anything into a Restrictions instance. Never raises.

Parameters:

  • value (Restrictions, Hash, nil, Object)

    The stored column value, a hash of lists, or an existing instance.

Returns:



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/api_keys/restrictions.rb', line 50

def wrap(value)
  return value if value.is_a?(self)
  return none if value.nil?
  return new(origins: [], ips: [], extras: {}, malformed: true) unless value.is_a?(Hash)

  known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) }
  known = known.to_h { |key, entries| [key.to_s, entries] }

  new(
    origins: coerce_list(known["origins"]),
    ips: coerce_list(known["ips"]),
    extras: extras.to_h
  )
rescue StandardError
  # Stored policy is untrusted input. Preserve the core invariant even if
  # an exotic object raises while being coerced: malformed never means
  # unrestricted.
  new(origins: [], ips: [], extras: {}, malformed: true)
end

Instance Method Details

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



332
333
334
# File 'lib/api_keys/restrictions.rb', line 332

def ==(other)
  other.is_a?(self.class) && other.to_h == to_h
end

#allows?(origin_host: nil, ip: nil) ⇒ Boolean

Does this request context satisfy every locked list?

Parameters:

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

    Host from Origin/Referer.

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

    Client IP address.

Returns:

  • (Boolean)


301
302
303
# File 'lib/api_keys/restrictions.rb', line 301

def allows?(origin_host: nil, ip: nil)
  !malformed? && origin_allowed?(origin_host) && ip_allowed?(ip)
end

#hashObject



337
338
339
# File 'lib/api_keys/restrictions.rb', line 337

def hash
  to_h.hash
end

#inspectObject



341
342
343
# File 'lib/api_keys/restrictions.rb', line 341

def inspect
  "#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect} malformed=#{malformed?.inspect}>"
end

#ip_allowed?(ip) ⇒ Boolean

Returns true when the IP list is empty or one entry contains it. A locked list plus an unparseable address refuses: fail closed.

Parameters:

  • ip (String, nil)

    Client IP address.

Returns:

  • (Boolean)

    true when the IP list is empty or one entry contains it. A locked list plus an unparseable address refuses: fail closed.



322
323
324
325
326
327
328
329
330
# File 'lib/api_keys/restrictions.rb', line 322

def ip_allowed?(ip)
  return false if malformed?
  return true if ips.empty?

  address = self.class.parse_ip(ip.is_a?(String) ? ip : ip.to_s)
  return false unless address

  ips.any? { |entry| ip_entry_matches?(entry, address) }
end

#kindsArray<Symbol>

Returns The restriction kinds actually in use.

Returns:

  • (Array<Symbol>)

    The restriction kinds actually in use.



280
281
282
# File 'lib/api_keys/restrictions.rb', line 280

def kinds
  KINDS.select { |kind| public_send(kind).any? }
end

#malformed?Boolean

Malformed data can only arrive through validation-bypassing writes or a damaged database. Authentication always denies it.

Returns:

  • (Boolean)


265
266
267
# File 'lib/api_keys/restrictions.rb', line 265

def malformed?
  @malformed
end

#origin_allowed?(host) ⇒ Boolean

Returns true when the origins list is empty or one entry matches. A locked list plus a nil/blank host refuses: fail closed.

Parameters:

  • host (String, nil)

    Bare host to check.

Returns:

  • (Boolean)

    true when the origins list is empty or one entry matches. A locked list plus a nil/blank host refuses: fail closed.



308
309
310
311
312
313
314
315
316
317
# File 'lib/api_keys/restrictions.rb', line 308

def origin_allowed?(host)
  return false if malformed?
  return true if origins.empty?

  candidate = host.to_s.strip.downcase
  return false if candidate.empty?
  candidate = self.class.parse_ip(candidate)&.to_s || candidate

  origins.any? { |entry| origin_entry_matches?(entry, candidate) }
end

#restricted?Boolean

Returns true when at least one list is locked.

Returns:

  • (Boolean)

    true when at least one list is locked.



275
276
277
# File 'lib/api_keys/restrictions.rb', line 275

def restricted?
  !unrestricted?
end

#to_hHash Also known as: as_json

The storage shape: known lists that have entries, plus any unrecognized keys exactly as they were found.

Returns:

  • (Hash)


287
288
289
290
291
292
# File 'lib/api_keys/restrictions.rb', line 287

def to_h
  hash = {}
  hash["origins"] = deep_copy(origins) if origins.any?
  hash["ips"] = deep_copy(ips) if ips.any?
  hash.merge(deep_copy(extras))
end

#unrestricted?Boolean

Returns true when this key may be used from anywhere.

Returns:

  • (Boolean)

    true when this key may be used from anywhere.



270
271
272
# File 'lib/api_keys/restrictions.rb', line 270

def unrestricted?
  !malformed? && origins.empty? && ips.empty?
end