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
-
.foreign_markers(allowed_tenant_keys, registry) ⇒ Object
Markers belonging to every tenant OUTSIDE the persona's allowed set.
-
.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). -
.leaked_markers(body, markers) ⇒ Object
The subset of markers present in the body.
-
.leaked_recipients(recipients, foreign_emails) ⇒ Object
Outbound recipients that belong to a foreign tenant.
-
.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.
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 |