otto 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- data/docs/reverse-proxy-network-services.md +0 -371
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubygems'
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
# Raised when an explicitly enabled feature cannot load its optional gem.
|
|
7
|
+
class OptionalDependencyError < ArgumentError; end
|
|
8
|
+
|
|
9
|
+
# Loads optional gems against the compatibility ranges Otto supports.
|
|
10
|
+
module OptionalDependency
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def require!(gem_name, requirement, require_path:, feature:, alternative: nil)
|
|
14
|
+
version_requirement = Gem::Requirement.new(requirement)
|
|
15
|
+
guidance = "Add `gem '#{gem_name}', '#{requirement}'` to your Gemfile."
|
|
16
|
+
guidance = "#{guidance} #{alternative}" if alternative
|
|
17
|
+
installed_specs = Gem::Specification.find_all_by_name(gem_name)
|
|
18
|
+
active_spec = Gem.loaded_specs[gem_name]
|
|
19
|
+
compatible_spec = if active_spec && version_requirement.satisfied_by?(active_spec.version)
|
|
20
|
+
active_spec
|
|
21
|
+
else
|
|
22
|
+
installed_specs
|
|
23
|
+
.select { |spec| version_requirement.satisfied_by?(spec.version) }
|
|
24
|
+
.max_by(&:version)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless compatible_spec
|
|
28
|
+
installed = installed_specs.map(&:version).sort.join(', ')
|
|
29
|
+
reason = installed.empty? ? 'it is not installed' : "installed version(s) #{installed} are incompatible"
|
|
30
|
+
raise OptionalDependencyError,
|
|
31
|
+
"#{feature} requires optional dependency '#{gem_name}' (#{version_requirement}), but #{reason}. #{guidance}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
load_error = begin
|
|
35
|
+
compatible_spec.activate
|
|
36
|
+
require require_path
|
|
37
|
+
nil
|
|
38
|
+
# Convert loader failures into a feature-specific configuration error.
|
|
39
|
+
# Gem::LoadError (activation conflicts) is a LoadError subclass, so one
|
|
40
|
+
# rescue covers both activation and require failures.
|
|
41
|
+
rescue LoadError => e
|
|
42
|
+
e
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
raise_load_error!(feature, gem_name, version_requirement, guidance, load_error) if load_error
|
|
46
|
+
|
|
47
|
+
compatible_spec
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def raise_load_error!(feature, gem_name, requirement, guidance, error)
|
|
51
|
+
raise OptionalDependencyError,
|
|
52
|
+
"#{feature} requires optional dependency '#{gem_name}' (#{requirement}), but it could not be " \
|
|
53
|
+
"loaded: #{error.message}. #{guidance}",
|
|
54
|
+
cause: error
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/otto/privacy/config.rb
CHANGED
|
@@ -9,6 +9,7 @@ require 'digest'
|
|
|
9
9
|
require 'concurrent'
|
|
10
10
|
|
|
11
11
|
require_relative '../core/freezable'
|
|
12
|
+
require_relative '../optional_dependency'
|
|
12
13
|
|
|
13
14
|
class Otto
|
|
14
15
|
module Privacy
|
|
@@ -31,6 +32,8 @@ class Otto
|
|
|
31
32
|
class Config
|
|
32
33
|
include Otto::Core::Freezable
|
|
33
34
|
|
|
35
|
+
MAXMIND_DB_REQUIREMENT = '~> 1.2'
|
|
36
|
+
|
|
34
37
|
# Named privacy profiles: validated presets over the individual knobs,
|
|
35
38
|
# so a deployment's observability posture is declared in one reviewable
|
|
36
39
|
# word instead of inferred from knob combinations.
|
|
@@ -540,14 +543,13 @@ class Otto
|
|
|
540
543
|
def build_maxmind_reader(path, option_name: 'geo_db_path')
|
|
541
544
|
raise ArgumentError, "#{option_name} is not readable: #{path.inspect}" unless File.readable?(path)
|
|
542
545
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
end
|
|
546
|
+
Otto::OptionalDependency.require!(
|
|
547
|
+
'maxmind-db',
|
|
548
|
+
MAXMIND_DB_REQUIREMENT,
|
|
549
|
+
require_path: 'maxmind/db',
|
|
550
|
+
feature: "#{option_name} database loading",
|
|
551
|
+
alternative: "Or inject a reader with `#{option_name.sub('_path', '_reader')}:`."
|
|
552
|
+
)
|
|
551
553
|
|
|
552
554
|
begin
|
|
553
555
|
MaxMind::DB.new(path, mode: MaxMind::DB::MODE_MEMORY)
|
|
@@ -73,20 +73,37 @@ class Otto
|
|
|
73
73
|
#
|
|
74
74
|
# Supports multiple role sources in order of precedence:
|
|
75
75
|
# 1. result.user_roles (Array)
|
|
76
|
-
# 2. result.user[:roles] (
|
|
77
|
-
# 3. result.user
|
|
76
|
+
# 2. result.user[:roles] / result.user['roles'] (Hash user)
|
|
77
|
+
# 3. result.user.roles, then result.user.role (object-backed user:
|
|
78
|
+
# ORM model, PORO, Data/Struct); #roles must return role names
|
|
78
79
|
# 4. result.metadata[:user_roles] (Array)
|
|
79
80
|
#
|
|
81
|
+
# A user object that is neither a Hash nor responds to `#roles`/`#role`
|
|
82
|
+
# contributes no roles rather than raising, so authorization yields a
|
|
83
|
+
# deny result instead of a NoMethodError.
|
|
84
|
+
#
|
|
80
85
|
# @param result [StrategyResult] Authentication result
|
|
81
86
|
# @return [Array<String>] Array of role strings
|
|
82
87
|
def extract_roles(result)
|
|
83
88
|
# Try direct user_roles accessor (e.g., from RoleStrategy)
|
|
84
89
|
return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
|
|
85
90
|
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
# Hash access first (unchanged behaviour), then object-backed users
|
|
92
|
+
user = result.user
|
|
93
|
+
if user.is_a?(Hash)
|
|
94
|
+
roles = user[:roles] || user['roles']
|
|
89
95
|
return Array(roles) if roles
|
|
96
|
+
else
|
|
97
|
+
# Same fallback as StrategyResult#roles: an empty or nil #roles
|
|
98
|
+
# falls through to a singular #role.
|
|
99
|
+
if user.respond_to?(:roles)
|
|
100
|
+
roles = Array(user.roles).map(&:to_s)
|
|
101
|
+
return roles unless roles.empty?
|
|
102
|
+
end
|
|
103
|
+
if user.respond_to?(:role)
|
|
104
|
+
role = user.role
|
|
105
|
+
return [role.to_s] if role
|
|
106
|
+
end
|
|
90
107
|
end
|
|
91
108
|
|
|
92
109
|
# Try metadata
|
|
@@ -3,50 +3,213 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative '../auth_strategy'
|
|
6
|
+
require 'digest'
|
|
6
7
|
require 'rack/utils'
|
|
7
8
|
|
|
8
9
|
class Otto
|
|
9
10
|
module Security
|
|
10
11
|
module Authentication
|
|
11
12
|
module Strategies
|
|
12
|
-
# API key authentication strategy
|
|
13
|
+
# API key authentication strategy.
|
|
14
|
+
#
|
|
15
|
+
# Accepts exactly one key source: a static list (`api_keys:`), a
|
|
16
|
+
# callable (`resolver:`), or a block. A resolver receives the presented
|
|
17
|
+
# key (a non-empty String, never env) and returns the account behind it,
|
|
18
|
+
# or nil/false when the key is unknown. Any other return value, including
|
|
19
|
+
# an empty container, counts as a match: an ORM relation from `where`,
|
|
20
|
+
# an empty Array from `select`, or `{}` from a cache miss is truthy and
|
|
21
|
+
# authenticates the caller. Return exactly one record (`find_by`,
|
|
22
|
+
# `first`) or nil. The strategy never branches on mode: a static list is
|
|
23
|
+
# wrapped in a resolver internally.
|
|
24
|
+
#
|
|
25
|
+
# Fails closed: a strategy with no source, or a static list with no
|
|
26
|
+
# non-empty key, is a misconfiguration and raises ArgumentError at
|
|
27
|
+
# construction rather than authenticating every caller. Only a truthy
|
|
28
|
+
# resolver return (for a static list: a constant-time match) grants
|
|
29
|
+
# success.
|
|
30
|
+
#
|
|
31
|
+
# A credential that was presented and rejected, including a non-String
|
|
32
|
+
# credential such as an array query parameter, fails TERMINALLY, so a bad
|
|
33
|
+
# key halts the strategy chain instead of falling through to a later
|
|
34
|
+
# anonymous-capable strategy. A missing credential fails non-terminally.
|
|
35
|
+
# Blank and non-String credentials are rejected before the resolver runs.
|
|
36
|
+
#
|
|
37
|
+
# Exceptions raised by a resolver propagate. A database outage must
|
|
38
|
+
# surface as an error, not as a silent 401 and never as success.
|
|
39
|
+
#
|
|
40
|
+
# Timing: the static list is compared in constant time against every
|
|
41
|
+
# configured key. Both sides are reduced to fixed-width SHA-256 digests
|
|
42
|
+
# first, so the comparison never short-circuits on a length mismatch and
|
|
43
|
+
# the lengths of configured keys are not observable. A black-box lookup
|
|
44
|
+
# cannot be made constant-time by the strategy; the documented pattern
|
|
45
|
+
# is to store SHA-256 digests and look up by {APIKeyStrategy.digest},
|
|
46
|
+
# which is constant-time by construction and keeps raw keys out of the
|
|
47
|
+
# database.
|
|
48
|
+
#
|
|
49
|
+
# The strategy never places the raw key in the result itself: the only
|
|
50
|
+
# strategy-generated field derived from the key is a short SHA-256
|
|
51
|
+
# fingerprint. With a resolver, `user` is whatever the resolver returns,
|
|
52
|
+
# verbatim; the result is stored in env['otto.strategy_result'] and
|
|
53
|
+
# exposed to handlers, so anything the application serializes or logs
|
|
54
|
+
# from it carries `user`. It is the resolver's responsibility not to
|
|
55
|
+
# return an object that holds the raw key: return the account, not the
|
|
56
|
+
# ApiKey row that stores the key, and store digests. A resolver that
|
|
57
|
+
# returns the presented key String itself as the user raises
|
|
58
|
+
# ArgumentError.
|
|
59
|
+
#
|
|
60
|
+
# The query/form parameter path is opt-in (`param_name:`), because keys in
|
|
61
|
+
# URLs are recorded by access logs, proxies, and browser history.
|
|
62
|
+
#
|
|
63
|
+
# Scope: this is a small static-allowlist authenticator shipped as a
|
|
64
|
+
# low-dependency convenience and reference implementation. It has no
|
|
65
|
+
# native support for runtime addition or revocation, expiration, roles
|
|
66
|
+
# or scopes, key metadata, quotas, a management API, audit history, or
|
|
67
|
+
# hashed verifier storage. The resolver form delegates those concerns
|
|
68
|
+
# to the application's key store; see docs/guides/authentication.md.
|
|
69
|
+
#
|
|
70
|
+
# @example Static list, header only (recommended)
|
|
71
|
+
# APIKeyStrategy.new(api_keys: ['secret123'])
|
|
72
|
+
# @example Block resolver looking up a stored digest
|
|
73
|
+
# APIKeyStrategy.new do |presented_key|
|
|
74
|
+
# ApiKey.find_by(digest: APIKeyStrategy.digest(presented_key))&.account
|
|
75
|
+
# end
|
|
76
|
+
# @example Callable resolver (anything responding to #call)
|
|
77
|
+
# APIKeyStrategy.new(resolver: repo.method(:find_by_key))
|
|
78
|
+
# @example Also accept ?api_key= (logged in URLs; prefer the header)
|
|
79
|
+
# APIKeyStrategy.new(api_keys: ['secret123'], param_name: 'api_key')
|
|
13
80
|
class APIKeyStrategy < AuthStrategy
|
|
14
|
-
|
|
15
|
-
|
|
81
|
+
# Full SHA-256 hex digest of a key. Store this instead of the raw key
|
|
82
|
+
# and look up presented keys by their digest.
|
|
83
|
+
#
|
|
84
|
+
# @param key [String]
|
|
85
|
+
# @return [String] 64-char hex digest
|
|
86
|
+
def self.digest(key)
|
|
87
|
+
Digest::SHA256.hexdigest(key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param api_keys [String, Array<String>, nil] static list of valid API keys
|
|
91
|
+
# @param resolver [#call, nil] callable receiving the presented key and
|
|
92
|
+
# returning the account (truthy) or nil/false when unknown
|
|
93
|
+
# @param header_name [String] request header carrying the key
|
|
94
|
+
# @param param_name [String, nil] query/form parameter carrying the key.
|
|
95
|
+
# Defaults to nil (header only): keys placed in a URL are captured by
|
|
96
|
+
# access logs, proxies, and browser history. Pass 'api_key' to opt in.
|
|
97
|
+
# @yieldparam presented_key [String] the non-empty presented key
|
|
98
|
+
# @yieldreturn [Object, nil, false] the account behind the key, or
|
|
99
|
+
# nil/false when the key is unknown
|
|
100
|
+
# @raise [ArgumentError] if no source, or more than one, is given
|
|
101
|
+
# @raise [ArgumentError] if resolver: does not respond to #call
|
|
102
|
+
# @raise [ArgumentError] if api_keys: contains no non-empty API key
|
|
103
|
+
def initialize(api_keys: nil, resolver: nil, header_name: 'X-API-Key', param_name: nil, &block)
|
|
104
|
+
super()
|
|
105
|
+
@resolver = build_resolver(api_keys, resolver, block)
|
|
16
106
|
@header_name = header_name
|
|
17
107
|
@param_name = param_name
|
|
18
108
|
end
|
|
19
109
|
|
|
20
110
|
def authenticate(env, _requirement)
|
|
21
|
-
#
|
|
111
|
+
# Header first; the parameter path is consulted only when opted in.
|
|
22
112
|
api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
|
|
23
113
|
|
|
24
|
-
if api_key.nil?
|
|
114
|
+
if api_key.nil? && @param_name
|
|
25
115
|
request = Otto::Request.new(env)
|
|
26
116
|
api_key = request.params[@param_name]
|
|
27
117
|
end
|
|
28
118
|
|
|
29
|
-
|
|
119
|
+
# '' is truthy in Ruby; treat it, and a whitespace-only value, as a
|
|
120
|
+
# missing credential. The static list already refuses blank keys, so
|
|
121
|
+
# the resolver must never be asked about one either.
|
|
122
|
+
return failure('No API key provided') if api_key.nil? || (api_key.is_a?(String) && api_key.strip.empty?)
|
|
123
|
+
|
|
124
|
+
# A non-String credential (e.g. `?api_key[]=k` yields an Array) was
|
|
125
|
+
# still presented, so reject it terminally rather than handing it to
|
|
126
|
+
# the resolver. Credentials were explicitly presented and rejected:
|
|
127
|
+
# fail closed.
|
|
128
|
+
return failure('Invalid API key', terminal: true) unless api_key.is_a?(String)
|
|
30
129
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
130
|
+
# Resolver exceptions propagate deliberately (see class docs).
|
|
131
|
+
user = @resolver.call(api_key)
|
|
132
|
+
return failure('Invalid API key', terminal: true) unless user
|
|
133
|
+
|
|
134
|
+
# The most likely naive misuse: `->(k) { k if keys.include?(k) }`.
|
|
135
|
+
# Fail loud before the key can reach the result and env.
|
|
136
|
+
if user.is_a?(String) && constant_time_equal?(user, api_key)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
'APIKeyStrategy resolver returned the presented key as the user; ' \
|
|
139
|
+
'return the account behind the key, not the key'
|
|
37
140
|
end
|
|
141
|
+
|
|
142
|
+
# Identify the credential by a non-reversible fingerprint. The strategy
|
|
143
|
+
# itself never places the raw key in the result; `user` is the
|
|
144
|
+
# resolver's return value, verbatim (see class docs).
|
|
145
|
+
success(user: user,
|
|
146
|
+
auth_method: 'api_key',
|
|
147
|
+
api_key_fingerprint: key_fingerprint(api_key))
|
|
38
148
|
end
|
|
39
149
|
|
|
40
150
|
private
|
|
41
151
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
152
|
+
def build_resolver(api_keys, resolver, block)
|
|
153
|
+
sources = [api_keys, resolver, block].count { |source| !source.nil? }
|
|
154
|
+
if sources > 1
|
|
155
|
+
raise ArgumentError,
|
|
156
|
+
'APIKeyStrategy: pass api_keys:, resolver:, or a block, not more than one'
|
|
157
|
+
end
|
|
158
|
+
# `api_keys: nil` and omitting every source are indistinguishable
|
|
159
|
+
# here, so one message covers both.
|
|
160
|
+
if sources.zero?
|
|
161
|
+
raise ArgumentError,
|
|
162
|
+
'APIKeyStrategy requires a key source: at least one non-empty API key ' \
|
|
163
|
+
'(api_keys:), a resolver:, or a block'
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return static_resolver(api_keys) unless api_keys.nil?
|
|
167
|
+
return block if block
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, 'APIKeyStrategy resolver: must respond to #call' unless resolver.respond_to?(:call)
|
|
170
|
+
|
|
171
|
+
resolver
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Wrap a static key list in a resolver performing a constant-time,
|
|
175
|
+
# non-short-circuiting membership check.
|
|
176
|
+
def static_resolver(api_keys)
|
|
177
|
+
# `to_s` returns the caller's String and the strategy only ever
|
|
178
|
+
# receives a shallow freeze from config finalization, so the digest
|
|
179
|
+
# is taken now: a caller mutating its key after boot cannot change
|
|
180
|
+
# which credential is accepted, and the raw keys are not retained.
|
|
181
|
+
# Keys are matched verbatim, but a whitespace-only value is a blank
|
|
182
|
+
# configuration (`API_KEYS=" "`), not a credential: reject it so the
|
|
183
|
+
# fail-closed startup guarantee covers it.
|
|
184
|
+
digests = Array(api_keys).map(&:to_s).reject { |key| key.strip.empty? }
|
|
185
|
+
.map { |key| self.class.digest(key).freeze }.freeze
|
|
186
|
+
if digests.empty?
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
'APIKeyStrategy requires at least one non-empty API key ' \
|
|
189
|
+
'(api_keys: was empty or contained only blank values)'
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
lambda do |presented|
|
|
193
|
+
presented_digest = self.class.digest(presented)
|
|
194
|
+
matched = digests.reduce(false) do |acc, digest|
|
|
195
|
+
Rack::Utils.secure_compare(digest, presented_digest) || acc
|
|
196
|
+
end
|
|
197
|
+
matched ? { api_key_fingerprint: presented_digest[0, 12] } : nil
|
|
48
198
|
end
|
|
49
199
|
end
|
|
200
|
+
|
|
201
|
+
# Constant-time equality that does not leak the length of either side:
|
|
202
|
+
# `Rack::Utils.secure_compare` returns immediately on a length mismatch,
|
|
203
|
+
# so compare fixed-width digests instead of the raw values.
|
|
204
|
+
def constant_time_equal?(left, right)
|
|
205
|
+
Rack::Utils.secure_compare(self.class.digest(left), self.class.digest(right))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Short, non-reversible identifier for a key: enough to correlate
|
|
209
|
+
# requests and audit logs without exposing the credential.
|
|
210
|
+
def key_fingerprint(api_key)
|
|
211
|
+
self.class.digest(api_key)[0, 12]
|
|
212
|
+
end
|
|
50
213
|
end
|
|
51
214
|
end
|
|
52
215
|
end
|
|
@@ -11,6 +11,7 @@ class Otto
|
|
|
11
11
|
# Permission-based authentication strategy
|
|
12
12
|
class PermissionStrategy < AuthStrategy
|
|
13
13
|
def initialize(required_permissions, session_key: 'user_permissions')
|
|
14
|
+
super()
|
|
14
15
|
@required_permissions = Array(required_permissions)
|
|
15
16
|
@session_key = session_key
|
|
16
17
|
end
|
|
@@ -159,45 +159,34 @@ class Otto
|
|
|
159
159
|
|
|
160
160
|
# Check if the user has a specific role
|
|
161
161
|
#
|
|
162
|
+
# A user model that defines `#has_role?` is asked directly. Otherwise
|
|
163
|
+
# the answer is derived from {#roles}, so the predicate and the accessor
|
|
164
|
+
# always agree: `has_role?(r)` is `roles.include?(r.to_s)` for Hash,
|
|
165
|
+
# PORO, ORM, Set-backed, and single-`#role` users alike.
|
|
166
|
+
#
|
|
162
167
|
# @param role [String, Symbol] Role to check
|
|
163
168
|
# @return [Boolean] True if user has the role
|
|
164
169
|
def has_role?(role)
|
|
165
170
|
return false unless authenticated?
|
|
171
|
+
return user.has_role?(role) if user.respond_to?(:has_role?)
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
if user.respond_to?(:role)
|
|
169
|
-
user.role.to_s == role.to_s
|
|
170
|
-
elsif user.respond_to?(:has_role?)
|
|
171
|
-
user.has_role?(role)
|
|
172
|
-
elsif user.is_a?(Hash)
|
|
173
|
-
user_role = user[:role] || user['role']
|
|
174
|
-
user_role.to_s == role.to_s
|
|
175
|
-
else
|
|
176
|
-
false
|
|
177
|
-
end
|
|
173
|
+
roles.include?(role.to_s)
|
|
178
174
|
end
|
|
179
175
|
|
|
180
176
|
# Check if the user has a specific permission
|
|
181
177
|
#
|
|
178
|
+
# A user model that defines `#has_permission?` is asked directly.
|
|
179
|
+
# Otherwise the answer is derived from {#permissions}, so the predicate
|
|
180
|
+
# and the accessor always agree, including for Set-backed and other
|
|
181
|
+
# non-Array Enumerable collections.
|
|
182
|
+
#
|
|
182
183
|
# @param permission [String, Symbol] Permission to check
|
|
183
184
|
# @return [Boolean] True if user has the permission
|
|
184
185
|
def has_permission?(permission)
|
|
185
186
|
return false unless authenticated?
|
|
187
|
+
return user.has_permission?(permission) if user.respond_to?(:has_permission?)
|
|
186
188
|
|
|
187
|
-
|
|
188
|
-
if user.respond_to?(:has_permission?)
|
|
189
|
-
user.has_permission?(permission)
|
|
190
|
-
elsif user.respond_to?(:permissions)
|
|
191
|
-
permissions = user.permissions || []
|
|
192
|
-
permissions = [permissions] unless permissions.is_a?(Array)
|
|
193
|
-
permissions.map(&:to_s).include?(permission.to_s)
|
|
194
|
-
elsif user.is_a?(Hash)
|
|
195
|
-
permissions = user[:permissions] || user['permissions'] || []
|
|
196
|
-
permissions = [permissions] unless permissions.is_a?(Array)
|
|
197
|
-
permissions.map(&:to_s).include?(permission.to_s)
|
|
198
|
-
else
|
|
199
|
-
false
|
|
200
|
-
end
|
|
189
|
+
permissions.include?(permission.to_s)
|
|
201
190
|
end
|
|
202
191
|
|
|
203
192
|
# Check if the user has any of the specified roles
|
|
@@ -257,10 +246,31 @@ class Otto
|
|
|
257
246
|
|
|
258
247
|
# Get all user roles as an array
|
|
259
248
|
#
|
|
249
|
+
# Supports object-backed users (ORM models, POROs, Data/Struct) via
|
|
250
|
+
# `#roles` / `#role`, and Hash users via `:roles`/`'roles'` then
|
|
251
|
+
# `:role`/`'role'`. Never calls `#[]` on a non-Hash user, so a model
|
|
252
|
+
# without role support yields `[]` instead of raising. `#roles` on an
|
|
253
|
+
# object must return role names (Strings or Symbols, in any Enumerable);
|
|
254
|
+
# an association of role records is stringified as-is and matches
|
|
255
|
+
# nothing, which denies rather than grants.
|
|
256
|
+
#
|
|
260
257
|
# @return [Array<String>] Array of roles (empty if none)
|
|
261
258
|
def roles
|
|
262
259
|
return [] unless authenticated?
|
|
263
260
|
|
|
261
|
+
# Try user model methods first, fall back to hash access for backward compatibility
|
|
262
|
+
if user.respond_to?(:roles)
|
|
263
|
+
normalized = normalize_list(user.roles)
|
|
264
|
+
return normalized unless normalized.empty?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if user.respond_to?(:role)
|
|
268
|
+
role = user.role
|
|
269
|
+
return [role.to_s] if role
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
return [] unless user.is_a?(Hash)
|
|
273
|
+
|
|
264
274
|
roles_data = user[:roles] || user['roles']
|
|
265
275
|
if roles_data.is_a?(Array)
|
|
266
276
|
roles_data.map(&:to_s)
|
|
@@ -274,13 +284,21 @@ class Otto
|
|
|
274
284
|
|
|
275
285
|
# Get all user permissions as an array
|
|
276
286
|
#
|
|
287
|
+
# Supports object-backed users via `#permissions` and Hash users via
|
|
288
|
+
# `:permissions`/`'permissions'`. Never calls `#[]` on a non-Hash user.
|
|
289
|
+
#
|
|
277
290
|
# @return [Array<String>] Array of permissions (empty if none)
|
|
278
291
|
def permissions
|
|
279
292
|
return [] unless authenticated?
|
|
280
293
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
294
|
+
# Try user model methods first, fall back to hash access for backward compatibility
|
|
295
|
+
if user.respond_to?(:permissions)
|
|
296
|
+
normalize_list(user.permissions)
|
|
297
|
+
elsif user.is_a?(Hash)
|
|
298
|
+
normalize_list(user[:permissions] || user['permissions'])
|
|
299
|
+
else
|
|
300
|
+
[]
|
|
301
|
+
end
|
|
284
302
|
end
|
|
285
303
|
|
|
286
304
|
# Create a string representation for debugging
|
|
@@ -332,6 +350,18 @@ class Otto
|
|
|
332
350
|
permissions: permissions,
|
|
333
351
|
}
|
|
334
352
|
end
|
|
353
|
+
|
|
354
|
+
private
|
|
355
|
+
|
|
356
|
+
# Coerce a roles/permissions value into an Array of Strings. Enumerables
|
|
357
|
+
# (Array, Set, an ORM relation) expand to their elements; a scalar
|
|
358
|
+
# becomes a one-element list; nil becomes [].
|
|
359
|
+
#
|
|
360
|
+
# @param value [Array, Enumerable, Object, nil]
|
|
361
|
+
# @return [Array<String>]
|
|
362
|
+
def normalize_list(value)
|
|
363
|
+
Array(value).map(&:to_s)
|
|
364
|
+
end
|
|
335
365
|
end
|
|
336
366
|
end
|
|
337
367
|
end
|