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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. 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
@@ -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
- begin
544
- require 'maxmind/db'
545
- rescue LoadError
546
- raise ArgumentError,
547
- "#{option_name} is set (#{path.inspect}) but the 'maxmind-db' gem is not available. " \
548
- "Add `gem 'maxmind-db'` to your Gemfile, or inject your own reader via " \
549
- "configure_ip_privacy(#{option_name.sub('_path', '_reader')}: ...)."
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] (Array)
77
- # 3. result.user['roles'] (Array)
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
- # Try user hash/object with roles
87
- if result.user
88
- roles = result.user[:roles] || result.user['roles']
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
- def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
15
- @api_keys = Array(api_keys)
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
- # Try header first, then query parameter
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
- return failure('No API key provided') unless api_key
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
- if @api_keys.empty? || valid_api_key?(api_key)
32
- # Create a simple user hash for API key authentication
33
- user_data = { api_key: api_key }
34
- success(user: user_data, api_key: api_key)
35
- else
36
- failure('Invalid API key')
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
- # Constant-time membership check over the configured API keys. Compares
43
- # against every key without short-circuiting so match position/membership is
44
- # not leaked via timing.
45
- def valid_api_key?(api_key)
46
- @api_keys.reduce(false) do |matched, key|
47
- Rack::Utils.secure_compare(key, api_key) || matched
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
@@ -11,6 +11,7 @@ class Otto
11
11
  # Role-based authentication strategy
12
12
  class RoleStrategy < AuthStrategy
13
13
  def initialize(allowed_roles, session_key: 'user_roles')
14
+ super()
14
15
  @allowed_roles = Array(allowed_roles)
15
16
  @session_key = session_key
16
17
  end
@@ -11,6 +11,7 @@ class Otto
11
11
  # Session-based authentication strategy
12
12
  class SessionStrategy < AuthStrategy
13
13
  def initialize(session_key: 'user_id', session_store: nil)
14
+ super()
14
15
  @session_key = session_key
15
16
  @session_store = session_store
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
- # Try user model methods first, fall back to hash access for backward compatibility
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
- # Try user model methods first, fall back to hash access for backward compatibility
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
- perms = user[:permissions] || user['permissions'] || []
282
- perms = [perms] unless perms.is_a?(Array)
283
- perms.map(&:to_s)
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