clickwrap 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +23 -5
- data/guides/migrating.md +23 -0
- data/guides/naming.md +1 -1
- data/guides/request-evidence.md +47 -2
- data/lib/clickwrap/canonical_json.rb +18 -2
- data/lib/clickwrap/configuration.rb +12 -8
- data/lib/clickwrap/dsl/policy_builder.rb +57 -6
- data/lib/clickwrap/models/request_evidence.rb +12 -5
- data/lib/clickwrap/request_evidence_policy.rb +19 -2
- data/lib/clickwrap/version.rb +1 -1
- data/lib/clickwrap/vocabulary.rb +9 -0
- data/lib/generators/clickwrap/install_generator.rb +22 -12
- data/lib/generators/clickwrap/templates/initializer.rb.erb +12 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ea29dba21d2740876c9708ad687520310951911eab737844b59577fbbe24464
|
|
4
|
+
data.tar.gz: 01140025f14f2c137ba35a25ab694898d31fdbc719a42a488d63204007b0dc85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 752ed3dda0897564843222843c444a9c5c945493152ec04e697241871afda8df0eeea9635b3f07296a392f58c46abe763366daa759f0916cd5bb455f56a04e01
|
|
7
|
+
data.tar.gz: 71e5c6512a165734f941b7e5483b399e8d46e83a3963e05af5ae2d3e077dbcf4eac0b0e7eddebc9ff61b7a9d3b73c551502fb434388c65c1830e2b75a6e0add1
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,137 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.2] - 2026-08-20
|
|
10
|
+
|
|
11
|
+
### Fixed — two guards that looked like they held
|
|
12
|
+
|
|
13
|
+
- **Canonical JSON refuses invalid bytes whatever their encoding tag.**
|
|
14
|
+
`valid_encoding?` is always true on an `ASCII-8BIT` string — BINARY has no
|
|
15
|
+
invalid byte sequences by definition — and BINARY is exactly what Rack and
|
|
16
|
+
CDN headers deliver, so the UTF-8 guard was a no-op for the ten
|
|
17
|
+
request-evidence values most likely to be malformed. Invalid bytes passed
|
|
18
|
+
through and produced canonical JSON that was not itself valid UTF-8, which
|
|
19
|
+
RFC 8785 forbids and a verifier in another language may reject or normalize
|
|
20
|
+
into a different digest. The guard now normalizes the tag before validating.
|
|
21
|
+
**No digest ever written changes**: bytes that are valid UTF-8 canonicalize
|
|
22
|
+
byte-identically whether they arrive tagged BINARY or UTF-8, which a test
|
|
23
|
+
pins. And a stored value that can no longer be canonicalized now reports a
|
|
24
|
+
binding mismatch instead of raising out of the integrity check — a check
|
|
25
|
+
that crashes tells an operator nothing except that the tool broke.
|
|
26
|
+
- **`record_ip_geolocation(country: nil)` is refused instead of quietly
|
|
27
|
+
enabling three fields.** With plain `nil` keyword defaults an explicit nil
|
|
28
|
+
was indistinguishable from an omitted keyword, so a policy written as
|
|
29
|
+
`record_ip_geolocation(country: settings[:geo])` with an empty setting fell
|
|
30
|
+
through to the coarse-trio default — enabling a category of personal data as
|
|
31
|
+
a side effect, which the frictionless pass never relaxed. A sentinel now
|
|
32
|
+
tells the two apart: unmentioned still gets the coarse trio, `false` still
|
|
33
|
+
reaches the coherence check that names `do_not_record_ip_geolocation`, and
|
|
34
|
+
`nil` raises a sentence.
|
|
35
|
+
- **A scaffolding `legal_basis_reference:` is refused like a scaffolding
|
|
36
|
+
`because:`.** The reference lands in the compiled policy revision and every
|
|
37
|
+
receipt built from it, permanently, where "TODO: ask legal" reads as a
|
|
38
|
+
reviewed determination rather than an omission. The option stays optional;
|
|
39
|
+
text the host actually wrote has to be text they meant.
|
|
40
|
+
|
|
41
|
+
### Documentation
|
|
42
|
+
|
|
43
|
+
- The installer's purpose and retention prompts now describe what the
|
|
44
|
+
installer actually does since 0.3.x: a blank purpose is accepted (the gem
|
|
45
|
+
records its own stated purpose, marked as the gem's), a blank period keeps
|
|
46
|
+
pace with the evidence it corroborates, and only scaffolding text or a
|
|
47
|
+
negative period stops generation.
|
|
48
|
+
|
|
49
|
+
## [0.3.1] - 2026-08-20
|
|
50
|
+
|
|
51
|
+
### Changed — the rest of the collection friction, and the principle behind removing it
|
|
52
|
+
|
|
53
|
+
0.3.0 made *enabling* request evidence one switch and gave the initializer
|
|
54
|
+
defaults honest gem-supplied purposes. It left the friction standing
|
|
55
|
+
everywhere else: a policy-level `record_ip_address` still read as though it
|
|
56
|
+
wanted a sentence, `record_ip_geolocation` refused to do anything without a
|
|
57
|
+
field list, and the encryption escape hatch still demanded a reason be
|
|
58
|
+
phrased twice. This finishes the job the owner asked for.
|
|
59
|
+
|
|
60
|
+
The principle, in their words: **the host application and its privacy policy
|
|
61
|
+
own *why* data is collected; this gem records *what* was collected, honestly,
|
|
62
|
+
and is nobody's nanny.** Its job is evidence mechanics, not gatekeeping
|
|
63
|
+
collection. Everything below now works exactly as written:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Clickwrap.configure do |config|
|
|
67
|
+
config.record_request_evidence_by_default = true
|
|
68
|
+
config.keep_recorded_ip_addresses_indefinitely!
|
|
69
|
+
config.deliberately_store_request_evidence_unencrypted!
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Clickwrap.policy :anything do
|
|
73
|
+
agree_to :terms
|
|
74
|
+
record_ip_address
|
|
75
|
+
record_browser_user_agent
|
|
76
|
+
record_ip_geolocation
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- **All three `record_` verbs take zero keyword arguments.** `because:`,
|
|
81
|
+
`legal_basis_reference:`, `data_protection_impact_assessment_reference:`,
|
|
82
|
+
`delete_after:`, `retain_until:`, and `encrypted:` are optional in fact and
|
|
83
|
+
not merely in the signature — nothing downstream refuses their absence.
|
|
84
|
+
(`legal_basis_reference` and the DPIA reference never were required anywhere
|
|
85
|
+
in the gem; a test now pins that they never become so.)
|
|
86
|
+
- **`record_ip_geolocation` with no field named records the coarse trio** —
|
|
87
|
+
country, region, city — the same set `record_request_evidence_by_default`
|
|
88
|
+
turns on, and nothing finer. The field keywords moved from `false` to `nil`
|
|
89
|
+
defaults so "did not mention this field" is distinguishable from "named it
|
|
90
|
+
and turned it off". Naming even one field means you are choosing the set
|
|
91
|
+
yourself, and the set is exactly what you named. Naming every field `false`
|
|
92
|
+
is still refused, because calling the verb and disabling everything cannot
|
|
93
|
+
mean anything; `do_not_record_ip_geolocation` is how to say that.
|
|
94
|
+
- **`keep_recorded_{ip_addresses,browser_user_agents,ip_geolocation}_indefinitely!`
|
|
95
|
+
take no arguments at all.**
|
|
96
|
+
- **`deliberately_store_request_evidence_unencrypted!` no longer needs a
|
|
97
|
+
`because:`.** The method NAME is the ceremony: `encrypt_recorded_* = false`
|
|
98
|
+
still cannot be reached without writing that line, and a reviewer still
|
|
99
|
+
finds it in the diff. When the host writes no reason the gem records
|
|
100
|
+
`Vocabulary::DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED`.
|
|
101
|
+
Encryption itself is untouched — on by default for all three categories,
|
|
102
|
+
with a test pinning that the one switch does not weaken it.
|
|
103
|
+
- **The install generator stops refusing an incomplete category.**
|
|
104
|
+
`--record-ip-addresses-by-default` with no reason and no period writes the
|
|
105
|
+
file and simply omits those two lines, so the gem's own defaults apply.
|
|
106
|
+
- The `ReviewedText` placeholder check now only ever applies to text a host
|
|
107
|
+
actually supplied. Absence is never scaffolding.
|
|
108
|
+
|
|
109
|
+
### Unchanged, deliberately
|
|
110
|
+
|
|
111
|
+
- Reasons that are not about *collection* keep their required `because:`:
|
|
112
|
+
`delete_recorded_ip_address!` and its siblings, `dispose_core_event!`,
|
|
113
|
+
`place_on_legal_hold!` / `release_legal_hold!`, `plan_disposition_for`,
|
|
114
|
+
unredacted receipt export, and the lifecycle verbs. Those record a
|
|
115
|
+
destructive act, an access, or a state change — there the audit trail *is*
|
|
116
|
+
the reason, and there is no honest default for "why did somebody delete
|
|
117
|
+
this".
|
|
118
|
+
- Still refused, because each is the host contradicting themselves rather than
|
|
119
|
+
leaving a blank: scaffolding text the host actually wrote standing in for a
|
|
120
|
+
purpose (including in the installer, where a `TODO` in a shipped initializer
|
|
121
|
+
is worse than no line and the gem would reject it at boot anyway), a
|
|
122
|
+
negative deletion period passed to the installer, a deletion clock declared
|
|
123
|
+
alongside `keep_recorded_..._indefinitely!` for the same category, and
|
|
124
|
+
`record_ip_geolocation` with every field explicitly off.
|
|
125
|
+
- The gem's code default is still record-nothing. Claim boundaries, receipt
|
|
126
|
+
state labeling (`not_configured` / `unavailable` / `recorded` /
|
|
127
|
+
`deleted_after_retention`), and every released receipt format are untouched.
|
|
128
|
+
|
|
129
|
+
### Documentation
|
|
130
|
+
|
|
131
|
+
- README, `guides/request-evidence.md`, and `guides/naming.md` show the
|
|
132
|
+
zero-keyword forms as the ordinary way to write a policy, with purposes,
|
|
133
|
+
legal bases, and clocks as the upgrade path.
|
|
134
|
+
- `CLAUDE.md` / `AGENTS.md` rule 6 is rewritten around the principle above and
|
|
135
|
+
records the owner directive and its date. What it keeps as non-negotiable:
|
|
136
|
+
never a switch whose NAME hides what it collects, never an overclaim, never
|
|
137
|
+
a blurred receipt state, encryption on by default behind its named call,
|
|
138
|
+
host-supplied scaffolding rejected, and contradictions refused.
|
|
139
|
+
|
|
9
140
|
## [0.3.0] - 2026-08-20
|
|
10
141
|
|
|
11
142
|
### Changed — recording request evidence is one switch, not a checklist
|
data/README.md
CHANGED
|
@@ -733,7 +733,7 @@ end
|
|
|
733
733
|
|
|
734
734
|
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".
|
|
735
735
|
|
|
736
|
-
Nothing else is required,
|
|
736
|
+
Nothing else is required — anywhere, at either level. **Your privacy policy owns the why; the gem records the what, honestly, and is nobody's nanny.** Clickwrap supplies its own defaults for every part you did not write:
|
|
737
737
|
|
|
738
738
|
- **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.
|
|
739
739
|
- **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.
|
|
@@ -772,7 +772,23 @@ end
|
|
|
772
772
|
|
|
773
773
|
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.
|
|
774
774
|
|
|
775
|
-
|
|
775
|
+
### Per policy, with as much or as little as you want
|
|
776
|
+
|
|
777
|
+
A single surface can name the fields itself instead of inheriting the default. The frictionless form takes no arguments at all:
|
|
778
|
+
|
|
779
|
+
```ruby
|
|
780
|
+
Clickwrap.policy :withdrawal_authorization do
|
|
781
|
+
authorize :regulated_action, one_time: true, valid_for: 10.minutes
|
|
782
|
+
|
|
783
|
+
record_ip_address
|
|
784
|
+
record_browser_user_agent
|
|
785
|
+
record_ip_geolocation
|
|
786
|
+
end
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
`record_ip_geolocation` with no field named records the same coarse trio as the switch — country, region, city. Name even one field and you are choosing the set yourself, and the set is exactly what you named.
|
|
790
|
+
|
|
791
|
+
The same declarations with the full record a reviewed team would want:
|
|
776
792
|
|
|
777
793
|
```ruby
|
|
778
794
|
Clickwrap.policy :regulated_authorization do
|
|
@@ -791,9 +807,11 @@ end
|
|
|
791
807
|
Recorded values live in a separately encrypted annex with their own retention, so
|
|
792
808
|
they can be deleted later without rewriting the core event payload. Core payloads
|
|
793
809
|
have their own reviewed disposition path and leave a digest-linked tombstone.
|
|
794
|
-
Encryption is on by default
|
|
795
|
-
`
|
|
796
|
-
|
|
810
|
+
Encryption is on by default for all three categories. Turning it off keeps its
|
|
811
|
+
own ceremony — you cannot reach `encrypt_recorded_ip_addresses = false` without
|
|
812
|
+
first writing `config.deliberately_store_request_evidence_unencrypted!` — but the
|
|
813
|
+
ceremony is the method name a reviewer finds in the diff, not a sentence the gem
|
|
814
|
+
makes you type. `because:` there is optional too.
|
|
797
815
|
|
|
798
816
|
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:
|
|
799
817
|
|
data/guides/migrating.md
CHANGED
|
@@ -333,3 +333,26 @@ never satisfy a human-action predicate.)
|
|
|
333
333
|
| [FinePrint README at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/README.md#L7-L25) | Pinned source code |
|
|
334
334
|
| [FinePrint signature model at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/app/models/fine_print/signature.rb#L1-L33) | Pinned source code |
|
|
335
335
|
| The never-synthesize rule, the `unknown:` vocabulary, and the dry-run-first workflow | Product-design inference |
|
|
336
|
+
|
|
337
|
+
## Completeness: enumerate writers, not rows
|
|
338
|
+
|
|
339
|
+
A migration inventory built by looking at what the database HOLDS will miss
|
|
340
|
+
what the code can WRITE. One production integration classified 52 distinct
|
|
341
|
+
audit event types from its data — the codebase carried ~150: everything rare
|
|
342
|
+
(incident and recovery paths that had never fired) and everything new
|
|
343
|
+
(writers added after the cutover) was invisible to the data-only sweep, and
|
|
344
|
+
one of the missed stores turned out to hold the product's most consequential
|
|
345
|
+
assent act.
|
|
346
|
+
|
|
347
|
+
Sweep three ways, and reconcile the lists:
|
|
348
|
+
|
|
349
|
+
1. **Schema**: every table and column whose name says assent — accepted,
|
|
350
|
+
declared, consent, attest, authoriz, acknowledg, signed.
|
|
351
|
+
2. **Writers**: every place code can create such a record — grep the literals
|
|
352
|
+
(`event_type:`, ledger inserts), not the rows.
|
|
353
|
+
3. **Structure**: every model already carrying `has_clickwrap_evidence`, and
|
|
354
|
+
every dual-write site.
|
|
355
|
+
|
|
356
|
+
Then write a disposition for every entry — import it, "not a clickwrap" with
|
|
357
|
+
the reason, or "provider record" — in a tracked document. Silently ignoring a
|
|
358
|
+
store is not a valid disposition.
|
data/guides/naming.md
CHANGED
|
@@ -291,7 +291,7 @@ object.
|
|
|
291
291
|
| `chain_event_history_with`, `anchor_event_history_with`, `timestamp_receipts_with` | one `integrity_level` setting | Three different mechanisms making three different claims. One setting would let a reader infer the strongest from the presence of the weakest |
|
|
292
292
|
| `after_event_is_committed` | `after_commit` | Says which commit, and reads as a sentence |
|
|
293
293
|
| `authorize_unredacted_request_evidence_access_with` | `access_control` | Long, and correct. It names exactly which access it authorizes |
|
|
294
|
-
| `deliberately_store_request_evidence_unencrypted
|
|
294
|
+
| `deliberately_store_request_evidence_unencrypted!` | `encryption: false` | Turning encryption off should be a sentence a reviewer can find in a diff — not a `false`. The method NAME is the ceremony; its `because:` is optional |
|
|
295
295
|
|
|
296
296
|
The last row is the pattern worth copying. When an option has a consequence somebody should
|
|
297
297
|
have to think about, make the name carry the thinking.
|
data/guides/request-evidence.md
CHANGED
|
@@ -310,7 +310,26 @@ Clickwrap.policy :regulated_authorization do
|
|
|
310
310
|
end
|
|
311
311
|
```
|
|
312
312
|
|
|
313
|
-
Every keyword there is doing work
|
|
313
|
+
Every keyword there is doing work, and **every one of them is optional**. The same three
|
|
314
|
+
declarations with nothing at all supplied are valid, and record the same fields:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
Clickwrap.policy :frictionless_regulated_authorization do
|
|
318
|
+
authorize :regulated_action, one_time: true, valid_for: 10.minutes
|
|
319
|
+
|
|
320
|
+
record_ip_address
|
|
321
|
+
record_browser_user_agent
|
|
322
|
+
record_ip_geolocation
|
|
323
|
+
retain_with :regulated_evidence
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`record_ip_geolocation` with no field named records the coarse trio — country, region, city —
|
|
328
|
+
and nothing finer. Name even one field and the set is exactly what you named; name every field
|
|
329
|
+
`false` and Clickwrap refuses, because calling `record_ip_geolocation` and disabling everything
|
|
330
|
+
cannot mean anything (`do_not_record_ip_geolocation` is how to say that).
|
|
331
|
+
|
|
332
|
+
What each keyword adds when you do supply it:
|
|
314
333
|
|
|
315
334
|
- **`because:`** is the present purpose, in a sentence someone outside engineering can read. It
|
|
316
335
|
is stored and printed by `bin/rails clickwrap:privacy:inventory`. It is optional: a policy
|
|
@@ -319,7 +338,9 @@ Every keyword there is doing work:
|
|
|
319
338
|
reviewed. What is refused is scaffolding text — `"TODO: ask legal"` is not a purpose.
|
|
320
339
|
- **`legal_basis_reference:`** and **`data_protection_impact_assessment_reference:`** are
|
|
321
340
|
host-supplied pointers to your own reviewed documents. Clickwrap stores them. It does not
|
|
322
|
-
read them, validate them, or
|
|
341
|
+
read them, validate them, endorse them, or ever require them — nothing in the gem refuses a
|
|
342
|
+
recorded field for want of either, and nothing ever will. Your privacy policy owns the why;
|
|
343
|
+
the gem records the what.
|
|
323
344
|
- **`delete_after:`** and **`retain_until:`** are both optional. When neither the policy, its
|
|
324
345
|
retention class, nor the configuration names a schedule, the field keeps pace with the
|
|
325
346
|
evidence it corroborates — the same posture the core event has — and the annex is stamped
|
|
@@ -554,3 +575,27 @@ can never undo or stand in for the Clickwrap event. Clickwrap ships no Footprint
|
|
|
554
575
|
| [Trackdown v0.4.0 result object](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/location_result.rb), [configuration](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/configuration.rb), [Cloudflare provider](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/providers/cloudflare_provider.rb), [MaxMind provider](https://github.com/rameerez/trackdown/blob/v0.4.0/lib/trackdown/providers/maxmind_provider.rb), [PR #9](https://github.com/rameerez/trackdown/pull/9) | Pinned released source and project change record |
|
|
555
576
|
| [Footprinted pinned model](https://github.com/rameerez/footprinted/blob/03b714bd3fa31368a8ce6695433386128fb6f91c/lib/footprinted/footprint.rb#L7-L52), [tracking concern](https://github.com/rameerez/footprinted/blob/03b714bd3fa31368a8ce6695433386128fb6f91c/lib/footprinted/model.rb#L7-L65) | Pinned source code |
|
|
556
577
|
| The field selection, the ordering of evidentiary priority, and every API prescription above | Product-design inference |
|
|
578
|
+
|
|
579
|
+
## Rendering recorded values: encodings survive encryption
|
|
580
|
+
|
|
581
|
+
Rack hands header strings to Ruby tagged `ASCII-8BIT`, and encrypted
|
|
582
|
+
attributes preserve the original encoding through decryption — so a recorded
|
|
583
|
+
`browser_user_agent` read back from the annex is BINARY-tagged even when its
|
|
584
|
+
bytes are perfectly valid UTF-8. Concatenating it into a UTF-8 view buffer
|
|
585
|
+
raises `Encoding::CompatibilityError`, and because only live captures carry
|
|
586
|
+
an annex, the failure hides until the first real signup reaches an admin
|
|
587
|
+
screen (it took down a production operator panel exactly that way).
|
|
588
|
+
|
|
589
|
+
Normalize at the render boundary, not in storage — the stored bytes are the
|
|
590
|
+
evidence:
|
|
591
|
+
|
|
592
|
+
```ruby
|
|
593
|
+
# clickwrap-doc-test: syntax-only — a host view helper, not gem configuration
|
|
594
|
+
def rendered_recorded_text(value)
|
|
595
|
+
utf8 = value.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
596
|
+
utf8.valid_encoding? ? utf8 : utf8.scrub
|
|
597
|
+
end
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
The gem's own screens never render raw annex values, so they are not exposed;
|
|
601
|
+
any host view that does — an operator panel, an export page — is.
|
|
@@ -132,10 +132,26 @@ module Clickwrap
|
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
def write_string(string, buffer)
|
|
135
|
-
|
|
135
|
+
# `valid_encoding?` is ALWAYS true on an ASCII-8BIT string — BINARY has
|
|
136
|
+
# no invalid byte sequences by definition — and BINARY is exactly what
|
|
137
|
+
# Rack and CDN headers deliver, so this guard was a no-op for the
|
|
138
|
+
# strings most likely to be malformed. Ten request-evidence columns
|
|
139
|
+
# reach it.
|
|
140
|
+
#
|
|
141
|
+
# Normalizing the tag before validating is safe for every digest ever
|
|
142
|
+
# written: bytes that ARE valid UTF-8 canonicalize byte-identically
|
|
143
|
+
# whether they arrive tagged BINARY or UTF-8 (measured, and pinned by a
|
|
144
|
+
# test below). What changes is only the case that was broken —
|
|
145
|
+
# genuinely invalid bytes used to emit canonical JSON that was itself
|
|
146
|
+
# not valid UTF-8, which RFC 8785 forbids and a verifier in another
|
|
147
|
+
# language may reject or normalize into a different digest. That is the
|
|
148
|
+
# "still verifiable years later" promise failing silently, so it is
|
|
149
|
+
# refused at write time instead.
|
|
150
|
+
utf8 = string.encoding == Encoding::UTF_8 ? string : string.dup.force_encoding(Encoding::UTF_8)
|
|
151
|
+
raise SerializationError, "Canonical JSON strings must be valid UTF-8" unless utf8.valid_encoding?
|
|
136
152
|
|
|
137
153
|
buffer << '"'
|
|
138
|
-
|
|
154
|
+
utf8.each_char do |char|
|
|
139
155
|
escape = ESCAPES[char]
|
|
140
156
|
buffer << if escape
|
|
141
157
|
escape
|
|
@@ -1227,15 +1227,19 @@ module Clickwrap
|
|
|
1227
1227
|
@keep_recorded_request_evidence_indefinitely[category.to_sym]
|
|
1228
1228
|
end
|
|
1229
1229
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1230
|
+
# The named escape hatch for turning encryption off. The ceremony is the
|
|
1231
|
+
# method: you cannot reach `encrypt_recorded_* = false` without writing a
|
|
1232
|
+
# line that says out loud what you are doing, and that line is what a
|
|
1233
|
+
# reviewer finds in a diff. Since 0.3.1 the `because:` is optional — the
|
|
1234
|
+
# gem records its own sentence when you do not write one — because the
|
|
1235
|
+
# host's privacy policy owns the why, and demanding it twice never stopped
|
|
1236
|
+
# anybody who had already typed this method name.
|
|
1237
|
+
#
|
|
1238
|
+
# Encryption itself is unchanged: on by default, for all three categories.
|
|
1239
|
+
def deliberately_store_request_evidence_unencrypted!(because: nil)
|
|
1237
1240
|
@deliberately_storing_request_evidence_unencrypted = true
|
|
1238
|
-
@reason_for_storing_request_evidence_unencrypted =
|
|
1241
|
+
@reason_for_storing_request_evidence_unencrypted =
|
|
1242
|
+
because.presence || Vocabulary::DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED
|
|
1239
1243
|
end
|
|
1240
1244
|
|
|
1241
1245
|
def storing_request_evidence_unencrypted? = @deliberately_storing_request_evidence_unencrypted == true
|
|
@@ -239,24 +239,46 @@ module Clickwrap
|
|
|
239
239
|
@request_evidence[:browser_user_agent] = RequestEvidencePolicy::NOT_RECORDED
|
|
240
240
|
end
|
|
241
241
|
|
|
242
|
-
# Each IP-geolocation data field
|
|
243
|
-
# a separate decision about what to keep about a person's network
|
|
242
|
+
# Each IP-geolocation data field can be named separately, because each one
|
|
243
|
+
# is a separate decision about what to keep about a person's network
|
|
244
244
|
# context. `latitude_and_longitude` is one coupled choice: half a
|
|
245
245
|
# coordinate is not a result. Whatever is enabled, the provider name,
|
|
246
246
|
# source, estimated status, resolution time, and any accuracy or database
|
|
247
247
|
# provenance the resolver supplies are stored with it automatically — a
|
|
248
248
|
# policy cannot keep the coordinates and drop the uncertainty needed to
|
|
249
249
|
# read them.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
250
|
+
#
|
|
251
|
+
# Naming no field at all is the frictionless form:
|
|
252
|
+
#
|
|
253
|
+
# record_ip_geolocation
|
|
254
|
+
#
|
|
255
|
+
# It records the same coarse trio as
|
|
256
|
+
# `config.record_request_evidence_by_default` — country, region, city —
|
|
257
|
+
# and nothing finer, because that is what "IP geolocation" means in this
|
|
258
|
+
# gem when nobody narrows it. Naming even one field means you are
|
|
259
|
+
# choosing the set yourself, and then the set is exactly what you named.
|
|
260
|
+
# Distinguishes "the policy never mentioned this field" from "the policy
|
|
261
|
+
# passed nil for it". With plain `nil` defaults the two are identical, and
|
|
262
|
+
# `record_ip_geolocation(country: settings[:geo])` with an empty setting
|
|
263
|
+
# silently enabled the coarse trio — enabling a category of personal data
|
|
264
|
+
# as a side effect, which is the one thing the frictionless pass never
|
|
265
|
+
# relaxed.
|
|
266
|
+
UNMENTIONED_FIELD = Object.new.freeze
|
|
267
|
+
private_constant :UNMENTIONED_FIELD
|
|
268
|
+
|
|
269
|
+
def record_ip_geolocation(country: UNMENTIONED_FIELD, region: UNMENTIONED_FIELD,
|
|
270
|
+
city: UNMENTIONED_FIELD, postal_code: UNMENTIONED_FIELD,
|
|
271
|
+
latitude_and_longitude: UNMENTIONED_FIELD,
|
|
272
|
+
timezone: UNMENTIONED_FIELD, continent: UNMENTIONED_FIELD,
|
|
273
|
+
metro_code: UNMENTIONED_FIELD,
|
|
274
|
+
accuracy_radius_in_kilometers: UNMENTIONED_FIELD,
|
|
253
275
|
using: nil, encrypted: nil, delete_after: nil, retain_until: nil,
|
|
254
276
|
fail_if_unavailable: false, because: nil,
|
|
255
277
|
legal_basis_reference: nil,
|
|
256
278
|
data_protection_impact_assessment_reference: nil,
|
|
257
279
|
**unknown_options)
|
|
258
280
|
refuse_unknown_options!("record_ip_geolocation", unknown_options)
|
|
259
|
-
|
|
281
|
+
named = {
|
|
260
282
|
"country" => country,
|
|
261
283
|
"region" => region,
|
|
262
284
|
"city" => city,
|
|
@@ -267,6 +289,7 @@ module Clickwrap
|
|
|
267
289
|
"metro_code" => metro_code,
|
|
268
290
|
"accuracy_radius_in_kilometers" => accuracy_radius_in_kilometers
|
|
269
291
|
}
|
|
292
|
+
@ip_geolocation_fields = default_ip_geolocation_fields_when_none_named(named)
|
|
270
293
|
@ip_geolocation_resolver_name = using
|
|
271
294
|
|
|
272
295
|
@request_evidence[:ip_geolocation] = RequestEvidencePolicy::Setting.new(
|
|
@@ -404,6 +427,34 @@ module Clickwrap
|
|
|
404
427
|
end
|
|
405
428
|
end
|
|
406
429
|
|
|
430
|
+
# An UNMENTIONED field means "the policy did not mention this"; `false`
|
|
431
|
+
# means "the policy named it and turned it off". A policy that mentions
|
|
432
|
+
# nothing gets the coarse trio; a policy that explicitly sets every field
|
|
433
|
+
# to false still reaches the coherence check that tells it to say
|
|
434
|
+
# `do_not_record_ip_geolocation` instead.
|
|
435
|
+
#
|
|
436
|
+
# An explicit `nil` is neither, and is refused rather than guessed: it is
|
|
437
|
+
# almost always a variable that came out empty, and treating it as "the
|
|
438
|
+
# policy said nothing" would turn a missing setting into three enabled
|
|
439
|
+
# fields of personal data.
|
|
440
|
+
def default_ip_geolocation_fields_when_none_named(named)
|
|
441
|
+
ambiguous = named.select { |_, value| value.nil? }.keys
|
|
442
|
+
unless ambiguous.empty?
|
|
443
|
+
raise DefinitionError,
|
|
444
|
+
"Policy #{@key} passes nil for #{ambiguous.join(", ")} in " \
|
|
445
|
+
"`record_ip_geolocation`. Say `true` or `false` for each field you name, " \
|
|
446
|
+
"or leave it out entirely — Clickwrap will not read an empty value as " \
|
|
447
|
+
"permission to record it, and it will not read it as silence either."
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
mentioned = named.reject { |_, value| value.equal?(UNMENTIONED_FIELD) }
|
|
451
|
+
return named.transform_values { |value| value == true } if mentioned.any?
|
|
452
|
+
|
|
453
|
+
Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field|
|
|
454
|
+
[field, Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.include?(field)]
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
407
458
|
def resolved_ip_geolocation_fields(setting)
|
|
408
459
|
return {} unless setting.record?
|
|
409
460
|
return @ip_geolocation_fields if @request_evidence.key?(:ip_geolocation)
|
|
@@ -302,11 +302,18 @@ module Clickwrap
|
|
|
302
302
|
key = Clickwrap.config.request_evidence_binding_key_for(key_id)
|
|
303
303
|
return false if key.nil?
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
305
|
+
# A stored value that can no longer be canonicalized cannot reproduce its
|
|
306
|
+
# binding, and saying so is the honest answer — an integrity check that
|
|
307
|
+
# raises tells an operator nothing except that the tool broke.
|
|
308
|
+
begin
|
|
309
|
+
computed = Digest.keyed_digest(
|
|
310
|
+
CanonicalJson.generate(binding_body_for(category)),
|
|
311
|
+
key: key,
|
|
312
|
+
algorithm: digest_algorithm
|
|
313
|
+
)
|
|
314
|
+
rescue CanonicalJson::SerializationError
|
|
315
|
+
return false
|
|
316
|
+
end
|
|
310
317
|
Digest.secure_compare?(computed, digest)
|
|
311
318
|
end
|
|
312
319
|
|
|
@@ -229,6 +229,20 @@ module Clickwrap
|
|
|
229
229
|
"purpose."
|
|
230
230
|
end
|
|
231
231
|
|
|
232
|
+
# The legal-basis reference goes into the compiled policy revision and
|
|
233
|
+
# every receipt built from it, permanently. Scaffolding there is worse
|
|
234
|
+
# than an omission: an omission reads as "the host said nothing", while
|
|
235
|
+
# "TODO: ask legal" reads as a reviewed determination to anyone who finds
|
|
236
|
+
# it later. Same rule as `because:` — the option is optional, but text
|
|
237
|
+
# the host actually wrote has to be text they meant.
|
|
238
|
+
if ReviewedText.placeholder?(setting.legal_basis_reference)
|
|
239
|
+
raise DefinitionError,
|
|
240
|
+
"Policy #{policy_key} records #{category} with a `legal_basis_reference:` " \
|
|
241
|
+
"that is still scaffolding text (#{setting.legal_basis_reference.inspect}). " \
|
|
242
|
+
"Replace it with the application's own reference, or drop the option — " \
|
|
243
|
+
"Clickwrap would rather record nothing than record a TODO as a legal basis."
|
|
244
|
+
end
|
|
245
|
+
|
|
232
246
|
return unless setting.delete_after && setting.delete_after.to_i <= 0
|
|
233
247
|
|
|
234
248
|
raise DefinitionError,
|
|
@@ -241,8 +255,11 @@ module Clickwrap
|
|
|
241
255
|
|
|
242
256
|
if ip_geolocation.record? && enabled.empty?
|
|
243
257
|
raise DefinitionError,
|
|
244
|
-
"Policy #{policy_key} calls `record_ip_geolocation`
|
|
245
|
-
"Name the fields you
|
|
258
|
+
"Policy #{policy_key} calls `record_ip_geolocation` and then turns every field " \
|
|
259
|
+
"off, which cannot mean anything. Name the fields you want, for example " \
|
|
260
|
+
"`country: true`; call `record_ip_geolocation` with no fields at all for the " \
|
|
261
|
+
"coarse country, region, and city; or say `do_not_record_ip_geolocation` if that " \
|
|
262
|
+
"is what you meant."
|
|
246
263
|
end
|
|
247
264
|
|
|
248
265
|
if ip_geolocation.record? &&
|
data/lib/clickwrap/version.rb
CHANGED
data/lib/clickwrap/vocabulary.rb
CHANGED
|
@@ -187,6 +187,15 @@ module Clickwrap
|
|
|
187
187
|
DEFAULT_REASON_FOR_KEEPING_REQUEST_EVIDENCE_INDEFINITELY =
|
|
188
188
|
"Corroboration lives as long as the evidence it corroborates"
|
|
189
189
|
|
|
190
|
+
# The reason recorded when a host turns off encryption for request evidence
|
|
191
|
+
# without writing their own. Calling the method is still the ceremony —
|
|
192
|
+
# `deliberately_store_request_evidence_unencrypted!` is a sentence a
|
|
193
|
+
# reviewer finds in a diff and cannot misread — but the gem no longer
|
|
194
|
+
# demands the sentence be phrased twice.
|
|
195
|
+
DEFAULT_REASON_FOR_STORING_REQUEST_EVIDENCE_UNENCRYPTED =
|
|
196
|
+
"The application deliberately stores request evidence unencrypted; the reason lives " \
|
|
197
|
+
"outside Clickwrap"
|
|
198
|
+
|
|
190
199
|
# Provenance that travels with any stored IP-geolocation result. A policy
|
|
191
200
|
# cannot keep provider-derived coordinates while stripping the uncertainty
|
|
192
201
|
# needed to interpret them.
|
|
@@ -980,15 +980,18 @@ module Clickwrap
|
|
|
980
980
|
|
|
981
981
|
def ask_purpose(label)
|
|
982
982
|
say "\n Why does the application need #{label}? One plain sentence, in your own"
|
|
983
|
-
say " words — it goes into the initializer and the privacy inventory.
|
|
984
|
-
say "
|
|
983
|
+
say " words — it goes into the initializer and the privacy inventory. Leave it"
|
|
984
|
+
say " blank and Clickwrap records its own stated purpose instead, marked as the"
|
|
985
|
+
say " gem's; a scaffolding answer (TODO, FIXME) stops generation before any"
|
|
986
|
+
say " files are written."
|
|
985
987
|
ask(" Purpose:").to_s.strip
|
|
986
988
|
end
|
|
987
989
|
|
|
988
990
|
def ask_retention_days(label)
|
|
989
991
|
say "\n After how many days should Clickwrap delete #{label}?"
|
|
990
|
-
say "
|
|
991
|
-
say "
|
|
992
|
+
say " Enter the number of days your application reviewed, or leave it blank and"
|
|
993
|
+
say " #{label} keeps pace with the evidence it corroborates — kept until a"
|
|
994
|
+
say " reviewed disposition removes it. A negative number stops generation."
|
|
992
995
|
ask(" Days:").to_s.strip.to_i
|
|
993
996
|
end
|
|
994
997
|
|
|
@@ -1160,23 +1163,30 @@ module Clickwrap
|
|
|
1160
1163
|
validate_ip_geolocation_resolver_class_name!
|
|
1161
1164
|
end
|
|
1162
1165
|
|
|
1166
|
+
# A missing purpose and a missing deletion period are both fine, and the
|
|
1167
|
+
# generated file simply omits those lines: the gem records its own stated
|
|
1168
|
+
# purpose and keeps the field as long as the evidence it corroborates.
|
|
1169
|
+
# Scaffolding text is the one thing still refused, because a `TODO` the
|
|
1170
|
+
# installer writes into a shipped initializer is worse than no line at
|
|
1171
|
+
# all — the gem would reject it at boot anyway.
|
|
1163
1172
|
def validate_enabled_category!(label, enabled:, because:, delete_after_days:,
|
|
1164
1173
|
reason_option:, retention_option:)
|
|
1165
1174
|
return unless enabled
|
|
1166
1175
|
|
|
1167
|
-
|
|
1176
|
+
if Clickwrap::ReviewedText.placeholder?(because)
|
|
1168
1177
|
raise Thor::Error,
|
|
1169
|
-
"Clickwrap cannot enable #{label} with a
|
|
1170
|
-
"Give the application's reviewed, present-tense reason
|
|
1171
|
-
"#{reason_option}=\"...\", or
|
|
1178
|
+
"Clickwrap cannot enable #{label} with a scaffolding reason " \
|
|
1179
|
+
"(#{because.inspect}). Give the application's reviewed, present-tense reason " \
|
|
1180
|
+
"with #{reason_option}=\"...\", or omit it entirely and let Clickwrap record " \
|
|
1181
|
+
"its own stated purpose. No files were written."
|
|
1172
1182
|
end
|
|
1173
1183
|
|
|
1174
|
-
return
|
|
1184
|
+
return unless delete_after_days.negative?
|
|
1175
1185
|
|
|
1176
1186
|
raise Thor::Error,
|
|
1177
|
-
"
|
|
1178
|
-
"
|
|
1179
|
-
"
|
|
1187
|
+
"#{retention_option} cannot be negative (got #{delete_after_days}). Give the " \
|
|
1188
|
+
"number of days your application reviewed, or omit it and #{label} will keep " \
|
|
1189
|
+
"pace with the evidence it corroborates. No files were written."
|
|
1180
1190
|
end
|
|
1181
1191
|
|
|
1182
1192
|
def validate_ip_geolocation_coordinates!
|
|
@@ -287,19 +287,25 @@ Clickwrap.configure do |config|
|
|
|
287
287
|
# event — the historical agreement, declaration, or authorization stays intact
|
|
288
288
|
# and verifiable without it.
|
|
289
289
|
|
|
290
|
-
<%- if record_ip_addresses? -%>
|
|
290
|
+
<%- if record_ip_addresses? && reason_for_recording_ip_addresses.present? -%>
|
|
291
291
|
config.reason_for_recording_ip_addresses_by_default =
|
|
292
292
|
<%= reason_for_recording_ip_addresses.inspect %>
|
|
293
|
+
<%- end -%>
|
|
294
|
+
<%- if record_ip_addresses? && delete_recorded_ip_addresses_after_days.positive? -%>
|
|
293
295
|
config.delete_recorded_ip_addresses_after = <%= delete_recorded_ip_addresses_after_days %>.days
|
|
294
296
|
<%- end -%>
|
|
295
|
-
<%- if record_browser_user_agents? -%>
|
|
297
|
+
<%- if record_browser_user_agents? && reason_for_recording_browser_user_agents.present? -%>
|
|
296
298
|
config.reason_for_recording_browser_user_agents_by_default =
|
|
297
299
|
<%= reason_for_recording_browser_user_agents.inspect %>
|
|
300
|
+
<%- end -%>
|
|
301
|
+
<%- if record_browser_user_agents? && delete_recorded_browser_user_agents_after_days.positive? -%>
|
|
298
302
|
config.delete_recorded_browser_user_agents_after = <%= delete_recorded_browser_user_agents_after_days %>.days
|
|
299
303
|
<%- end -%>
|
|
300
|
-
<%- if any_ip_geolocation_field? -%>
|
|
304
|
+
<%- if any_ip_geolocation_field? && reason_for_recording_ip_geolocation.present? -%>
|
|
301
305
|
config.reason_for_recording_ip_geolocation_by_default =
|
|
302
306
|
<%= reason_for_recording_ip_geolocation.inspect %>
|
|
307
|
+
<%- end -%>
|
|
308
|
+
<%- if any_ip_geolocation_field? && delete_recorded_ip_geolocation_after_days.positive? -%>
|
|
303
309
|
config.delete_recorded_ip_geolocation_after = <%= delete_recorded_ip_geolocation_after_days %>.days
|
|
304
310
|
<%- end -%>
|
|
305
311
|
<%- if records_any_request_evidence? -%>
|
|
@@ -332,8 +338,9 @@ Clickwrap.configure do |config|
|
|
|
332
338
|
# Turning one of these off puts the raw value in plain text in your database,
|
|
333
339
|
# and therefore in every ordinary backup and database dump. It is allowed,
|
|
334
340
|
# because some applications have a reviewed reason, but it is never a quiet
|
|
335
|
-
# one-character change: Clickwrap refuses `false` until you have
|
|
336
|
-
# `config.deliberately_store_request_evidence_unencrypted
|
|
341
|
+
# one-character change: Clickwrap refuses `false` until you have written
|
|
342
|
+
# `config.deliberately_store_request_evidence_unencrypted!` above it. Writing
|
|
343
|
+
# that line IS the whole ceremony — its `because:` is optional.
|
|
337
344
|
#
|
|
338
345
|
# config.encrypt_recorded_ip_addresses = true
|
|
339
346
|
# config.encrypt_recorded_browser_user_agents = true
|