Class: RackJwtAegis::RbacManager

Inherits:
Object
  • Object
show all
Includes:
DebugLogger
Defined in:
lib/rack_jwt_aegis/rbac_manager.rb

Overview

Role-Based Access Control (RBAC) manager

Handles authorization by checking user permissions against cached RBAC data. Supports both simple boolean permissions and complex permission structures. Uses a two-tier caching system for performance optimization.

Examples:

Basic usage

config = Configuration.new(jwt_secret: 'secret', rbac_enabled: true, rbac_cache_store: :memory)
manager = RbacManager.new(config)
manager.authorize(request, jwt_payload)

Author:

  • Ken Camajalan Demanawa

Since:

  • 0.1.0

Instance Method Summary collapse

Methods included from DebugLogger

#debug_log

Constructor Details

#initialize(config) ⇒ RbacManager

Initialize the RBAC manager

Parameters:

Since:

  • 0.1.0



23
24
25
26
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 23

def initialize(config)
  @config = config
  setup_cache_adapters
end

Instance Method Details

#authorize(request, payload) ⇒ Object

Authorize a request against RBAC permissions

Parameters:

  • request (Rack::Request)

    the incoming request

  • payload (Hash)

    the JWT payload containing user information

Raises:

Since:

  • 0.1.0



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 33

def authorize(request, payload)
  user_id = payload[@config.payload_key(:user_id).to_s]
  raise AuthorizationError, 'User ID missing from JWT payload' if user_id.nil?

  # Build permission key
  permission_key = build_permission_key(user_id, request)
  return if check_cached_permission(permission_key) == true

  # Permission not cached or cache miss - check RBAC store
  has_permission = check_rbac_permission(user_id, request)
  # Cache the result if middleware has write access
  cache_permission_result(permission_key, has_permission)
  return if has_permission

  raise AuthorizationError, 'Access denied - insufficient permissions'
end

#build_permission_key(user_id, request) ⇒ Object (private)

Since:

  • 0.1.0



64
65
66
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 64

def build_permission_key(user_id, request)
  "#{user_id}:#{request.host}#{request.path}:#{request.request_method.downcase}"
end

#cache_matched_permission(user_id, request) ⇒ Object (private)

Cache the specific permission match for faster future lookups Format: => timestamp

Since:

  • 0.1.0



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 270

def cache_matched_permission(user_id, request)
  return unless @permissions_cache

  begin
    current_time = Time.now.to_i
    permission_key = "#{user_id}:#{request.host}#{request.path}:#{request.request_method.downcase}"
    # Get existing user permissions cache or create new one
    user_permissions = @permissions_cache.read('user_permissions') || {}
    # Store permission with new format
    user_permissions[permission_key] = current_time
    # Write back to cache
    @permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
    debug_log("Cached user permission: #{permission_key} => #{current_time}")
  rescue CacheError => e
    # Log cache error but don't fail the request
    debug_log("RbacManager permission cache write error: #{e.message}", :warn)
  end
end

#cache_permission_result(permission_key, has_permission) ⇒ Object (private)

Since:

  • 0.1.0



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 129

def cache_permission_result(permission_key, has_permission)
  return unless @permissions_cache
  return unless has_permission # Only cache positive permissions

  begin
    current_time = Time.now.to_i

    # Get existing user permissions cache or create new one
    user_permissions = @permissions_cache.read('user_permissions') || {}

    # Store permission with new format: {"user_id:full_url:method" => timestamp}
    user_permissions[permission_key] = current_time

    # Write back to cache
    @permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)

    debug_log("Cached permission: #{permission_key} => #{current_time}")
  rescue CacheError => e
    # Log cache error but don't fail the request
    debug_log("RbacManager permission cache write error: #{e.message}", :warn)
  end
end

#check_cached_permission(permission_key) ⇒ Object (private)

Since:

  • 0.1.0



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 68

def check_cached_permission(permission_key)
  return nil unless @permissions_cache

  begin
    # Get the cached user permissions
    user_permissions = @permissions_cache.read('user_permissions')
    return nil if user_permissions.nil? || !user_permissions.is_a?(Hash)

    # First check: If RBAC permissions were updated recently, nuke ALL cached permissions
    rbac_last_update = rbac_last_update_timestamp
    if rbac_last_update
      rbac_update_age = Time.now.to_i - rbac_last_update

      # If RBAC was updated within the TTL period, all cached permissions are invalid
      if rbac_update_age <= @config.cached_permissions_ttl
        nuke_user_permissions_cache("RBAC permissions updated recently (#{rbac_update_age}s ago, within TTL)")
        return nil
      end
    end

    # Check if permission exists in this format: {"user_id:full_url:method" => timestamp}
    cached_timestamp = user_permissions[permission_key]
    return nil unless cached_timestamp.is_a?(Integer)

    permission_age = Time.now.to_i - cached_timestamp

    # Second check: TTL expiration
    if permission_age > @config.cached_permissions_ttl
      # This specific permission expired due to TTL
      remove_stale_permission(permission_key,
                              "TTL expired (#{permission_age}s > #{@config.cached_permissions_ttl}s)")
      return nil
    end

    # Permission is fresh
    debug_log("Cache hit: #{permission_key} (permission age: \
              #{permission_age}s, RBAC age: #{rbac_update_age || 'unknown'}s)".squeeze)
    true
  rescue CacheError => e
    # Log cache error but don't fail the request
    debug_log("RbacManager cache read error: #{e.message}", :warn)
    nil
  end
end

#check_rbac_format?(user_id, request, rbac_data) ⇒ Boolean (private)

Returns:

  • (Boolean)

Since:

  • 0.1.0



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

def check_rbac_format?(user_id, request, rbac_data)
  # Extract user roles from JWT payload
  user_roles = extract_user_roles_from_request(request)
  if user_roles.nil? || user_roles.empty?
    debug_log('RbacManager: No user roles found in request context', :warn)
    return false
  end

  # Get permissions object for direct lookup
  permissions_data = rbac_data['permissions'] || rbac_data[:permissions]

  # Check permissions for each user role using direct lookup
  user_roles.each do |role_id|
    # Try both string and integer keys for role lookup
    role_permissions = permissions_data[role_id.to_s] || permissions_data[role_id.to_i]
    next unless role_permissions

    matched_permission = find_matching_permission(role_permissions, request)
    next unless matched_permission

    # Cache this specific permission match for faster future lookups
    cache_matched_permission(user_id, request)
    return true
  end

  false
end

#check_rbac_permission(user_id, request) ⇒ Object (private)

Since:

  • 0.1.0



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 113

def check_rbac_permission(user_id, request)
  rbac_data = @rbac_cache.read('permissions')

  # Check if RBAC data exists and is valid
  if rbac_data.is_a?(Hash) && validate_rbac_cache_format(rbac_data)
    return check_rbac_format?(user_id, request, rbac_data)
  end

  # No valid RBAC data found
  false
rescue CacheError => e
  # Cache error - fail secure (deny access)
  debug_log("RbacManager RBAC cache error: #{e.message}", :warn)
  false
end

#check_role_permissions?(permissions, request) ⇒ Boolean (private)

Check if any role permission matches the request

Returns:

  • (Boolean)

Since:

  • 0.1.0



241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 241

def check_role_permissions?(permissions, request)
  return false unless permissions.is_a?(Array)

  request_path = extract_api_path_from_request(request)
  request_method = request.request_method.downcase

  permissions.each do |permission|
    return true if permission_matches?(permission, request_path, request_method)
  end

  false
end

#extract_api_path_from_request(request) ⇒ Object (private)

Extract the API path portion from the full request path Removes subdomain and pathname slug parts to get the resource endpoint

Since:

  • 0.1.0



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 291

def extract_api_path_from_request(request)
  path = request.path

  # Remove API prefix and pathname slug pattern if configured
  if @config.pathname_slug_pattern
    # Extract the resource path after the pathname slug
    match = path.match(@config.pathname_slug_pattern)
    if match&.captures&.any?
      # Get everything after the slug pattern
      slug_part = match[0]
      resource_path = path.sub(slug_part, '')
      return resource_path.start_with?('/') ? resource_path[1..] : resource_path
    end
  end

  # Fallback: remove common API prefixes
  path = path.sub(%r{^/api/v\d+/}, '')
  path = path.sub(%r{^/api/}, '')
  path.sub(%r{^/}, '')
end

#extract_user_roles_from_request(request) ⇒ Object (private)

Extract user roles from request context (stored by middleware)

Since:

  • 0.1.0



235
236
237
238
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 235

def extract_user_roles_from_request(request)
  # Check if roles are stored in request environment by middleware
  request.env['rack_jwt_aegis.user_roles']
end

#find_matching_permission(permissions, request) ⇒ Object (private)

Find the first matching permission for the request (returns the permission string or nil)

Since:

  • 0.1.0



255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 255

def find_matching_permission(permissions, request)
  return nil unless permissions.is_a?(Array)

  request_path = extract_api_path_from_request(request)
  request_method = request.request_method.downcase

  permissions.each do |permission|
    return permission if permission_matches?(permission, request_path, request_method)
  end

  nil
end

#method_matches?(permission_method, request_method) ⇒ Boolean (private)

Check if HTTP method matches

Returns:

  • (Boolean)

Since:

  • 0.1.0



330
331
332
333
334
335
336
337
338
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 330

def method_matches?(permission_method, request_method)
  permission_method = permission_method.downcase

  # Wildcard method matches all
  return true if permission_method == '*'

  # Exact method match
  permission_method == request_method
end

#nuke_user_permissions_cache(reason) ⇒ Object (private)

Nuke (delete) the entire user permissions cache

Since:

  • 0.1.0



223
224
225
226
227
228
229
230
231
232
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 223

def nuke_user_permissions_cache(reason)
  return unless @permissions_cache

  begin
    @permissions_cache.delete('user_permissions')
    debug_log("Nuked user permissions cache: #{reason}")
  rescue CacheError => e
    debug_log("RbacManager cache nuke error: #{e.message}", :warn)
  end
end

#path_matches?(permission_path, resource_path) ⇒ Boolean (private)

Check if path matches (handles both literal strings and regex patterns)

Returns:

  • (Boolean)

Since:

  • 0.1.0



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 341

def path_matches?(permission_path, resource_path)
  # Handle regex pattern format: "%r{pattern}"
  if permission_path.start_with?('%r{') && permission_path.end_with?('}')
    regex_pattern = permission_path[3..-2] # Remove %r{ and }
    begin
      regex = Regexp.new(regex_pattern)
      return regex.match?(resource_path)
    rescue RegexpError => e
      debug_log("RbacManager: Invalid regex pattern '#{regex_pattern}': #{e.message}", :warn)
      return false
    end
  end

  # Exact string match
  permission_path == resource_path
end

#permission_matches?(permission, resource_path, request_method) ⇒ Boolean (private)

Check if a permission string matches the request

Returns:

  • (Boolean)

Since:

  • 0.1.0



313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 313

def permission_matches?(permission, resource_path, request_method)
  return false unless permission.is_a?(String)

  # Parse permission format: "resource-endpoint:http-method"
  parts = permission.split(':')
  return false unless parts.length == 2

  permission_path, permission_method = parts

  # Check if method matches
  return false unless method_matches?(permission_method, request_method)

  # Check if path matches (handle both literal and regex patterns)
  path_matches?(permission_path, resource_path)
end

#rbac_last_update_timestampObject (private)

Get RBAC permissions collection last_update timestamp

Since:

  • 0.1.0



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 181

def rbac_last_update_timestamp
  return nil unless @rbac_cache

  begin
    rbac_data = @rbac_cache.read('permissions')
    if rbac_data.is_a?(Hash) && (rbac_data.key?('last_update') || rbac_data.key?(:last_update))
      return rbac_data['last_update'] || rbac_data[:last_update]
    end

    nil
  rescue CacheError => e
    debug_log("RbacManager RBAC last-update read error: #{e.message}", :warn)
    nil
  end
end

#remove_stale_permission(permission_key, reason) ⇒ Object (private)

Remove a specific stale permission

Since:

  • 0.1.0



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 198

def remove_stale_permission(permission_key, reason)
  return unless @permissions_cache

  begin
    user_permissions = @permissions_cache.read('user_permissions')
    return unless user_permissions.is_a?(Hash)

    # Remove the specific permission key
    user_permissions.delete(permission_key)

    # If no permissions remain, remove the entire cache
    if user_permissions.empty?
      @permissions_cache.delete('user_permissions')
      debug_log("Removed last permission, cleared entire cache: #{reason}")
    else
      # Update the cache with the modified permissions
      @permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
      debug_log("Removed stale permission #{permission_key}: #{reason}")
    end
  rescue CacheError => e
    debug_log("RbacManager stale permission removal error: #{e.message}", :warn)
  end
end

#setup_cache_adaptersObject (private)

Since:

  • 0.1.0



52
53
54
55
56
57
58
59
60
61
62
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 52

def setup_cache_adapters
  return unless @config.rbac_enabled

  begin
    @rbac_cache = CacheAdapter.build(@config.rbac_cache_store, @config.rbac_cache_store_options || {})
    @permissions_cache = CacheAdapter.build(@config.permissions_cache_store,
                                            @config.permissions_cache_store_options || {})
  rescue ConfigurationError
    raise ConfigurationError, 'RBAC cache store not configured'
  end
end

#validate_rbac_cache_format(rbac_data) ⇒ Object (private)

Validate RBAC cache format according to specification Expected format: { last_update: timestamp, permissions: { "role-id": ["resource-endpoint:http-method"] } }

Since:

  • 0.1.0



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/rack_jwt_aegis/rbac_manager.rb', line 366

def validate_rbac_cache_format(rbac_data)
  return false unless rbac_data.is_a?(Hash)

  # Check required fields
  return false unless rbac_data.key?('last_update') || rbac_data.key?(:last_update)
  return false unless rbac_data.key?('permissions') || rbac_data.key?(:permissions)

  # Get permissions object (now expecting a Hash, not Array)
  permissions = rbac_data['permissions'] || rbac_data[:permissions]

  # If permissions is present but not a Hash, raise an exception to help developers
  if !permissions.nil? && !permissions.is_a?(Hash)
    raise ConfigurationError, "RBAC permissions must be a Hash with role-id keys, not #{permissions.class}. " \
                              "Expected format: {\"role-id\": [\"resource:method\", ...]}, " \
                              "but got: #{permissions.class}"
  end

  # Return false if permissions is nil (should not happen given the key check above, but defensive)
  return false if permissions.nil?

  # Validate each role's permissions
  permissions.each_value do |role_permissions|
    return false unless role_permissions.is_a?(Array)

    # Each permission should be a string in format "endpoint:method"
    role_permissions.each do |permission|
      return false unless permission.is_a?(String)
      # Permission must include ':' (resource:method format) or '*' (wildcard)
      return false unless permission.include?(':') || permission.include?('*')
    end
  end

  true
rescue ConfigurationError
  # Re-raise configuration errors so developers see them
  raise
rescue StandardError => e
  debug_log("RbacManager: Cache format validation error: #{e.message}", :warn)
  false
end