Module: TrustyCms::SiteScopeAuthReporter

Defined in:
lib/trusty_cms/site_scope_auth_reporter.rb

Overview

Observe-only telemetry for the intermittent CMS logout bug (diagnostic for issue #1040). It changes NO behavior.

When the multi_site extension is active, User carries a site-scoped default_scope (an INNER JOIN on admins_sites for Page.current_site). Devise deserializes the session user every request through that scope, so if current_site points at a site the user is not assigned to, the lookup returns nil and the user is logged out. Because current_site is process-global mutable state shared across Puma threads, we suspect this is usually a race (the user IS on a site they belong to, but the global was clobbered by a concurrent request for another host).

This reporter fires only when a session lookup misses BUT the user actually exists (unscoped) — i.e. a valid user was excluded by the site scope — and records enough context to tell a race from a genuine cross-site access or an unmatched-host fallback. It never raises into the auth path.

Constant Summary collapse

MESSAGE =
'CMS site-scope logout (possible current_site race)'.freeze

Class Method Summary collapse

Class Method Details

.build_context(user) ⇒ Object



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

def build_context(user)
  current = current_site
  host = Thread.current[:trusty_request_host]
  expected = expected_site_for(host)
  site_ids = user.site_ids

  {
    user_id: user.id,
    user_admin: user.admin?,
    user_site_ids: site_ids,
    current_site_id: current&.id,
    current_site_name: current&.name,
    current_site_domain_blank: current.present? && current.domain.blank?,
    request_host: host,
    expected_site_id: expected&.id,
    expected_site_name: expected&.name,
    classification: classify(site_ids, current, expected, host),
    thread: Thread.current.object_id,
    at: Time.now.utc.iso8601,
  }
end

.classify(site_ids, current, expected, host) ⇒ Object

race -> user belongs to the host's site, but current_site is something else (the global was clobbered) genuine_cross_site -> user hit a host whose site they are not assigned to host_unmatched -> request host matched no site (asset/unknown host); current_site falls back to the default site no_host_captured -> ran outside a captured request (e.g. rake/console)



73
74
75
76
77
78
79
80
# File 'lib/trusty_cms/site_scope_auth_reporter.rb', line 73

def classify(site_ids, current, expected, host)
  return 'no_host_captured' if host.blank?
  return 'host_unmatched' if expected.nil?
  return 'race' if site_ids.include?(expected.id) && (current.nil? || site_ids.exclude?(current.id))
  return 'genuine_cross_site' if site_ids.exclude?(expected.id)

  'unknown'
end

.current_siteObject



82
83
84
# File 'lib/trusty_cms/site_scope_auth_reporter.rb', line 82

def current_site
  Page.current_site if Page.respond_to?(:current_site)
end

.domain_regexp_match?(site, host) ⇒ Boolean

Returns:

  • (Boolean)


100
101
102
103
104
105
106
# File 'lib/trusty_cms/site_scope_auth_reporter.rb', line 100

def domain_regexp_match?(site, host)
  return false if site.domain.blank?

  !(host =~ Regexp.compile(site.domain)).nil?
rescue RegexpError
  false
end

.expected_site_for(host) ⇒ Object

Mirrors Site.find_for_host's matching but WITHOUT the Site.default / catchall fallback, so telemetry never creates a record and returns nil when the host matches no site (which is what drives the host_unmatched classification). A blank domain compiles to a regexp that matches every host, so blank domains are matched only by exact base_domain, never by the regexp.



92
93
94
95
96
97
98
# File 'lib/trusty_cms/site_scope_auth_reporter.rb', line 92

def expected_site_for(host)
  return nil if host.blank?

  Site.all.detect do |site|
    host == site.base_domain || domain_regexp_match?(site, host)
  end
end

.multi_site_scoped?Boolean

Only meaningful when User is actually site-scoped by the multi_site extension; otherwise the scoped and unscoped lookups are identical.

Returns:

  • (Boolean)


41
42
43
# File 'lib/trusty_cms/site_scope_auth_reporter.rb', line 41

def multi_site_scoped?
  User.respond_to?(:is_site_scoped?) && User.is_site_scoped?
end

.report_miss(key) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/trusty_cms/site_scope_auth_reporter.rb', line 23

def report_miss(key)
  return unless multi_site_scoped?

  excluded = User.unscoped { User.to_adapter.get(key) }
  return if excluded.nil? # ordinary unknown/expired session — not our bug

  context = build_context(excluded)
  Rails.logger.warn("[multi_site] #{MESSAGE} #{context.to_json}")
  Honeybadger.notify(MESSAGE, context: context) if defined?(Honeybadger)
  nil
rescue StandardError => e
  # Telemetry must never break authentication.
  Rails.logger.error("[multi_site] SiteScopeAuthReporter error: #{e.class}: #{e.message}")
  nil
end