Class: RackJwtAegis::MultiTenantValidator

Inherits:
Object
  • Object
show all
Defined in:
lib/rack_jwt_aegis/multi_tenant_validator.rb

Overview

Multi-tenant validation for subdomain and pathname slug access control

Validates that users can only access resources within their permitted tenant boundaries. Supports two levels of tenant validation:

  1. Subdomain-based (Level 1) - Company-Group level isolation
  2. Pathname slug-based (Level 2) - Company level isolation within groups

Examples:

Usage

config = Configuration.new(
  jwt_secret: 'secret',
  validate_subdomain: true,
  validate_pathname_slug: true
)
validator = MultiTenantValidator.new(config)
validator.validate(request, jwt_payload)

Author:

  • Ken Camajalan Demanawa

Since:

  • 0.1.0

Instance Method Summary collapse

Constructor Details

#initialize(config) ⇒ MultiTenantValidator

Initialize the multi-tenant validator

Parameters:

Since:

  • 0.1.0



26
27
28
# File 'lib/rack_jwt_aegis/multi_tenant_validator.rb', line 26

def initialize(config)
  @config = config
end

Instance Method Details

#extract_slug_from_path(path) ⇒ Object (private)

Since:

  • 0.1.0



124
125
126
127
# File 'lib/rack_jwt_aegis/multi_tenant_validator.rb', line 124

def extract_slug_from_path(path)
  # Use configured pattern to extract company slug
  @config.pathname_slug_pattern.match(path.to_s.strip.downcase)&.to_a&.last
end

#extract_subdomain(host) ⇒ Object (private)

Since:

  • 0.1.0



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/rack_jwt_aegis/multi_tenant_validator.rb', line 105

def extract_subdomain(host)
  return nil if host.nil? || host.empty?

  # Handle different host formats:
  # - subdomain.domain.com -> subdomain
  # - subdomain.domain.co.uk -> subdomain
  # - domain.com -> nil (no subdomain)
  # - localhost:3000 -> nil (no subdomain)

  parts = host.split('.')

  # Need at least 3 parts for subdomain (subdomain.domain.tld)
  # or 4 parts for country domains (subdomain.domain.co.uk)
  return nil if parts.length < 3

  # Return first part as subdomain
  parts.first
end

#validate(request, payload) ⇒ Object

Validate multi-tenant access permissions for the request

Parameters:

  • request (Rack::Request)

    the incoming request

  • payload (Hash)

    the JWT payload containing tenant information

Raises:

Since:

  • 0.1.0



35
36
37
38
39
# File 'lib/rack_jwt_aegis/multi_tenant_validator.rb', line 35

def validate(request, payload)
  validate_subdomain(request, payload)
  validate_pathname_slug(request, payload)
  validate_tenant_id_header(request, payload)
end

#validate_pathname_slug(request, payload) ⇒ Object (private)

Level 2 Multi-Tenant: Sub-level tenant (Company) validation via URL path

Raises:

Since:

  • 0.1.0



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/rack_jwt_aegis/multi_tenant_validator.rb', line 66

def validate_pathname_slug(request, payload)
  return unless @config.validate_pathname_slug?

  # Extract company slug from URL path
  pathname_slug = extract_slug_from_path(request.path)

  return if pathname_slug.nil? # No company slug in path

  # Get accessible company slugs from JWT
  accessible_slugs = payload[@config.payload_key(:pathname_slugs).to_s]

  if accessible_slugs.nil? || !accessible_slugs.is_a?(Array) || accessible_slugs.empty?
    raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for pathname slug access validation'
  end

  # Check if requested company slug is in user's accessible list
  return if accessible_slugs.map(&:downcase).include?(pathname_slug)

  # TODO: make this error configurable as well
  raise AuthorizationError,
        "Pathname slug access denied: '#{pathname_slug}' not in accessible pathname slugs #{accessible_slugs}"
end

#validate_subdomain(request, payload) ⇒ Object (private)

Level 1 Multi-Tenant: Top-level tenant (Company-Group) validation via subdomain

Raises:

Since:

  • 0.1.0



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/rack_jwt_aegis/multi_tenant_validator.rb', line 44

def validate_subdomain(request, payload)
  return unless @config.validate_subdomain?

  request_host = request.host
  return if request_host.to_s.empty?

  # Extract subdomain from request host
  req_subdomain = extract_subdomain(request_host).to_s.downcase

  # Get JWT domain claim
  jwt_claim = payload[@config.payload_key(:subdomain).to_s].to_s.strip.downcase
  raise AuthorizationError, 'JWT payload missing subdomain for subdomain validation' if jwt_claim.empty?

  # Compare subdomains
  return if req_subdomain.eql?(jwt_claim)

  raise AuthorizationError,
        "Subdomain access denied: request subdomain '#{req_subdomain}' " \
        "does not match JWT subdomain '#{jwt_claim}'"
end

#validate_tenant_id_header(request, payload) ⇒ Object (private)

Company Group header validation (additional security layer)

Raises:

Since:

  • 0.1.0



90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/rack_jwt_aegis/multi_tenant_validator.rb', line 90

def validate_tenant_id_header(request, payload)
  return unless @config.validate_tenant_id?

  # Get tenant id from request header
  header_value = request.get_header("HTTP_#{@config.tenant_id_header_name.upcase.tr('-', '_')}").to_s.downcase
  # Get tenant id from JWT payload
  jwt_claim = payload[@config.payload_key(:tenant_id).to_s].to_s.strip.downcase
  raise AuthorizationError, 'JWT payload missing tenant_id for header validation' if jwt_claim.empty?

  return if !header_value.empty? && header_value.eql?(jwt_claim)

  raise AuthorizationError,
        "Tenant id header mismatch: header '#{header_value}' does not match JWT '#{jwt_claim}'"
end