diff --git a/AGENTS.md b/AGENTS.md index abacfaf..912ab18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Clickwrap's value is evidence that is still true and still verifiable years afte 3. **Released evidence formats are permanent.** Every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning gets a golden fixture, and new versions must keep verifying old receipts. A format change means a new explicit schema and verifier, never a silent reinterpretation. Do not edit a released migration underneath an installed application; add an upgrade migration. 4. **Required writes cannot be error-isolated.** Evidence and the protected database action commit together or not at all. Optional after-commit hooks, analytics, and notifications are isolated and can never undo a committed action or stand in for one. 5. **The browser is not a policy author.** Policy key, revision, document versions, validity, subject binding, retention, and request-evidence fields are resolved server-side and rechecked at submit. Never add a hidden field, parameter, or header that lets a client choose any of them. -6. **Default to collecting nothing.** IP address, browser user-agent, and every individual IP-geolocation field stay off until a policy names them with a plain-English purpose and a retention decision. Never add an option that enables a category of personal data as a side effect of enabling something else, and never add an opaque profile switch (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`). +6. **Collecting nothing is the gem's default; collecting the ordinary trio is one honest switch.** Every `record_*` flag ships false. Turning on the coarse trio — IP address, browser user agent, and a country/region/city estimate — is `config.record_request_evidence_by_default = true` and nothing else: the purpose and the disposal answer have gem-supplied defaults (`Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE`, and no clock means it keeps pace with the evidence it corroborates), the per-field flags and per-policy `record_ip_address(...)`/`do_not_record_ip_address` still override it, and every finer geolocation field stays its own separately named one-liner. Never add a switch whose NAME hides what it collects (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`) — that prohibition is about dishonest names and legal claims, and it survives; `record_request_evidence_by_default` says exactly what it records. **Owner directive, 2026-08-20 (v0.3.0), deliberately reversing the previous "every field needs a written purpose and a retention decision or boot fails" rule:** the ceremony was pushing integrators to collect nothing, and no evidence is worse than un-reviewed evidence. Do not "fix" this back. What is still refused: scaffolding text (`TODO`) standing in for a purpose the host actually wrote, a deletion clock set alongside a keep-indefinitely declaration for the same category, and turning encryption off without `deliberately_store_request_evidence_unencrypted!(because:)`. 7. **Names read aloud.** Complete verb-and-noun names, positive booleans, destructive methods that say exactly what they delete, `ip_address` not `ip`, `browser_user_agent` not `ua`, `ip_geolocation` not `location`, `http_request` not `context`, `recorded_at_by_server` not `signed_at`. If an example does not make sense read aloud by a developer who has never seen the gem, the name is wrong. ## Working here diff --git a/CHANGELOG.md b/CHANGELOG.md index ff427f9..0674b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-08-20 + +### Changed — recording request evidence is one switch, not a checklist + +Owner directive, and a deliberate reversal of part of this gem's own +philosophy. The reasoning, stated plainly so a future reader can weigh it: +the ceremony around *enabling* request evidence — a written purpose, a +disposal answer, and a reviewed trusted-proxy digest, each of them a boot +refusal — was not producing better-reviewed collection. It was producing +**no collection at all**. Integrators hit three refusals in a row on the way +to their first capture and turned the fields off, and an agreement with no +corroboration is worse evidence than one corroborated under a purpose the gem +stated on their behalf. So the friction is gone from turning collection *on*. +None of it is gone from describing collection honestly. + +- **`config.record_request_evidence_by_default = true`.** One line records, on + every policy, the IP address the request arrived from, the browser user + agent it sent, and a coarse country / region / city estimate for that + address. Nothing finer: a postal code, coordinates, a timezone, a continent, + a metro code, and an accuracy radius each remain their own separately named + setting. It is a fan-out setter over the existing `record_*_by_default` + flags, so it composes with them in reading order, and any policy still + overrides it with `record_ip_address(...)` or `do_not_record_ip_address`. + The reader reports what is actually on rather than a remembered assignment. +- **A purpose is no longer the price of admission.** A category enabled + without `because:` / `reason_for_recording_*_by_default` records + `Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE` — "Corroborate who performed + each recorded act, from where, on what client — to defend the recorded + agreement itself." Every compiled policy revision therefore still carries a + purpose into every receipt, and `Clickwrap::Privacy.inventory` marks each one + `"purpose_source": "gem_default"` or `"host"` so the gem's sentence can never + be mistaken for a decision somebody reviewed. +- **No disposal answer means indefinite.** The four gatekeepers that refused a + recorded field with no clock — `Configuration#validate_request_evidence_defaults!`, + the policy-level check in `RequestEvidencePolicy`, the reference validator, + and the capture-time extractor — now treat absence as "keeps pace with the + evidence it corroborates", which is what core evidence has done since 0.2.0. + The annex is stamped with no schedule and the retention planner never lists + it. `keep_recorded_*_indefinitely!` still works, still records its reason, + and now accepts no `because:` at all (defaulting to "Corroboration lives as + long as the evidence it corroborates"). +- **`trusted_proxy_configuration_digest` is no longer required to record an IP + address.** When it is absent the annex stores `nil`, and that nil is honest + provenance: no reviewed proxy configuration was recorded when this address + was observed. Hosts who set one still get the stronger record, the setter + still refuses anything that is not a complete prefixed SHA-2 digest, and + `clickwrap:doctor` still warns while it is unset. +- **Bundled `trackdown` is used without a wiring line.** A policy that records + IP geolocation and names no resolver now gets + `Clickwrap::IpGeolocation::TrackdownResolver` automatically when the host's + bundle carries trackdown 0.4 or newer — lazily, considered once, and only at + the moment something actually needs an address resolved. The privacy + inventory reports such a resolver with `"source": "gem_default"` and doctor + names it. An installed release older than 0.4 gets the adapter's own + upgrade sentence rather than a misleading "trackdown is not installed", and + a host with no trackdown gets the boot sentence, now naming `bundle add + trackdown` as the first option. + +### Unchanged, deliberately + +- The gem's code default is still record-nothing. The switch is opt-in. +- Claim boundaries are untouched: nothing says compliant, enforceable, proves + identity, or physical location, and IP geolocation remains network context — + not identity, not GPS. +- Encryption stays on by default, and turning it off keeps its + `deliberately_store_request_evidence_unencrypted!(because:)` ceremony. That + one is a genuine hazard with a named escape hatch; the directive was about + the friction of enabling collection, not the friction of weakening it. +- Scaffolding text is still refused wherever the host actually wrote it + (`"TODO: ask legal"` is not a purpose), and a deletion clock declared + alongside `keep_recorded_..._indefinitely!` for the same category is still + refused as opposite decisions. +- Receipts still distinguish `not_configured` / `unavailable` / `recorded` / + `deleted_after_retention`, and no released receipt format changed. The + purpose provenance is deliberately kept off `to_snapshot`, so policy + revision digests and every golden fixture verify exactly as before. + +### Documentation — the request-evidence story is rewritten around the switch + +- README, `guides/request-evidence.md`, `guides/integrating.md`, and + `guides/naming.md` lead with the one switch and present purposes, legal + bases, clocks, and proxy digests as the upgrade path for teams who want + reviewed records — not as the entry fee. The now-false "boot refuses without + a reason or a period" claims are gone. +- `CLAUDE.md` / `AGENTS.md` rule 6 is rewritten to the new truth and records + this directive and its date, so a future agent does not restore the + refusals as a "fix". The prohibition it keeps is the one that was always the + point: never add a switch whose *name* hides what it collects. + ### Documentation — the docs stop contradicting the shipped gem - **The README and `guides/integrating.md` teach `gem "clickwrap"`.** Both diff --git a/CLAUDE.md b/CLAUDE.md index 5fb0b40..702252a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Clickwrap's value is evidence that is still true and still verifiable years afte 3. **Released evidence formats are permanent.** Every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning gets a golden fixture, and new versions must keep verifying old receipts. A format change means a new explicit schema and verifier, never a silent reinterpretation. Do not edit a released migration underneath an installed application; add an upgrade migration. 4. **Required writes cannot be error-isolated.** Evidence and the protected database action commit together or not at all. Optional after-commit hooks, analytics, and notifications are isolated and can never undo a committed action or stand in for one. 5. **The browser is not a policy author.** Policy key, revision, document versions, validity, subject binding, retention, and request-evidence fields are resolved server-side and rechecked at submit. Never add a hidden field, parameter, or header that lets a client choose any of them. -6. **Default to collecting nothing.** IP address, browser user-agent, and every individual IP-geolocation field stay off until a policy names them with a plain-English purpose and a retention decision. Never add an option that enables a category of personal data as a side effect of enabling something else, and never add an opaque profile switch (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`). +6. **Collecting nothing is the gem's default; collecting the ordinary trio is one honest switch.** Every `record_*` flag ships false. Turning on the coarse trio — IP address, browser user agent, and a country/region/city estimate — is `config.record_request_evidence_by_default = true` and nothing else: the purpose and the disposal answer have gem-supplied defaults (`Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE`, and no clock means it keeps pace with the evidence it corroborates), the per-field flags and per-policy `record_ip_address(...)`/`do_not_record_ip_address` still override it, and every finer geolocation field stays its own separately named one-liner. Never add a switch whose NAME hides what it collects (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`) — that prohibition is about dishonest names and legal claims, and it survives; `record_request_evidence_by_default` says exactly what it records. **Owner directive, 2026-08-20 (v0.3.0), deliberately reversing the previous "every field needs a written purpose and a retention decision or boot fails" rule:** the ceremony was pushing integrators to collect nothing, and no evidence is worse than un-reviewed evidence. Do not "fix" this back. What is still refused: scaffolding text (`TODO`) standing in for a purpose the host actually wrote, a deletion clock set alongside a keep-indefinitely declaration for the same category, and turning encryption off without `deliberately_store_request_evidence_unencrypted!(because:)`. 7. **Names read aloud.** Complete verb-and-noun names, positive booleans, destructive methods that say exactly what they delete, `ip_address` not `ip`, `browser_user_agent` not `ua`, `ip_geolocation` not `location`, `http_request` not `context`, `recorded_at_by_server` not `signed_at`. If an example does not make sense read aloud by a developer who has never seen the gem, the name is wrong. ## Working here diff --git a/README.md b/README.md index 4643620..7848488 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,11 @@ Clickwrap.policy :signup do end ``` -(Yes, the payload-retention decision is mandatory — `clickwrap` will not silently -default captured evidence or request evidence to "keep forever" and will not pick -a period for you. A minimal, digest-linked disposition tombstone remains after a -reviewed core deletion so the deletion itself does not become an unexplained hole.) +(A retention class that names no rule keeps the evidence, which is the same +posture request evidence takes: keeping is reversible, deleting is the explicit +act. `clickwrap` will not pick a *deletion* period for you. A minimal, +digest-linked disposition tombstone remains after a reviewed core deletion so +the deletion itself does not become an unexplained hole.) Add one macro to your model: @@ -722,37 +723,54 @@ With the engine mounted, users can view and download their own receipts, and ope `clickwrap` always records its event ID, server time, capture channel, and policy version. That proves **what was offered and what came back**. But an agreement dispute is rarely about the words — it's "that wasn't me" — and the answer to that is request evidence: the IP address, the browser, and where in the world the request came from, bound into the same digest-linked record at the same instant. Years later, "this exact sentence was accepted from this address, on this client, from this city, at this second, in the transaction that created the account" is a different conversation from "the row says yes". **Our recommendation is to record IP + user agent + geolocation on every assent policy** — you already hold a purpose (defending the very agreement being made) and the evidence lives encrypted, in its own annex, deletable on its own schedule if your counsel ever decides so. -What the gem refuses to do is turn it on *silently*. Every field is a separate named decision with a written purpose — there is deliberately no `maximum_evidence` switch — so the recommended posture is three explicit blocks in your initializer: +So turn it on. One line, and you are done: + +```ruby +Clickwrap.configure do |config| + config.record_request_evidence_by_default = true +end +``` + +That records, on every policy: the IP address the request arrived from, the browser user agent it sent, and a coarse country / region / city estimate for that address. Add [`trackdown`](https://github.com/rameerez/trackdown) 0.4+ to your Gemfile and the geolocation half resolves itself — Clickwrap picks up the official adapter with no wiring line, so trackdown plus Cloudflare genuinely is "bundle it and flip the switch". + +Nothing else is required, because Clickwrap supplies honest defaults for the parts you did not write: + +- **Purpose.** Every recorded field carries one into the receipt. Yours if you wrote one, otherwise Clickwrap's: *"Corroborate who performed each recorded act, from where, on what client — to defend the recorded agreement itself."* The [privacy inventory](#operations) marks which of the two it is reading back (`"purpose_source": "gem_default"` vs `"host"`), so a gem sentence never passes for a decision your team reviewed. +- **How long.** No clock means it keeps pace with the evidence it corroborates — the same posture core evidence has had since 0.2.0. A corroboration scheduled to expire before the agreement it corroborates is a scheduled weakening of the record. +- **Proxy provenance.** `trusted_proxy_configuration_digest` is recorded when you set one and left `nil` when you have not, and the `nil` is itself the honest answer: no reviewed proxy configuration was in force when this address was observed. `bin/rails clickwrap:doctor` says so out loud. + +What the gem still will not do is hide what it collects behind a name. The switch is called `record_request_evidence_by_default` because that is what it does; there is deliberately no `gdpr_compliant_mode`, `maximum_evidence`, or `legal_proof`, and no flag here makes any claim about the law. It also stops exactly at the coarse trio: a postal code, coordinates, a timezone, a metro code, and an accuracy radius each stay their own separately named line, because a switch that reads "record request evidence" should not hand you coordinates you never asked for. + +### The upgrade path: reviewed records + +The one-liner is the entry point, not the ceiling. Teams who want the stronger record — words their counsel signed off on, a legal basis reference, a named clock, a reviewed proxy topology — write them, and Clickwrap keeps every one of them as the host's own: ```ruby # clickwrap-doc-test: syntax-only — the resolver needs trackdown installed Clickwrap.configure do |config| - config.record_ip_address_by_default = true + config.record_request_evidence_by_default = true + config.reason_for_recording_ip_addresses_by_default = "Corroborate who performed each recorded act, to defend the agreement itself" + config.legal_basis_reference_for_recording_ip_addresses_by_default = "LIA-SECURITY-2026-01" config.keep_recorded_ip_addresses_indefinitely!( because: "Corroboration must live exactly as long as the evidence it corroborates") - config.record_browser_user_agent_by_default = true config.reason_for_recording_browser_user_agents_by_default = "Corroborate the client context of each recorded act" - config.keep_recorded_browser_user_agents_indefinitely!( - because: "Corroboration must live exactly as long as the evidence it corroborates") + config.delete_recorded_browser_user_agents_after = 2.years - config.record_ip_geolocation_country_by_default = true - config.record_ip_geolocation_region_by_default = true - config.record_ip_geolocation_city_by_default = true config.reason_for_recording_ip_geolocation_by_default = "Corroborate where each recorded act was performed from" - config.keep_recorded_ip_geolocation_indefinitely!( - because: "Corroboration must live exactly as long as the evidence it corroborates") config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new + config.trusted_proxy_configuration_digest = + Clickwrap.trusted_proxy_configuration_digest_for_rails_application config.review_default_request_evidence_configuration_on = Date.new(2027, 8, 1) end ``` -(`keep_recorded_..._indefinitely!` matches the retention default since 0.2.0 — evidence keeps until deletion is an explicit reviewed act. A corroboration that expires before the agreement it corroborates is a scheduled weakening of the record; if your counsel wants a clock instead, `delete_recorded_..._after` is the same one-line decision in the other direction.) +Two things are still refused, and both are you contradicting yourself rather than leaving a blank: scaffolding text (`"TODO: ask legal"`) standing in for a purpose, and a deletion clock set alongside `keep_recorded_..._indefinitely!` for the same category. A single regulated surface can also name a field per policy instead of by default: @@ -773,10 +791,11 @@ end Recorded values live in a separately encrypted annex with their own retention, so they can be deleted later without rewriting the core event payload. Core payloads have their own reviewed disposition path and leave a digest-linked tombstone. -There is deliberately no `gdpr_compliant_mode` or `maximum_evidence` switch — -every field is named individually, in plain English. +Encryption is on by default and turning it off keeps its own ceremony — +`config.deliberately_store_request_evidence_unencrypted!(because: "…")` — because +that one is a real hazard, not paperwork. -For IP geolocation, [`trackdown`](https://github.com/rameerez/trackdown) 0.4 or newer is the optional official resolver: +For IP geolocation, [`trackdown`](https://github.com/rameerez/trackdown) 0.4 or newer is the official resolver, and Clickwrap uses it automatically when your bundle has it and you named no resolver of your own. Set it explicitly when you want a different provider per policy, or when you are wiring Trackdown's per-request CDN trust: ```ruby Trackdown.configure do |trackdown| @@ -1237,8 +1256,11 @@ Clickwrap.configure do |config| controller.current_user == receipt.actor end - # Safe defaults: no IP address, browser user-agent, or IP geolocation is stored. - # Enable fields per policy, each with a plain-English purpose and retention rule. + # Nothing request-derived is stored until you say so. This one line says so: + # IP address, browser user agent, and a coarse country/region/city estimate, + # on every policy. Per-field flags and per-policy declarations still override + # it in either direction. + config.record_request_evidence_by_default = true # Optional hooks run only after evidence and domain state have committed: config.after_event_is_committed = ->(event) { } @@ -1340,7 +1362,7 @@ No — and `clickwrap` never equates scrolling with reading. It makes documents ### Should I record IP addresses? -Only for policies with a real, documented purpose. They corroborate request context; they don't prove identity or location. Everything defaults off. +We think yes, for assent policies — they are what turns "the row says yes" into "this sentence was accepted from this address, on this client, at this second". They corroborate request context; they do not prove identity or physical location. The gem's own default is still off, and turning them on is `config.record_request_evidence_by_default = true`. ### Can I keep my domain models? diff --git a/guides/integrating.md b/guides/integrating.md index 0153135..d03d250 100644 --- a/guides/integrating.md +++ b/guides/integrating.md @@ -560,8 +560,11 @@ history, not just post-migration. The shape that worked: ## 9. Request evidence, when a surface earns it -Default to collecting nothing; enable per policy, per field, when a surface -has a real purpose (our money path did; our signup did not): +The gem collects nothing until you say otherwise, and since 0.3.0 saying +otherwise for every policy at once is one line — +`config.record_request_evidence_by_default = true`. We took the narrower road +and enabled per policy, per field, where the surface earned it (our money path +did; our signup did not): ```ruby policy.record_ip_address( @@ -572,16 +575,19 @@ policy.record_ip_address( ) ``` -Enabling any IP field requires `config.trusted_proxy_configuration_digest` — -a digest of the effective proxy rules, not a prose label, so old evidence -records which configuration was in force. Generate it from Rails' configured -rules (or Rails' actual defaults when none were overridden): +`config.trusted_proxy_configuration_digest` is worth setting and is no longer +required to record an address — a digest of the effective proxy rules, not a +prose label, so old evidence records which configuration was in force. Generate +it from Rails' configured rules (or Rails' actual defaults when none were +overridden): ```ruby config.trusted_proxy_configuration_digest = Clickwrap.trusted_proxy_configuration_digest_for_rails_application ``` +Leave it unset and the annex records a `nil` digest, which honestly says nobody +reviewed a proxy topology; `clickwrap:doctor` warns while that is the case. This records configuration provenance; it does not prove the rules were correctly deployed or reviewed. Sharing the same fields across several policies? A plain Ruby lambda in `config/clickwrap.rb` calling diff --git a/guides/naming.md b/guides/naming.md index 08887ff..1976563 100644 --- a/guides/naming.md +++ b/guides/naming.md @@ -106,11 +106,19 @@ The same distinction runs through the six request-evidence states. `not_configur configuration fact; `unavailable` is an observation; `deleted_after_retention` is a third thing entirely. None of them is blank. -### 6. One option never secretly enables another category of data +### 6. One option never *secretly* enables another category of data -**Why:** this is the failure mode the gem exists to prevent. An option that turns on a category -of personal data as a side effect makes the diff unreviewable and the upgrade dangerous — a -later release can widen what the profile covers, and nobody reading the initializer would know. +**Why:** this is the failure mode the gem exists to prevent. An option whose name does not say +what it collects makes the diff unreviewable and the upgrade dangerous — a later release can +widen what the profile covers, and nobody reading the initializer would know. + +The test is the name, not the count. `config.record_request_evidence_by_default = true` does +enable three categories at once, and it is fine, because the line tells a reviewer what those +three are and the gem may never widen them: the switch is defined as the IP address, the +browser user agent, and a coarse country/region/city estimate, and a future release that +wanted to add coordinates would have to change its name. A `precision: :full` or a +`level: :enhanced` fails the test for the opposite reason — nobody reading it can say what +came back. ```ruby # Before @@ -249,7 +257,7 @@ banned for a specific reason, not for taste. |---|---| | `:network`, `:full`, `:enhanced`, `:forensic`, `:maximum` | They hide what will be collected behind a word that sounds like a quality level | | `record_location` | A developer could reasonably read it as GPS or physical location. It is neither | -| `request_evidence: :network`, `track_everything`, `record_everything` | Category switches. See rule 6 | +| `request_evidence: :network`, `track_everything`, `record_everything` | Their names do not name their contents. See rule 6, and contrast `record_request_evidence_by_default`, which does | | `maximum_evidence`, `full_evidence`, `legal_proof: true` | They imply a verdict the gem cannot reach, and they enable data as a side effect | | An opaque privacy-profile switch keyed to a regulation | No runtime flag can make a legal determination on anyone's behalf, and the name would be the least accurate string in the codebase | | `include_sensitive_context: true` | One flag turning on three categories of personal data makes an operator's intent unreviewable. Use `include_ip_address:`, `include_browser_user_agent:`, `include_ip_geolocation:` | diff --git a/guides/request-evidence.md b/guides/request-evidence.md index bbc05b9..1a14774 100644 --- a/guides/request-evidence.md +++ b/guides/request-evidence.md @@ -5,11 +5,25 @@ configured actor and authentication source, the policy and application version, request ID when one is available. None of that is derived from the person's network or browser. It records **nothing** about the request itself — no IP address, no browser user-agent, no -IP-geolocation field — unless the initializer or a policy names that exact field. There is no -category switch, no profile, and no option that turns one of these on as a side effect of -turning on something else. +IP-geolocation field — until the initializer or a policy says otherwise. Saying otherwise takes +one line: -That default is evidence design, not squeamishness. Three things follow from it: +```ruby +Clickwrap.configure do |config| + config.record_request_evidence_by_default = true +end +``` + +That switch records exactly what its name says and nothing else: the IP address, the browser +user agent, and a coarse country / region / city estimate. Every finer geolocation field — a +postal code, coordinates, a timezone, a continent, a metro code, an accuracy radius — remains +its own separately named line, and no option here turns a category on as a side effect of +turning on something else. There is still no profile switch and no name that hides its +contents (`gdpr_compliant_mode`, `maximum_evidence`, `legal_proof`); a switch that reads +`record_request_evidence_by_default` is the opposite of one. + +The gem's own default is still record-nothing, and that default is evidence design, not +squeamishness. Three things follow from it: - An IP address and other online identifiers can be personal data. The CJEU addressed dynamic IP addresses in [Breyer, Case C-582/14](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A62014CJ0582) @@ -230,7 +244,8 @@ they tell an auditor completely different things: Two places, and the policy always wins. -**In the initializer, for every policy.** Each field is its own setting, and each is `false`: +**In the initializer, for every policy.** Either the one switch, or the individual settings — +each field has its own, and each is `false`: ```ruby Clickwrap.configure do |config| @@ -242,10 +257,15 @@ Clickwrap.configure do |config| end ``` -Turning a default on without a purpose or without a deletion period is a `ConfigurationError` -at the end of the `configure` block, not a warning. Enabling any -`record_ip_geolocation_*_by_default` without an `ip_geolocation_resolver` is likewise a -`ConfigurationError` — there would be nothing to resolve them. +Everything below the first line there is optional. A purpose you do not write becomes +Clickwrap's own stated one (marked `gem_default` in the inventory); a deletion period you do +not set means the field keeps pace with the evidence it corroborates. + +Three things are still a `ConfigurationError` at the end of the `configure` block rather than a +warning: scaffolding text standing in for a purpose, a deletion clock set alongside +`keep_recorded_..._indefinitely!` for the same category, and enabling a +`record_ip_geolocation_*_by_default` field with no `ip_geolocation_resolver` configured *and* +no `trackdown` in the bundle — there would be nothing to resolve them with. **In one policy, for one flow.** This is the shape most applications want: ordinary signup inherits nothing, and the one consequential action opts in by name. @@ -293,13 +313,19 @@ end Every keyword there is doing work: - **`because:`** is the present purpose, in a sentence someone outside engineering can read. It - is stored, printed by `bin/rails clickwrap:privacy:inventory`, and required. + is stored and printed by `bin/rails clickwrap:privacy:inventory`. It is optional: a policy + that omits it records Clickwrap's own stated purpose instead, and the inventory marks that + entry `"purpose_source": "gem_default"` so nobody mistakes it for a sentence your team + reviewed. What is refused is scaffolding text — `"TODO: ask legal"` is not a purpose. - **`legal_basis_reference:`** and **`data_protection_impact_assessment_reference:`** are host-supplied pointers to your own reviewed documents. Clickwrap stores them. It does not read them, validate them, or endorse them. -- **`delete_after:`** or **`retain_until:`** is not optional. If a policy records a field and - neither the policy nor the configuration says when it goes away, capture raises a - `ConfigurationError` before the row is written. There is no keep-forever default in this gem. +- **`delete_after:`** and **`retain_until:`** are both optional. When neither the policy, its + retention class, nor the configuration names a schedule, the field keeps pace with the + evidence it corroborates — the same posture the core event has — and the annex is stamped + with no deadline at all, so the retention planner never lists it. Setting a clock alongside + an application-wide `keep_recorded_..._indefinitely!` for the same category is still refused: + those are opposite decisions. - **`fail_if_unavailable:`** (default `false`) decides whether evidence you cannot get is worse than no capture at all. When it is `true` and the field cannot be resolved, the capture and the protected action roll back together. @@ -359,6 +385,12 @@ So "we record IP addresses" is not one decision. It is four: later reader identify which rules were in force — the difference between corroborating evidence and a number with no recorded collection context. + Since 0.3.0 this step is not a precondition for recording an address. Leave it unset and the + annex stores `trusted_proxy_configuration_digest` as `nil`, which is the honest reading of + the situation: nobody recorded having reviewed a proxy topology when this address was + observed. `bin/rails clickwrap:doctor` warns while it stays that way. Hosts who complete + step 4 get the stronger record; hosts who do not get an address that says what it is worth. + If you replace the reader, you own that decision, and the receipt says so: any host-assigned lambda is labeled `host_configured_reader` rather than `rails_request_remote_ip`, even when the body is identical. Clickwrap will not claim Rails' spoof checks on your behalf. @@ -378,6 +410,18 @@ gem and must never become one. bundle add trackdown --version ">= 0.4" ``` +That is the whole wiring step. When your bundle carries trackdown and you have not named a +resolver of your own, Clickwrap uses `Clickwrap::IpGeolocation::TrackdownResolver` for any +policy that records IP geolocation — lazily, only at the moment something actually needs an +address resolved, and never as a collection decision on its own (nothing is resolved until a +policy has already enabled a geolocation field). `bin/rails clickwrap:privacy:inventory` reports +that resolver with `"source": "gem_default"`, and `clickwrap:doctor` names it, so an adopted +resolver never reads as a host decision. An installed trackdown older than 0.4 is not hidden +behind "the gem is missing": you get the adapter's own sentence about upgrading. + +Set it explicitly when you want a different provider per policy, or when you are wiring +Trackdown's per-request CDN trust: + ```ruby # clickwrap-doc-test: syntax-only — requires the optional trackdown gem installed above Trackdown.configure do |trackdown| diff --git a/lib/clickwrap/configuration.rb b/lib/clickwrap/configuration.rb index d1cad54..a81558d 100644 --- a/lib/clickwrap/configuration.rb +++ b/lib/clickwrap/configuration.rb @@ -21,14 +21,18 @@ module Clickwrap # - Every hook defaults to a no-op, so the gem works untouched and a host # wires hooks only as needed. # - Nothing here collects personal data by default. Every `record_*` flag - # starts false, and turning one on without a purpose and a retention - # decision is a configuration error, not a warning. + # starts false. Turning one on takes one line and nothing else; the + # purpose and the disposal answer have honest gem-supplied defaults, and + # a host who wants reviewed ones writes them. # # One setting deserves its own note: there is deliberately no # `gdpr_compliant_mode`, `maximum_evidence`, `full_evidence`, or - # `legal_proof`. An option that silently enables a category of personal data - # is exactly the thing this gem exists not to do, and no runtime flag can - # make a legal determination on your behalf. + # `legal_proof`. Those names hide what they collect and pretend to make a + # legal determination, which is exactly the thing this gem exists not to do. + # `record_request_evidence_by_default` is the opposite kind of switch: it + # says out loud what it records (an IP address, a browser user agent, and a + # coarse country/region/city estimate), it enables nothing finer, and it + # claims nothing about the law. class Configuration DOCUMENT_STORES = %i[database active_storage resolver].freeze DIGEST_ALGORITHMS = %i[sha256 sha384 sha512].freeze @@ -246,6 +250,8 @@ def initialize @ip_geolocation_resolver = nil @ip_geolocation_resolvers = {} + @automatically_adopted_ip_geolocation_resolver = nil + @considered_automatic_ip_geolocation_resolver = false @fail_capture_when_ip_geolocation_is_unavailable = false # The keyed annex digest carries a key ID so a host can rotate keys @@ -612,6 +618,49 @@ def represented_party_authority_adapter_names # --- Request-evidence setters -------------------------------------------- + # The one switch. + # + # config.record_request_evidence_by_default = true + # + # Records, on every policy: the IP address the request arrived from, the + # browser user agent it sent, and a coarse country/region/city estimate for + # that address. Nothing finer — a postal code, coordinates, a timezone, a + # metro code — and nothing else at all. The purpose and the disposal answer + # have honest gem defaults (see `Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE` + # and the keep-indefinitely posture below), so this line is genuinely the + # whole first step. + # + # It is a fan-out setter, not a mode: it writes the individual + # `record_*_by_default` flags, which means it composes with them in reading + # order. Write the switch first and a narrower flag after it to carve one + # field back out — + # + # config.record_request_evidence_by_default = true + # config.record_browser_user_agent_by_default = false + # + # — and any policy can still override all of it with `record_ip_address`, + # `do_not_record_ip_address`, and their siblings. Setting it to false turns + # the same three fields off and leaves the finer geolocation fields alone, + # because it never turned those on. + def record_request_evidence_by_default=(value) + enabled = ensure_boolean(value, "record_request_evidence_by_default") + + @record_ip_address_by_default = enabled + @record_browser_user_agent_by_default = enabled + Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.each do |field| + instance_variable_set(:"@record_ip_geolocation_#{field}_by_default", enabled) + end + end + + # Reads back what the switch describes rather than a remembered assignment: + # true when all three coarse fields are on, however they were turned on. + def record_request_evidence_by_default + record_ip_address_by_default && record_browser_user_agent_by_default && + Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.all? do |field| + public_send(:"record_ip_geolocation_#{field}_by_default") + end + end + def record_ip_address_by_default=(value) @record_ip_address_by_default = ensure_boolean(value, "record_ip_address_by_default") end @@ -715,11 +764,36 @@ def register_ip_geolocation_resolver(name, resolver) end def ip_geolocation_resolver_for(name = nil) - return ip_geolocation_resolver if name.blank? || name.to_s == "application_default" + return application_default_ip_geolocation_resolver if name.blank? || name.to_s == "application_default" @ip_geolocation_resolvers[name.to_s] end + # What a policy gets when it does not name a resolver: the host's own, or — + # when they never set one and their bundle already carries `trackdown` 0.4 + # or newer — the official adapter for it. That is the whole "trackdown plus + # Cloudflare just works" path, and it is deliberately not a silent + # collection decision: nothing calls this until a policy has already + # enabled an IP-geolocation field. + # + # An installed-but-too-old trackdown is NOT hidden here. The adapter's own + # sentence about upgrading is more useful than pretending the gem is + # missing. + def application_default_ip_geolocation_resolver + @ip_geolocation_resolver || automatically_adopted_ip_geolocation_resolver + end + + # The resolver actually in force, for anything that only wants to describe + # the configuration (the privacy inventory, `clickwrap:doctor`). Unlike the + # reader above it never adopts one as a side effect of being asked. + def ip_geolocation_resolver_in_force + @ip_geolocation_resolver || @automatically_adopted_ip_geolocation_resolver + end + + def ip_geolocation_resolver_was_adopted_automatically? + @ip_geolocation_resolver.nil? && !@automatically_adopted_ip_geolocation_resolver.nil? + end + def ip_geolocation_resolver_names = @ip_geolocation_resolvers.keys.sort.freeze # Plain-English key-rotation API. The ID is evidence and must stay stable; @@ -842,7 +916,6 @@ def resolve_retention_time(name, event) # caught the typos; these are the things that need the whole block resolved. def validate! validate_request_evidence_defaults! - validate_trusted_proxy_configuration! validate_ip_geolocation_resolver! true end @@ -870,6 +943,15 @@ def default_actor_reference(actor) Reference.record(actor) end + # Enabling a category by default needs nothing else. A purpose the host did + # not write falls back to Clickwrap's own stated one, and a disposal answer + # nobody gave means the corroboration keeps pace with the evidence it + # corroborates — which is what core evidence has done since 0.2.0. + # + # Two things still fail here, and both are the host contradicting + # themselves rather than merely leaving a blank: scaffolding text standing + # in for a purpose, and a deletion clock set alongside a declaration to + # keep the same category forever. def validate_request_evidence_defaults! { ip_address: [record_ip_address_by_default, @@ -884,37 +966,20 @@ def validate_request_evidence_defaults! }.each do |category, (enabled, reason, delete_after)| next unless enabled - if reason.to_s.strip.empty? - raise ConfigurationError, - "Clickwrap is set to record #{category} for every policy by default, but " \ - "`reason_for_recording_#{plural_for(category)}_by_default` is blank. Say in one " \ - "plain sentence why the application needs it. If only some policies need it, " \ - "turn the default off and enable it in those policies instead." - end - if ReviewedText.placeholder?(reason) raise ConfigurationError, "Clickwrap is set to record #{category} for every policy by default, but its " \ "reason is still scaffolding text (#{reason.inspect}). Replace it with the " \ - "application's reviewed, present-tense reason, or turn that default off." + "application's reviewed, present-tense reason, or delete the line and let " \ + "Clickwrap record its own stated purpose." end - if delete_after.present? && keeps_recorded_request_evidence_indefinitely?(category) - raise ConfigurationError, - "Clickwrap is told both to delete recorded #{category} after " \ - "#{delete_after.inspect} and to keep it indefinitely. Those are opposite " \ - "decisions — keep exactly one." - end - - next if delete_after.present? || keeps_recorded_request_evidence_indefinitely?(category) + next unless delete_after.present? && keeps_recorded_request_evidence_indefinitely?(category) raise ConfigurationError, - "Clickwrap is set to record #{category} for every policy by default, but nothing " \ - "says how long to keep it. Either set a reviewed period with " \ - "`delete_recorded_#{plural_for(category)}_after`, or keep it as long as the " \ - "evidence it corroborates with " \ - "`keep_recorded_#{plural_for(category)}_indefinitely!(because: \"…\")` — or turn " \ - "the default off and let each policy choose its own retention rule." + "Clickwrap is told both to delete recorded #{category} after " \ + "#{delete_after.inspect} and to keep it indefinitely. Those are opposite " \ + "decisions — keep exactly one." end end @@ -926,41 +991,27 @@ def plural_for(category) end end - def declare_indefinite_request_evidence!(category, because) - if because.to_s.strip.empty? - raise ConfigurationError, - "keep_recorded_#{plural_for(category)}_indefinitely! needs a `because:` " \ - "explaining the reviewed decision." - end - - @keep_recorded_request_evidence_indefinitely[category] = because + def host_reason_for_recording_by_default(category) + public_send(:"reason_for_recording_#{plural_for(category.to_sym)}_by_default").presence end - def validate_trusted_proxy_configuration! - records_ip_derived_evidence = - record_ip_address_by_default || enabled_default_ip_geolocation_fields.any? - return unless records_ip_derived_evidence - return if trusted_proxy_configuration_digest.present? - - raise ConfigurationError, - "Clickwrap is set to record an IP address or derive IP geolocation for every " \ - "policy, but `trusted_proxy_configuration_digest` is blank. Review and test the " \ - "deployment's trusted-proxy topology, digest that exact configuration, and set " \ - "the complete prefixed SHA-2 digest (for example `sha256:...`). This records which " \ - "proxy decision produced the address; it does not claim that decision was correct." + def declare_indefinite_request_evidence!(category, because) + @keep_recorded_request_evidence_indefinitely[category] = + because.presence || Vocabulary::DEFAULT_REASON_FOR_KEEPING_REQUEST_EVIDENCE_INDEFINITELY end def validate_ip_geolocation_resolver! - return if ip_geolocation_resolver return if enabled_default_ip_geolocation_fields.empty? && !fail_capture_when_ip_geolocation_is_unavailable + return if application_default_ip_geolocation_resolver if enabled_default_ip_geolocation_fields.any? raise ConfigurationError, "Clickwrap is set to record the IP-geolocation fields " \ "#{enabled_default_ip_geolocation_fields.join(", ")} but no " \ - "`ip_geolocation_resolver` is configured, so there is nothing to resolve them. " \ - "Set one (for example Clickwrap::IpGeolocation::TrackdownResolver.new) or turn " \ - "the fields off." + "`ip_geolocation_resolver` is configured and the `trackdown` gem is not " \ + "installed, so there is nothing to resolve them. Run " \ + "`bundle add trackdown --version \">= 0.4\"` and Clickwrap will use it, set " \ + "`config.ip_geolocation_resolver` to your own adapter, or turn the fields off." end raise ConfigurationError, @@ -968,6 +1019,22 @@ def validate_ip_geolocation_resolver! "`ip_geolocation_resolver` is configured, so every capture would fail." end + # Considered once, at the first moment something actually needs geolocation + # resolved, and remembered either way — including the "no trackdown here" + # answer, so a host without it does not pay for a failed `require` on every + # policy compile. + def automatically_adopted_ip_geolocation_resolver + return @automatically_adopted_ip_geolocation_resolver if @considered_automatic_ip_geolocation_resolver + + # Remembered only once the adapter has actually been built. An installed + # trackdown too old to use raises out of here, and a host who fixes their + # bundle and asks again must not be told the gem is missing because a + # failed attempt got memoized as "no". + resolver = (IpGeolocation::TrackdownResolver.new if IpGeolocation::TrackdownResolver.installed?) + @considered_automatic_ip_geolocation_resolver = true + @automatically_adopted_ip_geolocation_resolver = resolver + end + # --- Setter helpers ------------------------------------------------------- def ensure_callable(value, name) @@ -1118,24 +1185,25 @@ def ensure_positive_duration_or_nil(value, name) public - # The deliberate, named escape hatch referenced by `ensure_encryption_choice`. - # It exists so that turning encryption off is a sentence a reviewer can find - # in a diff, with the host's own reason attached, rather than a `false`. - # The named escape hatch for by-default request evidence with no deletion - # clock: request evidence exists to corroborate evidence that (since 0.2.0) - # keeps indefinitely by default, and a corroboration that expires before - # the thing it corroborates is a scheduled weakening of the record. Same - # rule as every escape hatch here: keeping forever must be a sentence a - # reviewer can find in a diff, with the host's own reason attached. - def keep_recorded_ip_addresses_indefinitely!(because:) + # Says out loud what an absent deletion clock already means: this category + # keeps pace with the evidence it corroborates. Request evidence exists to + # corroborate evidence that (since 0.2.0) keeps indefinitely by default, + # and a corroboration that expires before the thing it corroborates is a + # scheduled weakening of the record. + # + # Saying it explicitly is worth doing — it puts the decision and its reason + # in the initializer where a reviewer finds them — but since 0.3.0 it is no + # longer the price of admission, and `because:` is optional. What a host + # writes is kept as their own words; what they leave out gets Clickwrap's. + def keep_recorded_ip_addresses_indefinitely!(because: nil) declare_indefinite_request_evidence!(:ip_address, because) end - def keep_recorded_browser_user_agents_indefinitely!(because:) + def keep_recorded_browser_user_agents_indefinitely!(because: nil) declare_indefinite_request_evidence!(:browser_user_agent, because) end - def keep_recorded_ip_geolocation_indefinitely!(because:) + def keep_recorded_ip_geolocation_indefinitely!(because: nil) declare_indefinite_request_evidence!(:ip_geolocation, because) end @@ -1143,6 +1211,18 @@ def keeps_recorded_request_evidence_indefinitely?(category) @keep_recorded_request_evidence_indefinitely.key?(category.to_sym) end + # The purpose that will actually be recorded for a category enabled + # application-wide, and which of the two wrote it. The inventory reports + # both, so a reviewer can tell a sentence their team signed off on from the + # one the gem supplied. + def reason_for_recording_by_default(category) + host_reason_for_recording_by_default(category) || Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE + end + + def reason_for_recording_by_default_source(category) + host_reason_for_recording_by_default(category) ? "host" : "gem_default" + end + def reason_for_keeping_recorded_request_evidence_indefinitely(category) @keep_recorded_request_evidence_indefinitely[category.to_sym] end diff --git a/lib/clickwrap/doctor.rb b/lib/clickwrap/doctor.rb index 89d25d1..089aa3a 100644 --- a/lib/clickwrap/doctor.rb +++ b/lib/clickwrap/doctor.rb @@ -271,7 +271,8 @@ def default_review_date_findings end def resolver_findings - configured = Clickwrap.config.ip_geolocation_resolver + config = Clickwrap.config + configured = config.ip_geolocation_resolver_in_force wanted = Clickwrap.policies.values.select { |policy| policy.request_evidence.records_ip_geolocation? } if configured.nil? @@ -285,7 +286,11 @@ def resolver_findings "as unavailable")] end - [ok("an IP-geolocation resolver is configured (#{configured.class.name})")] + return [ok("an IP-geolocation resolver is configured (#{configured.class.name})")] unless + config.ip_geolocation_resolver_was_adopted_automatically? + + [ok("IP geolocation resolves through #{configured.class.name}, which Clickwrap adopted " \ + "because this application bundles trackdown and named no resolver of its own")] end # An IP address read from a forwarded header is only as good as the proxy diff --git a/lib/clickwrap/ip_geolocation/trackdown_resolver.rb b/lib/clickwrap/ip_geolocation/trackdown_resolver.rb index 7a6203a..c4b9b6c 100644 --- a/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +++ b/lib/clickwrap/ip_geolocation/trackdown_resolver.rb @@ -83,6 +83,22 @@ class TrackdownResolver < Resolver attr_reader :capabilities + # Whether the host's bundle carries `trackdown` at all. The configuration + # asks this before adopting the adapter for a policy that enabled + # IP-geolocation fields without naming a resolver, so the common case — + # trackdown plus Cloudflare, already bundled — needs no wiring line. + # + # It answers the narrow question it is named after and nothing else. An + # installed release older than 0.4 answers `true` here and then fails in + # the constructor with the sentence about upgrading, which is far more + # useful to that host than being told the gem is missing. + def self.installed? + require "trackdown" unless defined?(::Trackdown) + true + rescue ::LoadError + false + end + # Trust is per request in Trackdown 0.4. A host registers its verifier with # Trackdown, Trackdown runs it against the same request that supplied the # CDN headers, and this adapter copies the result's explicit trust state. diff --git a/lib/clickwrap/privacy.rb b/lib/clickwrap/privacy.rb index 1023cb5..11ed76f 100644 --- a/lib/clickwrap/privacy.rb +++ b/lib/clickwrap/privacy.rb @@ -125,7 +125,7 @@ def default_inventory "browser_user_agent" => default_category(config, :browser_user_agent), "ip_geolocation" => default_category(config, :ip_geolocation).merge( "fields" => config.enabled_default_ip_geolocation_fields, - "resolver" => describe_resolver(config.ip_geolocation_resolver), + "resolver" => describe_resolver(config), "fail_capture_when_unavailable" => config.fail_capture_when_ip_geolocation_is_unavailable ), "review_default_request_evidence_configuration_on" => @@ -138,14 +138,25 @@ def default_inventory } end + # `because` is the purpose that will actually be recorded, and + # `purpose_source` says who wrote it. A `"gem_default"` purpose is a real + # purpose and it is stored with the evidence — but nobody should mistake + # Clickwrap's own sentence for one the host's team reviewed, so the + # inventory never lets the two look alike. def default_category(config, category) + recorded = default_recorded?(config, category) + { - "recorded_by_default" => default_recorded?(config, category), - "because" => config.public_send(:"reason_for_recording_#{plural_for(category)}_by_default"), + "recorded_by_default" => recorded, + "because" => recorded ? config.reason_for_recording_by_default(category) : nil, + "purpose_source" => (config.reason_for_recording_by_default_source(category) if recorded), "legal_basis_reference" => config.public_send(:"legal_basis_reference_for_recording_#{plural_for(category)}_by_default"), "encrypted" => config.public_send(:"encrypt_recorded_#{plural_for(category)}"), - "delete_after_seconds" => config.public_send(:"delete_recorded_#{plural_for(category)}_after")&.to_i + "delete_after_seconds" => config.public_send(:"delete_recorded_#{plural_for(category)}_after")&.to_i, + "kept_indefinitely" => config.keeps_recorded_request_evidence_indefinitely?(category), + "reason_for_keeping_indefinitely" => + config.reason_for_keeping_recorded_request_evidence_indefinitely(category) } end @@ -186,6 +197,7 @@ def policy_category(policy, category, retention_class) entry = { "recorded" => setting.record?, "because" => setting.because, + "purpose_source" => policy.request_evidence.purpose_source_for(category), "legal_basis_reference" => setting.legal_basis_reference, "data_protection_impact_assessment_reference" => setting.data_protection_impact_assessment_reference, @@ -201,7 +213,7 @@ def policy_category(policy, category, retention_class) entry.merge( "fields" => policy.request_evidence.enabled_ip_geolocation_fields, "resolver_named_by_policy" => policy.request_evidence.ip_geolocation_resolver_name&.to_s, - "resolver" => describe_resolver(Clickwrap.config.ip_geolocation_resolver) + "resolver" => describe_resolver(Clickwrap.config) ) end @@ -283,10 +295,19 @@ def describe_callback(callable) } end - def describe_resolver(resolver) + # Reports the resolver in force, and whether the host named it or + # Clickwrap adopted the official trackdown adapter because their bundle + # already carried it. Asking never causes that adoption to happen — an + # inventory describes a configuration, it does not make one. + def describe_resolver(config) + resolver = config.ip_geolocation_resolver_in_force return { "configured" => false } if resolver.nil? - { "configured" => true, "class" => resolver.class.name } + { + "configured" => true, + "class" => resolver.class.name, + "source" => config.ip_geolocation_resolver_was_adopted_automatically? ? "gem_default" : "host" + } end def plural_for(category) diff --git a/lib/clickwrap/request_evidence_extractor.rb b/lib/clickwrap/request_evidence_extractor.rb index 6835499..e319a4c 100644 --- a/lib/clickwrap/request_evidence_extractor.rb +++ b/lib/clickwrap/request_evidence_extractor.rb @@ -244,7 +244,10 @@ def ip_address_attributes # Provenance is recorded even when the value is not, because "which reader # was asked, under which reviewed proxy configuration" is what tells a - # later reader how much the address is worth. + # later reader how much the address is worth. A nil digest is part of + # that answer rather than a hole in it: it says no reviewed proxy + # configuration was recorded when this address was observed, which is + # exactly what a reader should know about it. provenance = { ip_address_reader_name: ip_address_reader_name, trusted_proxy_configuration_digest: policy.trusted_proxy_configuration_digest @@ -484,13 +487,17 @@ def accuracy_radius_values(location) # --- Retention ------------------------------------------------------------ # Every recorded field leaves here with a disposal answer: a date, the name - # of a host rule that will produce one, or the explicit reviewed decision - # to keep it as long as the evidence it corroborates. Keeping forever is - # never a silent default — it is a named declaration in the retention class - # (`keep_recorded_..._indefinitely`) or the initializer - # (`keep_recorded_..._indefinitely!(because: "…")`) — and a recorded field - # with no answer at all is a configuration bug caught before the row is - # written rather than a row nobody ever decided about. + # of a host rule that will produce one, or no schedule at all — which means + # it keeps pace with the evidence it corroborates, exactly like an + # indefinite core event. A blank schedule is the answer, not a gap: the + # planner never lists these rows, and the receipt reports them as recorded + # rather than as anything pending. + # + # Until 0.3.0 this raised when nothing had named a schedule, on the theory + # that keeping forever must never be silent. The theory held; the cost did + # not. It refused captures for hosts who had simply not written a sentence, + # and pushed integrators toward recording nothing — which is worse evidence + # than evidence kept under the same posture as the agreement it belongs to. # # `retain_until` names a host calculation instead of a duration because real # record-keeping schedules are not always durations — "five years, or three @@ -504,15 +511,7 @@ def retention_attributes(category, setting) return { "#{category}_delete_after": now + class_rule.duration } if class_rule&.duration? return { "#{category}_retain_until_rule": class_rule.host_event_name.to_s } if class_rule&.host_event? - # Indefinite — declared in the class or application-wide — stamps - # nothing: the blank schedule plus the recorded declaration IS the - # disposal answer, exactly like an indefinite core event. - if class_rule&.indefinite? || - Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category) - return {} - end - - raise ConfigurationError, missing_retention_message(category) + {} end def retention_class_rule_for(category) @@ -521,16 +520,6 @@ def retention_class_rule_for(category) Clickwrap.retention_class!(policy.retention_class_key).rule_for(category) end - def missing_retention_message(category) - "Clickwrap is about to record #{category} for policy #{policy_key} and nothing says what " \ - "should ever happen to it. Give the policy a rule — `delete_after:` with a reviewed " \ - "period, or `retain_until:` naming a host retention calculation — add a #{category} " \ - "rule (or `keep_recorded_#{category}_indefinitely`) to retention class " \ - "#{policy.retention_class_key.inspect}, or answer it application-wide with " \ - "`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never silent, " \ - "and Clickwrap will not choose for you." - end - # --- Failing closed ------------------------------------------------------- # A policy can decide that evidence it cannot get is worse than no capture diff --git a/lib/clickwrap/request_evidence_policy.rb b/lib/clickwrap/request_evidence_policy.rb index dc4bb36..a3d29b4 100644 --- a/lib/clickwrap/request_evidence_policy.rb +++ b/lib/clickwrap/request_evidence_policy.rb @@ -8,9 +8,15 @@ module Clickwrap # not squeamishness about useful data: it is that high-quality evidence is # purpose-specific. An IP address is personal data, and keeping it on your own # infrastructure does not remove the duty to have a reason for it, protect it, - # and stop keeping it eventually. So each field is enabled by name, with a - # plain-English purpose and a retention decision attached, and the policy that - # enables it is the server's, never the browser's. + # and stop keeping it eventually. So each field is enabled by name, and the + # policy that enables it is the server's, never the browser's. + # + # Naming a field is the whole requirement. Every recorded category still + # leaves here carrying a purpose and a disposal posture, because a snapshot + # read years from now has to answer both questions — but since 0.3.0 those + # answers have honest gem-supplied defaults instead of being the entry fee. A + # host who writes their own keeps their own words, and the privacy inventory + # reports which of the two is looking back at you. # # None of these fields is identity or physical location. An IP address is a # network observation. IP geolocation is a provider's estimate about that @@ -61,9 +67,11 @@ def initialize(policy_key:, retention_class_key: nil, ip_address: nil, trusted_proxy_configuration_digest: nil, review_configuration_on: nil) @policy_key = policy_key @retention_class_key = retention_class_key&.to_s + @purpose_sources = {} @ip_address = normalized_setting(:ip_address, ip_address || NOT_RECORDED) @browser_user_agent = normalized_setting(:browser_user_agent, browser_user_agent || NOT_RECORDED) @ip_geolocation = normalized_setting(:ip_geolocation, ip_geolocation || NOT_RECORDED) + @purpose_sources.freeze @ip_geolocation_fields = normalize_geolocation_fields(ip_geolocation_fields) @ip_geolocation_resolver_name = ip_geolocation_resolver_name&.to_sym @trusted_proxy_configuration_digest = trusted_proxy_configuration_digest&.to_s @@ -87,6 +95,15 @@ def enabled_ip_geolocation_fields def records_anything? = records_ip_address? || records_browser_user_agent? || records_ip_geolocation? + # Who wrote the purpose stored for this category: `"host"` when the policy + # or the initializer supplied one, `"gem_default"` when Clickwrap filled in + # its own. Deliberately kept off `to_snapshot`: the snapshot is a released + # evidence format, and the answer is derivable from the configuration a + # reader already has. + def purpose_source_for(category) + @purpose_sources[category.to_s] + end + def setting_for(category) case category.to_sym when :ip_address then ip_address @@ -132,7 +149,6 @@ def validate! FIELD_CATEGORIES.each { |category| validate_category!(category) } validate_geolocation_coherence! validate_named_resolver! - validate_trusted_proxy_configuration_digest! end def normalized_setting(category, setting) @@ -153,7 +169,16 @@ def normalized_setting(category, setting) "`config.encrypt_recorded_*` setting, or omit `encrypted:` to inherit it." end - Setting.new(**setting.to_h, encrypted: configured) + # The compiled revision always carries a purpose, so a reader years from + # now never finds a recorded field with nothing beside it saying what it + # was for. Whose sentence it is gets remembered separately. + @purpose_sources[category.to_s] = setting.because.presence ? "host" : "gem_default" + + Setting.new( + **setting.to_h, + encrypted: configured, + because: setting.because.presence || Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE + ) end def validate_named_resolver! @@ -164,8 +189,9 @@ def validate_named_resolver! raise DefinitionError, "Policy #{policy_key} records IP geolocation using resolver " \ "#{ip_geolocation_resolver_name.inspect}, but no resolver is registered under " \ - "that name. Configure `config.ip_geolocation_resolver` for " \ - "`:application_default`, or register the named resolver with " \ + "that name. Bundle `trackdown` (>= 0.4) and Clickwrap uses it for " \ + "`:application_default` with no wiring line at all, or set " \ + "`config.ip_geolocation_resolver` yourself, or register the named resolver with " \ "`config.register_ip_geolocation_resolver`. Registered resolvers: " \ "#{Clickwrap.config.ip_geolocation_resolver_names.join(", ").presence || "(none)"}." end @@ -186,34 +212,21 @@ def validate_named_resolver! "capabilities: #{error.message}" end + # A missing purpose and a missing disposal answer both have defaults now. + # What is left refuses only the two things a default cannot honestly stand + # in for: scaffolding text the host actually wrote, and a deletion clock + # that is not a period. def validate_category!(category) setting = setting_for(category) return unless setting.record? - if setting.because.to_s.strip.empty? - raise DefinitionError, - "Policy #{policy_key} records #{category} but gives no `because:`. Say in one " \ - "plain sentence why this policy needs it right now. \"We might need it someday\" " \ - "is not a purpose, and a privacy notice mentioning the field is not one either." - end - if ReviewedText.placeholder?(setting.because) raise DefinitionError, "Policy #{policy_key} records #{category}, but its `because:` is still " \ "scaffolding text (#{setting.because.inspect}). Replace it with the " \ - "application's reviewed, present-tense reason; Clickwrap never treats a TODO " \ - "as a data-collection purpose." - end - - if setting.delete_after.nil? && setting.retain_until.nil? && retention_class_key.nil? && - !Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category) - raise DefinitionError, - "Policy #{policy_key} records #{category} but nothing says what should ever " \ - "happen to it. Give it `delete_after:` with a duration, or `retain_until:` " \ - "naming a host event rule, attach a retention class with a rule for this " \ - "category, or answer it application-wide with " \ - "`keep_recorded_..._indefinitely!(because: \"…\")`. Keeping forever is never " \ - "silent, and Clickwrap will not choose for you." + "application's reviewed, present-tense reason, or drop the option and let " \ + "Clickwrap record its own stated purpose; a TODO is never a data-collection " \ + "purpose." end return unless setting.delete_after && setting.delete_after.to_i <= 0 @@ -248,17 +261,5 @@ def validate_geolocation_coherence! "Policy #{policy_key} enables the IP-geolocation fields #{enabled.join(", ")} " \ "without recording IP geolocation." end - - def validate_trusted_proxy_configuration_digest! - return unless records_ip_address? || records_ip_geolocation? - return unless trusted_proxy_configuration_digest.to_s.strip.empty? - - raise DefinitionError, - "Policy #{policy_key} records an IP address or derives IP geolocation, but " \ - "`config.trusted_proxy_configuration_digest` is blank. Review the deployment's " \ - "trusted-proxy topology, digest that reviewed configuration, and set the prefixed " \ - "digest (for example `sha256:...`). This records which proxy decision produced the " \ - "address; it does not claim that the decision was correct." - end end end diff --git a/lib/clickwrap/services/validate_policy_references.rb b/lib/clickwrap/services/validate_policy_references.rb index a31fabb..409dc44 100644 --- a/lib/clickwrap/services/validate_policy_references.rb +++ b/lib/clickwrap/services/validate_policy_references.rb @@ -15,7 +15,6 @@ def call Clickwrap.policies.each do |policy| validate_documents!(policy) retention_class = validate_retention_class!(policy) - validate_request_evidence_retention!(policy, retention_class) validate_host_calculations!(policy, retention_class) validate_authority_adapter!(policy) validate_ip_geolocation_resolver!(policy) @@ -105,37 +104,13 @@ def validate_retention_class!(policy) ) end - def validate_request_evidence_retention!(policy, retention_class) - RequestEvidencePolicy::FIELD_CATEGORIES.each do |category| - setting = policy.request_evidence.setting_for(category) - next unless setting.record? - next if setting.delete_after || setting.retain_until || retention_class.rule_for(category) - # The application-wide answer counts too: recording enabled in the - # initializer carries its disposal decision in the same place — - # either a global clock or the explicit, reasoned keep-indefinitely. - next if config_answers_disposal_for?(category) - - raise DefinitionError, - "Policy #{policy.key} records #{category}, but nothing says when to dispose of " \ - "it. Add `delete_after:`/`retain_until:` to the policy, a plain-English " \ - "request-evidence rule (or `keep_recorded_#{category}_indefinitely`) to " \ - "retention class #{retention_class.key}, or answer it application-wide in the " \ - "initializer with `delete_recorded_..._after` or " \ - "`keep_recorded_..._indefinitely!(because: \"…\")`." - end - end - - def config_answers_disposal_for?(category) - clock = - case category.to_sym - when :ip_address then Clickwrap.config.delete_recorded_ip_addresses_after - when :browser_user_agent then Clickwrap.config.delete_recorded_browser_user_agents_after - else Clickwrap.config.delete_recorded_ip_geolocation_after - end - - clock.present? || Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(category) - end - + # There is deliberately no disposal check here any more. A recorded + # category with no clock anywhere — not on the policy, not in its + # retention class, not in the initializer — means it keeps pace with the + # evidence it corroborates, which is what the core event does by default + # too. What still fails below is a rule that names a calculation nobody + # registered: a deadline waiting on a typo never arrives, and that is a + # different thing from a deliberate absence of one. def validate_host_calculations!(policy, retention_class) referenced = retention_class.rules.values.filter_map do |rule| rule.host_event_name&.to_sym diff --git a/lib/clickwrap/version.rb b/lib/clickwrap/version.rb index b0ec76a..7ab45f8 100644 --- a/lib/clickwrap/version.rb +++ b/lib/clickwrap/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Clickwrap - VERSION = "0.2.1" + VERSION = "0.3.0" # The canonical schema version for receipts, event digests, and presentation # manifests. This is deliberately independent of VERSION: gem releases may diff --git a/lib/clickwrap/vocabulary.rb b/lib/clickwrap/vocabulary.rb index adfab1f..44f2616 100644 --- a/lib/clickwrap/vocabulary.rb +++ b/lib/clickwrap/vocabulary.rb @@ -160,6 +160,33 @@ module Vocabulary accuracy_radius_in_kilometers ].freeze + # The three coarse fields `config.record_request_evidence_by_default = true` + # turns on. Coarse means administrative area, not a point: a country, a + # region, and a city are what a provider can estimate from an address with + # any confidence at all. Everything finer — a postal code, coordinates, a + # timezone, a metro code — stays its own separately named decision, because + # a switch that reads "record request evidence" should not hand somebody + # coordinates they never asked for. + COARSE_IP_GEOLOCATION_DATA_FIELDS = %w[country region city].freeze + + # The purpose Clickwrap records when a host enables request evidence + # without writing a purpose of their own. It is the gem's own sentence, not + # a reviewed host decision, and the privacy inventory says which of the two + # it is looking at. It exists because the alternative — refusing to boot + # until somebody writes a sentence — was pushing integrators to record + # nothing at all, and no corroboration is worse evidence than corroboration + # collected under the gem's stated purpose. + DEFAULT_REQUEST_EVIDENCE_PURPOSE = + "Corroborate who performed each recorded act, from where, on what client — to defend " \ + "the recorded agreement itself." + + # The reason recorded when a host keeps request evidence indefinitely + # without writing their own. Same posture as the purpose above: the + # declaration is still recorded and still readable years later; only the + # obligation to phrase it yourself is gone. + DEFAULT_REASON_FOR_KEEPING_REQUEST_EVIDENCE_INDEFINITELY = + "Corroboration lives as long as the evidence it corroborates" + # Provenance that travels with any stored IP-geolocation result. A policy # cannot keep provider-derived coordinates while stripping the uncertainty # needed to interpret them. diff --git a/lib/generators/clickwrap/install_generator.rb b/lib/generators/clickwrap/install_generator.rb index 9174342..b09dbf9 100644 --- a/lib/generators/clickwrap/install_generator.rb +++ b/lib/generators/clickwrap/install_generator.rb @@ -28,9 +28,13 @@ module Generators # spelling of the off-by-default posture. It disappears after generation; # it is deliberately not a runtime concept. There is no # `record_network_context`, `record_everything`, `full_evidence`, or - # regulation-named mode switch anywhere in this gem, because a flag that - # enables a whole category of personal data is the thing this gem exists not - # to do, and no flag can make a legal determination on a host's behalf. + # regulation-named mode switch anywhere in this gem, because a flag whose + # name hides what it collects is the thing this gem exists not to ship, and + # no flag can make a legal determination on a host's behalf. The runtime's + # one grouped switch, `config.record_request_evidence_by_default`, is not + # one of those: it names its three fields and can never grow a fourth. The + # installer still asks about them one at a time, because a purpose the host + # wrote beats the honest default the gem would otherwise record. class InstallGenerator < Rails::Generators::Base include ActiveRecord::Generators::Migration diff --git a/lib/generators/clickwrap/templates/initializer.rb.erb b/lib/generators/clickwrap/templates/initializer.rb.erb index ffc555b..46d03b8 100644 --- a/lib/generators/clickwrap/templates/initializer.rb.erb +++ b/lib/generators/clickwrap/templates/initializer.rb.erb @@ -28,8 +28,11 @@ # # There is deliberately no `maximum_evidence`, `full_evidence`, `legal_proof`, # or regulation-named mode setting. No runtime flag can make a legal -# determination for you, and an option that turns on a category of personal data -# as a side effect of something else is exactly what this gem exists not to do. +# determination for you, and an option whose name does not say what it collects +# is exactly what this gem exists not to ship. The one grouped switch that does +# exist, `config.record_request_evidence_by_default`, names its own contents: +# an IP address, a browser user agent, and a coarse country/region/city +# estimate — nothing finer, ever. <%- if recipe -%> # # This file was scaffolded with `--request-evidence-recipe=<%= recipe %>`. The @@ -251,10 +254,12 @@ Clickwrap.configure do |config| # proof that anyone was physically anywhere. <%- if records_any_request_evidence? -%> # - # You enabled some of these during install. The generator required an explicit - # purpose and deletion period before writing this file; review both below. The - # annex table itself came with `--with-request-evidence`, which this install - # added for you. + # You enabled some of these during install, and the installer asked for an + # explicit purpose and deletion period before writing this file; review both + # below. The gem itself would have accepted the fields without them and + # recorded its own stated purpose instead — the installer asks because a + # sentence your team wrote is worth more than one the gem supplied. The annex + # table came with `--with-request-evidence`, which this install added for you. <%- else -%> # # Everything here is off, which is the safe default — and the annex table was @@ -272,12 +277,15 @@ Clickwrap.configure do |config| # --- Request evidence: why, how long, and how it is protected --------------- # - # An enabled field with a blank purpose or no deletion rule is a configuration - # error at boot, not a warning. "We turned it on and never decided why" is the - # failure mode these settings exist to prevent. There is no keep-forever - # default anywhere in this gem, and deleting a value removes the encrypted - # annex entry and appends a disposition event — the historical agreement, - # declaration, or authorization stays intact and verifiable without it. + # Both of these are optional, and the installer wrote yours because you gave + # them. A field enabled with no purpose records the one Clickwrap states, and + # the privacy inventory marks it as the gem's sentence rather than yours; a + # field with no deletion period keeps pace with the evidence it corroborates. + # What is refused is a purpose that is still scaffolding text, and a deletion + # period set alongside `keep_recorded_..._indefinitely!` for the same field. + # Deleting a value removes the encrypted annex entry and appends a disposition + # event — the historical agreement, declaration, or authorization stays intact + # and verifiable without it. <%- if record_ip_addresses? -%> config.reason_for_recording_ip_addresses_by_default = @@ -301,10 +309,10 @@ Clickwrap.configure do |config| config.review_default_request_evidence_configuration_on = <%= review_date_literal %> <%- else -%> # Nothing above is enabled, so there is nothing to give a purpose or a - # deletion period to. Enabling a field is never one line — the gem refuses an - # enabled field with no recorded purpose and no deletion rule — and it wants a - # review date so the decision gets looked at again by a person instead of - # outliving its reason: + # deletion period to. Turning the ordinary three on later is one line — + # `config.record_request_evidence_by_default = true` — and these are the + # settings that upgrade it into a record your own team reviewed, plus a date + # so the decision gets looked at again instead of outliving its reason: # # config.reason_for_recording_ip_addresses_by_default = "Investigate disputed submissions" # config.delete_recorded_ip_addresses_after = 90.days @@ -359,7 +367,9 @@ Clickwrap.configure do |config| Clickwrap.trusted_proxy_configuration_digest_for_rails_application <%- else -%> # A digest of the effective trusted-proxy rules, stored beside any recorded - # address. Nothing here records one, so there is nothing to stamp: + # address. Nothing here records one, so there is nothing to stamp. Set it if + # you enable IP recording later: it is not required, but without it every + # recorded address carries a blank where its collection context should be. # # config.trusted_proxy_configuration_digest = # Clickwrap.trusted_proxy_configuration_digest_for_rails_application @@ -368,8 +378,9 @@ Clickwrap.configure do |config| <%- if any_ip_geolocation_field? -%> # DECISION — you enabled IP-geolocation fields and explicitly selected this # resolver. Make its gem and data source available in every environment: - # Clickwrap refuses to boot with fields it has nothing to resolve, rather than - # recording blanks that later read as "no result". + # Clickwrap refuses to boot with fields nothing can resolve — no resolver here + # and no trackdown in the bundle — rather than recording blanks that later + # read as "no result". # # Only the fields authorized above are stored, never the whole result object, # and every stored value keeps the provider, source, estimated state, @@ -378,11 +389,18 @@ Clickwrap.configure do |config| # needed to read them. config.ip_geolocation_resolver = <%= ip_geolocation_resolver_class_name %>.new <%- else -%> - # Required before any IP-geolocation field can be enabled, here or in a - # policy. The official optional resolver requires trackdown >= 0.4 so the - # provider that actually answered and per-request source trust are preserved: + # Something has to resolve an IP-geolocation field before it can be enabled, + # here or in a policy. Bundling trackdown is enough — Clickwrap uses the + # official adapter for you when you have not named a resolver of your own, + # and the privacy inventory reports it as the gem's choice rather than yours. + # It requires trackdown >= 0.4 so the provider that actually answered and + # per-request source trust are preserved: # # bundle add trackdown --version ">= 0.4" + # + # Name it explicitly to pick a different provider, or when you are wiring + # Trackdown's per-request CDN trust: + # # config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new <%- end -%> diff --git a/test/configuration_test.rb b/test/configuration_test.rb index dd38851..bdff105 100644 --- a/test/configuration_test.rb +++ b/test/configuration_test.rb @@ -52,6 +52,7 @@ class ConfigurationTest < ActiveSupport::TestCase test "nothing personal is collected by default" do config = Clickwrap::Configuration.new + assert_not config.record_request_evidence_by_default assert_not config.record_ip_address_by_default assert_not config.record_browser_user_agent_by_default assert_empty config.enabled_default_ip_geolocation_fields @@ -155,24 +156,33 @@ class ConfigurationTest < ActiveSupport::TestCase assert_match(/must be true or false/, error.message) end - test "IP-derived application defaults require reviewed proxy provenance" do - error = assert_raises(Clickwrap::ConfigurationError) do - Clickwrap.configure do |config| - config.trusted_proxy_configuration_digest = nil - config.record_ip_address_by_default = true - config.reason_for_recording_ip_addresses_by_default = "Investigate disputed submissions" - config.delete_recorded_ip_addresses_after = 30.days - end + test "IP-derived application defaults record without reviewed proxy provenance" do + # Until 0.3.0 this combination refused to boot. It now records the annex + # with a nil digest, which is itself honest provenance: nobody reviewed a + # proxy topology, and the receipt does not pretend otherwise. `doctor` + # still says so out loud (see doctor_test). + Clickwrap.configure do |config| + config.trusted_proxy_configuration_digest = nil + config.record_ip_address_by_default = true + config.reason_for_recording_ip_addresses_by_default = "Investigate disputed submissions" + config.delete_recorded_ip_addresses_after = 30.days end - assert_match(/trusted_proxy_configuration_digest/, error.message) - assert_match(/does not claim that decision was correct/, error.message) + assert Clickwrap.config.validate! + assert_nil Clickwrap.config.trusted_proxy_configuration_digest + # Hosts who do review one still get the stronger record, and the setter + # still refuses anything that is not a complete prefixed digest. Clickwrap.configure do |config| config.trusted_proxy_configuration_digest = Clickwrap.trusted_proxy_configuration_digest_for([IPAddr.new("10.0.0.0/8")]) end - assert Clickwrap.config.validate! + assert Clickwrap::Digest.well_formed?(Clickwrap.config.trusted_proxy_configuration_digest) + + error = assert_raises(Clickwrap::ConfigurationError) do + Clickwrap.config.trusted_proxy_configuration_digest = "TODO: ask infrastructure" + end + assert_match(/complete prefixed SHA-2 digest/, error.message) end test "trusted proxy provenance digests the effective rules rather than prose" do @@ -320,14 +330,20 @@ def initialize(id) assert_match(/storage layer/, Clickwrap.config.reason_for_storing_request_evidence_unencrypted) end - test "there is no runtime compliance or profile switch" do - # Deliberately absent. An option that enables a category of personal data as - # a side effect of something else is the thing this gem exists not to do. + test "there is no switch whose name hides what it collects" do + # Deliberately absent. Each of these names either claims a legal outcome no + # runtime flag can deliver, or describes its collection so vaguely that a + # reader of the initializer cannot tell what it turns on. %i[gdpr_compliant_mode maximum_evidence full_evidence legal_proof record_network_context track_everything record_location].each do |name| assert_not Clickwrap.config.respond_to?(:"#{name}="), "#{name} must not exist" assert_not Clickwrap.config.respond_to?(name), "#{name} must not exist" end + + # The one switch that does exist names its own contents, and the reader + # reports what is actually on rather than a remembered assignment. + assert_respond_to Clickwrap.config, :record_request_evidence_by_default= + assert_not Clickwrap.config.record_request_evidence_by_default end test "every IP-geolocation field has its own separately named setting" do diff --git a/test/doctor_test.rb b/test/doctor_test.rb index 18f3b99..81772a1 100644 --- a/test/doctor_test.rb +++ b/test/doctor_test.rb @@ -103,11 +103,24 @@ class DoctorTest < ActiveSupport::TestCase end end + test "a resolver Clickwrap adopted says so, rather than reading as a host decision" do + Clickwrap.config.ip_geolocation_resolver = nil + adopted = Clickwrap::IpGeolocation::StaticResolver.new + Clickwrap.config.stubs(:ip_geolocation_resolver_in_force).returns(adopted) + Clickwrap.config.stubs(:ip_geolocation_resolver_was_adopted_automatically?).returns(true) + + findings = Clickwrap::Doctor.new.report + + assert ok?(findings, /Clickwrap adopted/) + assert_match(/bundles trackdown and named no resolver of its own/, + message_for(findings, /Clickwrap adopted/)) + end + test "recording IP addresses with no reviewed proxy configuration is a warning" do - # Normal `Clickwrap.configure` now refuses this incomplete state. Mutating - # individual setters can still create it temporarily (for example in a - # console or during a staged initializer migration), so Doctor continues to - # diagnose it instead of assuming only boot-valid states are observable. + # Since 0.3.0 this boots: a nil digest is recorded as the honest absence of + # reviewed proxy provenance rather than refused. Doctor is where the nudge + # lives now — a warning an operator can read and act on, not a wall between + # an integrator and any evidence at all. Clickwrap.config.trusted_proxy_configuration_digest = nil Clickwrap.config.record_ip_address_by_default = true Clickwrap.config.reason_for_recording_ip_addresses_by_default = "Investigate account compromise" diff --git a/test/privacy_test.rb b/test/privacy_test.rb index b9bd380..71c617d 100644 --- a/test/privacy_test.rb +++ b/test/privacy_test.rb @@ -36,6 +36,7 @@ class PrivacyTest < ActiveSupport::TestCase ip_address = regulated["fields"]["ip_address"] assert ip_address["recorded"] assert_equal "Investigate account compromise and disputes about this action", ip_address["because"] + assert_equal "host", ip_address["purpose_source"] assert_equal "DUMMY-LIA-SECURITY-2026-01", ip_address["legal_basis_reference"] assert_equal "security_evidence_retention_ends", ip_address["retain_until_rule"] assert ip_address["encrypted"] @@ -70,6 +71,50 @@ class PrivacyTest < ActiveSupport::TestCase .dig("rules", "core_event", "seconds") end + test "a purpose Clickwrap supplied is never printed as a reviewed host decision" do + Clickwrap.configure { |config| config.record_request_evidence_by_default = true } + Clickwrap.policy(:switched_on_policy) { agree_to :terms } + + inventory = Clickwrap::Privacy.inventory + defaults = inventory.dig("defaults", "ip_address") + + assert defaults["recorded_by_default"] + assert_equal Clickwrap::Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE, defaults["because"] + assert_equal "gem_default", defaults["purpose_source"] + + policy = policy_entry(inventory, "switched_on_policy")["fields"]["ip_address"] + assert_equal Clickwrap::Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE, policy["because"] + assert_equal "gem_default", policy["purpose_source"] + + # And the same field, once the host writes their own sentence. + Clickwrap.config.reason_for_recording_ip_addresses_by_default = "Investigate disputed submissions" + reviewed = Clickwrap::Privacy.inventory.dig("defaults", "ip_address") + + assert_equal "Investigate disputed submissions", reviewed["because"] + assert_equal "host", reviewed["purpose_source"] + end + + test "the inventory reads back an indefinite request-evidence decision as a decision" do + Clickwrap.configure do |config| + config.record_ip_address_by_default = true + config.keep_recorded_ip_addresses_indefinitely!(because: "It outlives the dispute window") + end + + defaults = Clickwrap::Privacy.inventory.dig("defaults", "ip_address") + + assert defaults["kept_indefinitely"] + assert_equal "It outlives the dispute window", defaults["reason_for_keeping_indefinitely"] + assert_nil defaults["delete_after_seconds"] + end + + test "a category nobody records prints no purpose at all" do + defaults = Clickwrap::Privacy.inventory.dig("defaults", "browser_user_agent") + + assert_not defaults["recorded_by_default"] + assert_nil defaults["because"], "an unrecorded field has no purpose to state" + assert_nil defaults["purpose_source"] + end + test "the inventory reports a policy whose review date has already passed" do # The date belongs to the host; the inventory states it rather than judging # it. What makes the fact actionable is that it is printed at all — the diff --git a/test/request_evidence_keep_indefinitely_test.rb b/test/request_evidence_keep_indefinitely_test.rb index 3329525..81ea4e4 100644 --- a/test/request_evidence_keep_indefinitely_test.rb +++ b/test/request_evidence_keep_indefinitely_test.rb @@ -5,8 +5,9 @@ # By-default request evidence used to demand a deletion clock — which, since # 0.2.0 flipped core evidence to keep-indefinitely, meant the corroboration # (IP, user agent, geolocation) was scheduled to expire before the agreement -# it corroborates. These pin the third option: keeping it as long as the -# evidence itself, said out loud with a reason, never silently. +# it corroborates. 0.3.0 made keeping pace the answer nobody has to write +# down: an absent clock means indefinite, and saying it out loud is still +# worth doing but no longer the price of recording anything at all. class RequestEvidenceKeepIndefinitelyTest < ActiveSupport::TestCase def enable_ip_defaults!(config) config.trusted_proxy_configuration_digest = @@ -28,15 +29,13 @@ def enable_ip_defaults!(config) assert_nil Clickwrap.config.delete_recorded_ip_addresses_after end - test "recording by default with neither a clock nor keep-indefinitely refuses to boot" do - error = assert_raises(Clickwrap::ConfigurationError) do - Clickwrap.configure { |config| enable_ip_defaults!(config) } - Clickwrap.config.validate! - end + test "recording by default with neither a clock nor keep-indefinitely boots, and means indefinite" do + Clickwrap.configure { |config| enable_ip_defaults!(config) } - assert_match(/nothing says how long to keep it/, error.message) - assert_match(/keep_recorded_ip_addresses_indefinitely!/, error.message) - assert_match(/delete_recorded_ip_addresses_after/, error.message) + assert Clickwrap.config.validate! + assert_nil Clickwrap.config.delete_recorded_ip_addresses_after + assert_not Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(:ip_address), + "nobody declared it, so there is no declaration to read back" end test "a deletion clock and keep-indefinitely together are refused as opposite decisions" do @@ -52,15 +51,23 @@ def enable_ip_defaults!(config) assert_match(/opposite decisions/, error.message) end - test "keep-indefinitely without a reason is refused" do - error = assert_raises(Clickwrap::ConfigurationError) do - Clickwrap.configure do |config| - config.keep_recorded_browser_user_agents_indefinitely!(because: " ") - end + test "keep-indefinitely without a reason records the one Clickwrap states" do + Clickwrap.configure do |config| + config.keep_recorded_browser_user_agents_indefinitely! + config.keep_recorded_ip_geolocation_indefinitely!(because: "Our own reviewed sentence") end - assert_match(/needs a `because:`/, error.message) + assert Clickwrap.config.keeps_recorded_request_evidence_indefinitely?(:browser_user_agent) + assert_equal Clickwrap::Vocabulary::DEFAULT_REASON_FOR_KEEPING_REQUEST_EVIDENCE_INDEFINITELY, + Clickwrap.config.reason_for_keeping_recorded_request_evidence_indefinitely( + :browser_user_agent + ) + assert_equal "Our own reviewed sentence", + Clickwrap.config.reason_for_keeping_recorded_request_evidence_indefinitely( + :ip_geolocation + ) end + test "by-default recording under keep-indefinitely compiles, captures, and is never planned" do Clickwrap.configure do |config| enable_ip_defaults!(config) diff --git a/test/request_evidence_test.rb b/test/request_evidence_test.rb index 23fd5ef..e2c3d6f 100644 --- a/test/request_evidence_test.rb +++ b/test/request_evidence_test.rb @@ -42,29 +42,166 @@ class RequestEvidenceTest < ActiveSupport::TestCase assert_not config.records_any_request_evidence_by_default? end - test "turning a default on without a purpose fails at boot" do - error = assert_raises(Clickwrap::ConfigurationError) do - Clickwrap.configure do |config| - config.record_ip_address_by_default = true - config.delete_recorded_ip_addresses_after = 90.days - end + # --- The one switch --------------------------------------------------------- + + test "the one switch records the coarse trio and nothing finer" do + Clickwrap.configure { |config| config.record_request_evidence_by_default = true } + + config = Clickwrap.config + assert config.record_request_evidence_by_default + assert config.record_ip_address_by_default + assert config.record_browser_user_agent_by_default + assert_equal %w[country region city], config.enabled_default_ip_geolocation_fields + + (Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS - + Clickwrap::Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS).each do |field| + assert_not config.public_send(:"record_ip_geolocation_#{field}_by_default"), + "the switch must not enable #{field}" + end + end + + test "the switch composes with the per-field flags in reading order" do + Clickwrap.configure do |config| + config.record_request_evidence_by_default = true + config.record_browser_user_agent_by_default = false + end + + assert Clickwrap.config.record_ip_address_by_default + assert_not Clickwrap.config.record_browser_user_agent_by_default + assert_not Clickwrap.config.record_request_evidence_by_default, + "the reader describes the trio, and one of them is off" + end + + test "turning the switch off leaves the separately named finer fields alone" do + Clickwrap.configure do |config| + config.record_ip_geolocation_timezone_by_default = true + config.record_request_evidence_by_default = true + config.record_request_evidence_by_default = false end - assert_match(/is blank/, error.message) + assert_not Clickwrap.config.record_ip_address_by_default + assert_equal %w[timezone], Clickwrap.config.enabled_default_ip_geolocation_fields end - test "turning a default on without a retention rule fails at boot" do + test "the switch is a boolean, not a mode name that could hide a third state" do error = assert_raises(Clickwrap::ConfigurationError) do + Clickwrap.config.record_request_evidence_by_default = :everything + end + + assert_match(/record_request_evidence_by_default must be true or false/, error.message) + end + + test "a policy narrower than the switch still wins" do + Clickwrap.configure { |config| config.record_request_evidence_by_default = true } + + policy = Clickwrap.policy :narrower_than_the_switch do + agree_to :terms + do_not_record_ip_address + do_not_record_ip_geolocation + end + + assert_not policy.request_evidence.records_ip_address? + assert_not policy.request_evidence.records_ip_geolocation? + assert policy.request_evidence.records_browser_user_agent? + end + + test "a policy wider than the switch still wins" do + policy = Clickwrap.policy :wider_than_the_switch do + agree_to :terms + record_ip_address because: "Investigate disputed acceptance" + end + + assert policy.request_evidence.records_ip_address? + assert_equal "host", policy.request_evidence.purpose_source_for(:ip_address) + assert_not policy.request_evidence.records_browser_user_agent? + end + + # --- Gem-supplied defaults -------------------------------------------------- + + test "turning a default on without a purpose records the purpose Clickwrap states" do + Clickwrap.configure do |config| + config.record_ip_address_by_default = true + config.delete_recorded_ip_addresses_after = 90.days + end + + policy = Clickwrap.policy(:purpose_supplied_by_the_gem) { agree_to :terms } + setting = policy.request_evidence.ip_address + + assert_equal Clickwrap::Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE, setting.because + assert_equal "gem_default", policy.request_evidence.purpose_source_for(:ip_address) + assert_equal Clickwrap::Vocabulary::DEFAULT_REQUEST_EVIDENCE_PURPOSE, + policy.snapshot.dig("request_evidence", "ip_address", "because"), + "the compiled revision always carries a purpose" + end + + test "a purpose the host wrote stays the host's own words" do + Clickwrap.configure do |config| + config.record_ip_address_by_default = true + config.reason_for_recording_ip_addresses_by_default = "Investigate account compromise" + end + + policy = Clickwrap.policy(:purpose_supplied_by_the_host) { agree_to :terms } + + assert_equal "Investigate account compromise", policy.request_evidence.ip_address.because + assert_equal "host", policy.request_evidence.purpose_source_for(:ip_address) + end + + test "scaffolding text is still not a purpose, wherever the host wrote it" do + application = assert_raises(Clickwrap::ConfigurationError) do Clickwrap.configure do |config| config.record_ip_address_by_default = true - config.reason_for_recording_ip_addresses_by_default = "Investigate account compromise" + config.reason_for_recording_ip_addresses_by_default = "TODO: ask legal" + end + end + assert_match(/scaffolding text/, application.message) + + policy = assert_raises(Clickwrap::DefinitionError) do + Clickwrap.policy :scaffolded_purpose do + agree_to :terms + record_ip_address because: "FIXME" end end + assert_match(/scaffolding text/, policy.message) + end + + test "turning a default on without a retention rule keeps pace with the evidence" do + Clickwrap.configure do |config| + config.record_ip_address_by_default = true + config.reason_for_recording_ip_addresses_by_default = "Investigate account compromise" + end + + assert Clickwrap.config.validate! + assert_nil Clickwrap.config.delete_recorded_ip_addresses_after + + Clickwrap.policy(:no_clock_anywhere) { agree_to :terms } + Clickwrap::Services::ValidatePolicyReferences.call + + receipt = submit_clickwrap(:no_clock_anywhere, actor: @user, http_request: @http_request) + annex = receipt.event.reload.request_evidence + + assert_equal "203.0.113.7", annex.ip_address + assert_nil annex.ip_address_delete_after, "no schedule IS the disposal answer" + assert_nil annex.ip_address_retain_until_rule + end + + test "an IP address recorded with no reviewed proxy configuration says so honestly" do + Clickwrap.configure do |config| + config.trusted_proxy_configuration_digest = nil + config.record_ip_address_by_default = true + end + + Clickwrap.policy(:unreviewed_proxy_topology) { agree_to :terms } + receipt = submit_clickwrap(:unreviewed_proxy_topology, actor: @user, http_request: @http_request) + annex = receipt.event.reload.request_evidence - assert_match(/nothing says how long to keep it/, error.message) + assert_equal "203.0.113.7", annex.ip_address + assert_nil annex.trusted_proxy_configuration_digest, + "the absence is the provenance: nobody recorded a reviewed proxy configuration" + assert_equal "rails_request_remote_ip", annex.ip_address_reader_name, + "which reader was asked is still recorded, digest or no digest" end - test "enabling geolocation with no resolver fails at boot" do + test "enabling geolocation with no resolver and no trackdown fails at boot" do error = assert_raises(Clickwrap::ConfigurationError) do Clickwrap.configure do |config| config.ip_geolocation_resolver = nil @@ -75,6 +212,7 @@ class RequestEvidenceTest < ActiveSupport::TestCase end assert_match(/no\s+`ip_geolocation_resolver` is configured/, error.message) + assert_match(/bundle add trackdown/, error.message) end test "storing raw values unencrypted requires a deliberately named decision" do diff --git a/test/trackdown_resolver_test.rb b/test/trackdown_resolver_test.rb index ea9c21a..37c5860 100644 --- a/test/trackdown_resolver_test.rb +++ b/test/trackdown_resolver_test.rb @@ -123,6 +123,79 @@ class TrackdownResolverTest < ActiveSupport::TestCase end end + # --- Adopted without a wiring line ------------------------------------------ + + test "a host who bundles trackdown and names no resolver gets this one" do + with_fake_trackdown do + Clickwrap.configure do |config| + config.ip_geolocation_resolver = nil + config.record_request_evidence_by_default = true + end + + resolver = Clickwrap.config.ip_geolocation_resolver_for(:application_default) + + assert_instance_of Clickwrap::IpGeolocation::TrackdownResolver, resolver + assert Clickwrap.config.ip_geolocation_resolver_was_adopted_automatically? + assert_same resolver, Clickwrap.config.ip_geolocation_resolver_in_force + assert_same resolver, Clickwrap.config.ip_geolocation_resolver_for(:application_default), + "adoption is considered once, not per policy compile" + assert_nil Clickwrap.config.ip_geolocation_resolver, + "the host's own setting stays empty; nothing was written into it" + end + end + + test "a host's own resolver is never displaced by the bundled trackdown" do + with_fake_trackdown do + chosen = Clickwrap::IpGeolocation::StaticResolver.new + Clickwrap.configure do |config| + config.ip_geolocation_resolver = chosen + config.record_request_evidence_by_default = true + end + + assert_same chosen, Clickwrap.config.ip_geolocation_resolver_for(:application_default) + assert_not Clickwrap.config.ip_geolocation_resolver_was_adopted_automatically? + end + end + + test "adoption never happens as a side effect of describing the configuration" do + with_fake_trackdown do + Clickwrap.configure { |config| config.ip_geolocation_resolver = nil } + + assert_nil Clickwrap.config.ip_geolocation_resolver_in_force + assert_not Clickwrap.config.ip_geolocation_resolver_was_adopted_automatically? + assert_equal({ "configured" => false }, + Clickwrap::Privacy.inventory.dig("defaults", "ip_geolocation", "resolver")) + end + end + + test "an adopted resolver is reported as the gem's choice, not the host's" do + with_fake_trackdown do + Clickwrap.configure do |config| + config.ip_geolocation_resolver = nil + config.record_request_evidence_by_default = true + end + Clickwrap.config.ip_geolocation_resolver_for(:application_default) + + described = Clickwrap::Privacy.inventory.dig("defaults", "ip_geolocation", "resolver") + + assert_equal "Clickwrap::IpGeolocation::TrackdownResolver", described["class"] + assert_equal "gem_default", described["source"] + end + end + + test "an installed release that is too old surfaces its own sentence, not a missing-gem one" do + with_fake_trackdown(version: "0.3.0") do + error = assert_raises(Clickwrap::ConfigurationError) do + Clickwrap.configure do |config| + config.ip_geolocation_resolver = nil + config.record_request_evidence_by_default = true + end + end + + assert_match(/requires trackdown >= 0\.4\.0/, error.message) + end + end + private def with_fake_trackdown(version: "0.4.0")