clickwrap 0.3.1 → 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 +40 -0
- data/guides/migrating.md +23 -0
- data/guides/request-evidence.md +24 -0
- data/lib/clickwrap/canonical_json.rb +18 -2
- data/lib/clickwrap/dsl/policy_builder.rb +35 -9
- data/lib/clickwrap/models/request_evidence.rb +12 -5
- data/lib/clickwrap/request_evidence_policy.rb +14 -0
- data/lib/clickwrap/version.rb +1 -1
- data/lib/generators/clickwrap/install_generator.rb +7 -4
- 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,46 @@ 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
|
+
|
|
9
49
|
## [0.3.1] - 2026-08-20
|
|
10
50
|
|
|
11
51
|
### Changed — the rest of the collection friction, and the principle behind removing it
|
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/request-evidence.md
CHANGED
|
@@ -575,3 +575,27 @@ can never undo or stand in for the Clickwrap event. Clickwrap ships no Footprint
|
|
|
575
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 |
|
|
576
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 |
|
|
577
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
|
|
@@ -257,9 +257,21 @@ module Clickwrap
|
|
|
257
257
|
# and nothing finer, because that is what "IP geolocation" means in this
|
|
258
258
|
# gem when nobody narrows it. Naming even one field means you are
|
|
259
259
|
# choosing the set yourself, and then the set is exactly what you named.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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,
|
|
263
275
|
using: nil, encrypted: nil, delete_after: nil, retain_until: nil,
|
|
264
276
|
fail_if_unavailable: false, because: nil,
|
|
265
277
|
legal_basis_reference: nil,
|
|
@@ -415,14 +427,28 @@ module Clickwrap
|
|
|
415
427
|
end
|
|
416
428
|
end
|
|
417
429
|
|
|
418
|
-
#
|
|
419
|
-
# policy named it and turned it off".
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
# still reaches the coherence check that tells it to say
|
|
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
|
|
423
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.
|
|
424
440
|
def default_ip_geolocation_fields_when_none_named(named)
|
|
425
|
-
|
|
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?
|
|
426
452
|
|
|
427
453
|
Vocabulary::IP_GEOLOCATION_DATA_FIELDS.to_h do |field|
|
|
428
454
|
[field, Vocabulary::COARSE_IP_GEOLOCATION_DATA_FIELDS.include?(field)]
|
|
@@ -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,
|
data/lib/clickwrap/version.rb
CHANGED
|
@@ -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
|
|