Module: DevDoc::Test::Lints::CrossTenantCanaryCheck

Defined in:
lib/dev_doc/test/lints/cross_tenant_canary_check.rb

Overview

Cross-tenant canary sweep: catches a tenant-isolation leak by observing responses/side-effects, not by inspecting source. The static auth cops (DevDoc/Auth/*) flag the leak-shaped code patterns we have seen before; this lint fails when another tenant's data actually APPEARS somewhere a persona must not see it — regardless of which novel controller shape caused it.

The canary convention (consumer-side)

Fixtures give every tenant globally-unique, greppable markers on fields that render into responses (e.g. CANARY-<tenant>-<field>), seeded for at least two tenants so "foreign" is well-defined. Markers must be STATIC fixture strings — never minted at runtime — so a leak is a pure substring test with zero false positives.

What it covers, honestly

  • A passive scan of every crawled response catches leaks on routes a persona can reach. It reads CONTENT, not status: an endpoint that returns 200 by design but embeds foreign data (a metadata preview) is caught where a status-only probe passes it.
  • An adversarial replay (id-like params substituted with a foreign tenant's ids) reaches leaks no page ever links the persona to. Only 2xx bodies are asserted on — a substituted id that 404s has no body to leak, and status is not the signal.
  • An outbound-recipient scan catches side-effect leaks (mail sent to a foreign tenant's address) that leave no trace in any response.

NOT covered: anything a persona can neither be linked to nor have its params mutated into, and any field carrying no marker. The lint reduces the cross-tenant surface; it does not eliminate it.

Usage

This file holds only the pure, Rails-free core (so the gem's own spec suite can load it). Projects include the runtime wrapper into their base integration test instead — see DevDoc::Test::Lints::CrossTenantCanarySweep.

Constant Summary collapse

MARKER =

The marker convention: CANARY-<tenant>-<field>. Consumers seed these STATIC strings onto tenant-owned fields. The tenant id is embedded, so collection needs no per-model knowledge — grouping is by the marker itself.

/CANARY-\d+-[a-z0-9-]+/
TENANT_FROM_MARKER =
/\ACANARY-(\d+)-/

Class Method Summary collapse

Class Method Details

.foreign_markers(allowed_tenant_keys, registry) ⇒ Object

Markers belonging to every tenant OUTSIDE the persona's allowed set. Takes a SET of allowed tenant keys, not a single tenant — a global admin legitimately sees every tenant (pass all keys and the check is vacuous for it), and hierarchical tenants can grant a parent visibility into its children. Keys compare as strings so fixture-derived integers and configured symbols can't miss.



70
71
72
73
74
# File 'lib/dev_doc/test/lints/cross_tenant_canary_check.rb', line 70

def foreign_markers(allowed_tenant_keys, registry)
  allowed = allowed_tenant_keys.map(&:to_s)
  registry.reject { |key, _| allowed.include?(key.to_s) }
          .values.flatten.uniq
end

.id_like_param_keys(keys, extra: []) ⇒ Object

Param keys worth substituting with a foreign tenant's values in an adversarial replay: id, anything ending _id, plus an explicit project-configured allowlist (e.g. a display-mode toggle that switches a form into a data-bearing preview).



97
98
99
100
101
102
# File 'lib/dev_doc/test/lints/cross_tenant_canary_check.rb', line 97

def id_like_param_keys(keys, extra: [])
  extras = extra.map(&:to_s)
  keys.map(&:to_s)
      .select { |key| key == 'id' || key.end_with?('_id') || extras.include?(key) }
      .uniq
end

.leaked_markers(body, markers) ⇒ Object

The subset of markers present in the body. Boundary-aware, like CopDriftCheck.unmentioned_cops: a marker that PREFIXES a longer marker must not match on the longer one's occurrence, or the failure names the wrong leaked field.



80
81
82
83
84
# File 'lib/dev_doc/test/lints/cross_tenant_canary_check.rb', line 80

def leaked_markers(body, markers)
  return [] if body.nil? || body.empty?

  markers.select { |marker| body.match?(/#{Regexp.escape(marker)}(?![A-Za-z0-9-])/) }
end

.leaked_recipients(recipients, foreign_emails) ⇒ Object

Outbound recipients that belong to a foreign tenant. Emails are case-insensitive per RFC domain rules and common practice, so the intersection normalizes case on both sides.



89
90
91
# File 'lib/dev_doc/test/lints/cross_tenant_canary_check.rb', line 89

def leaked_recipients(recipients, foreign_emails)
  normalize(recipients) & normalize(foreign_emails)
end

.tenant_registry(texts) ⇒ Object

Build { tenant_key => [markers...] } from arbitrary text sources — fixture-file contents, DB column values, rendered snapshots — grouping each marker by the tenant id embedded in it. Pure string logic: the consumer owns WHERE the text comes from (fixtures path, models), the gem owns the marker format and the grouping. Deduped per tenant.



54
55
56
57
58
59
60
61
62
# File 'lib/dev_doc/test/lints/cross_tenant_canary_check.rb', line 54

def tenant_registry(texts)
  registry = Hash.new { |hash, key| hash[key] = [] }
  texts.each do |text|
    text.to_s.scan(MARKER).each do |marker|
      registry[marker[TENANT_FROM_MARKER, 1]] << marker
    end
  end
  registry.transform_values(&:uniq)
end