Class: Clickwrap::Receipt
- Inherits:
-
Object
- Object
- Clickwrap::Receipt
- Defined in:
- lib/clickwrap/receipt.rb
Overview
The answer to "show me exactly what the application recorded."
One canonical JSON body per event, plus a human-readable projection of the same facts. The canonical form is what gets digested and independently verified, so it is built from the stored evidence alone: no Ruby object serialization, no current policy source, no database column order, no locale-dependent number formatting. A verifier written years from now in another language must be able to reproduce these bytes from the data.
Raw IP address, browser user-agent, and IP-geolocation values are NOT in the canonical body. They live in a separately encrypted annex with its own authorization, retention, hold, and disposition state, and the body carries only a keyed digest binding the two. That boundary is what lets the core event stay immutable and verifiable when a permitted retention process later removes the annex — deletion changes what a receipt can show, not what it says happened.
Constant Summary collapse
- SCHEMA =
"clickwrap.receipt.v1"- TIME_FORMAT =
The Clickwrap profile for timestamps: UTC, exactly six fractional digits,
Zsuffix. Fixed width so two verifiers never disagree about whether a trailing zero was significant. "%Y-%m-%dT%H:%M:%S.%6NZ"
Instance Attribute Summary collapse
-
#event ⇒ Object
readonly
Returns the value of attribute event.
Class Method Summary collapse
-
.export(receipt, requested_by: nil, because: nil, include_ip_address: false, include_browser_user_agent: false, include_ip_geolocation: false, access_channel: "export") ⇒ Object
An export names each sensitive field it includes.
- .find(event_id) ⇒ Object
- .format_time(time) ⇒ Object
-
.verify(canonical_json, documents: {}) ⇒ Object
Verifies a receipt with no host application, no database, and no policy source — the standalone path the CLI uses.
Instance Method Summary collapse
- #actor ⇒ Object
- #actor_reference ⇒ Object
- #as_json ⇒ Object
- #committed? ⇒ Boolean
- #digest_verified? ⇒ Boolean
- #documents ⇒ Object
-
#event_id ⇒ Object
--- Identity -------------------------------------------------------------.
- #held? ⇒ Boolean
-
#initialize(event) ⇒ Receipt
constructor
A new instance of Receipt.
- #inspect ⇒ Object
- #legal_holds ⇒ Object
-
#place_on_legal_hold!(because:, placed_by:, review_at:) ⇒ Object
--- Legal holds ----------------------------------------------------------.
- #policy_key ⇒ Object
- #recorded_at_by_server ⇒ Object
- #release_legal_hold!(because:, released_by:) ⇒ Object
- #request_evidence ⇒ Object
- #statements ⇒ Object
- #subject ⇒ Object
- #to_canonical_json ⇒ Object
-
#to_h ⇒ Object
The canonical, digestible body.
-
#to_html(view_context: nil) ⇒ Object
The human-readable projection.
- #to_json ⇒ Object
-
#to_pdf ⇒ Object
PDF is optional rendering of the receipt, never the source of truth.
- #to_s ⇒ Object
-
#verify ⇒ Object
--- Verification ---------------------------------------------------------.
Constructor Details
#initialize(event) ⇒ Receipt
Returns a new instance of Receipt.
32 33 34 |
# File 'lib/clickwrap/receipt.rb', line 32 def initialize(event) @event = event end |
Instance Attribute Details
#event ⇒ Object (readonly)
Returns the value of attribute event.
30 31 32 |
# File 'lib/clickwrap/receipt.rb', line 30 def event @event end |
Class Method Details
.export(receipt, requested_by: nil, because: nil, include_ip_address: false, include_browser_user_agent: false, include_ip_geolocation: false, access_channel: "export") ⇒ Object
An export names each sensitive field it includes. There is deliberately
no include_sensitive_context: true: one flag that turns on three
different categories of personal data is exactly the kind of option that
makes an operator's intent unreviewable.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/clickwrap/receipt.rb', line 63 def export(receipt, requested_by: nil, because: nil, include_ip_address: false, include_browser_user_agent: false, include_ip_geolocation: false, access_channel: "export") receipt = find(receipt) if receipt.is_a?(String) requested = { ip_address: include_ip_address, browser_user_agent: include_browser_user_agent, ip_geolocation: include_ip_geolocation } (receipt, requested, requested_by, because) # A redacted receipt contains no annex value and therefore performs no # privileged read. It needs neither an access row nor a transaction of # its own; making ordinary exports depend on commit context would make # harmless rendering fail inside otherwise unrelated host work. return receipt.to_h unless requested.value?(true) refuse_export_inside_an_outer_transaction! exported = nil Event.transaction(requires_new: true) do event = Event.lock.find(receipt.event.id) current_receipt = new(event) included = requested.transform_keys(&:to_s) requested_by_reference = Reference.actor(requested_by) ReceiptAccess.record!( event: event, requested_by: requested_by, because: because, included_fields: included, access_channel: access_channel ) Lifecycle.append_lifecycle_event!( event: event, event_type: "receipt_access", reason: because.presence || "Exported a redacted receipt", actor: requested_by, extra: { protected_outcome: { "receipt_access" => { "requested_by_reference" => requested_by_reference, "included_fields" => included, "access_channel" => access_channel.to_s }.compact } } ) exported = current_receipt.send( :to_h_with_revealed_request_evidence, requested.select { |_, wanted| wanted }.keys ) end exported end |
.find(event_id) ⇒ Object
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
# File 'lib/clickwrap/receipt.rb', line 37 def find(event_id) # The annex is eager-loaded only where it can exist. An installation # that records no request evidence never created that table, and # `includes` would read its schema before deciding there is nothing in # it. The receipt still reports every category by state — the states # just all come out `not_configured`, which is the true answer. associations = %i[statements documents policy_revision] associations << :request_evidence if SchemaRequirements.available?(:request_evidence) event = Event.includes(*associations).find_by(id: event_id) raise ReceiptInvalid, "No Clickwrap event with id #{event_id.inspect}" unless event new(event) end |
.format_time(time) ⇒ Object
53 54 55 56 57 |
# File 'lib/clickwrap/receipt.rb', line 53 def format_time(time) return nil if time.nil? time.utc.strftime(TIME_FORMAT) end |
.verify(canonical_json, documents: {}) ⇒ Object
Verifies a receipt with no host application, no database, and no policy source — the standalone path the CLI uses.
122 123 124 |
# File 'lib/clickwrap/receipt.rb', line 122 def verify(canonical_json, documents: {}) ReceiptVerifier.verify(canonical_json, documents: documents) end |
Instance Method Details
#actor ⇒ Object
163 |
# File 'lib/clickwrap/receipt.rb', line 163 def actor = event.actor |
#actor_reference ⇒ Object
162 |
# File 'lib/clickwrap/receipt.rb', line 162 def actor_reference = event.actor_reference |
#as_json ⇒ Object
212 |
# File 'lib/clickwrap/receipt.rb', line 212 def as_json(*) = to_h |
#committed? ⇒ Boolean
165 |
# File 'lib/clickwrap/receipt.rb', line 165 def committed? = true |
#digest_verified? ⇒ Boolean
258 |
# File 'lib/clickwrap/receipt.rb', line 258 def digest_verified? = event.digest_verified? |
#documents ⇒ Object
168 |
# File 'lib/clickwrap/receipt.rb', line 168 def documents = event.documents |
#event_id ⇒ Object
--- Identity -------------------------------------------------------------
159 |
# File 'lib/clickwrap/receipt.rb', line 159 def event_id = event.id |
#held? ⇒ Boolean
171 |
# File 'lib/clickwrap/receipt.rb', line 171 def held? = event.on_legal_hold? |
#inspect ⇒ Object
305 |
# File 'lib/clickwrap/receipt.rb', line 305 def inspect = "#<Clickwrap::Receipt #{event_id} #{policy_key}>" |
#legal_holds ⇒ Object
303 |
# File 'lib/clickwrap/receipt.rb', line 303 def legal_holds = LegalHold.for_event(event.id) |
#place_on_legal_hold!(because:, placed_by:, review_at:) ⇒ Object
--- Legal holds ----------------------------------------------------------
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/clickwrap/receipt.rb', line 262 def place_on_legal_hold!(because:, placed_by:, review_at:) SchemaRequirements.require!(:retention_ops) hold = nil ::ActiveRecord::Base.transaction do locked_event = Event.lock.find(event.id) hold = LegalHold.create!( hold_scope: "event", event_id: event.id, reason: because, placed_by_reference: reference_for(placed_by), placed_at: Clickwrap.now, review_at: review_at, created_at: Clickwrap.now ) locked_event.set_legal_hold!(true) Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_placed", reason: because, actor: placed_by) end hold end |
#policy_key ⇒ Object
160 |
# File 'lib/clickwrap/receipt.rb', line 160 def policy_key = event.policy_key |
#recorded_at_by_server ⇒ Object
161 |
# File 'lib/clickwrap/receipt.rb', line 161 def recorded_at_by_server = event.recorded_at_by_server |
#release_legal_hold!(because:, released_by:) ⇒ Object
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/clickwrap/receipt.rb', line 287 def release_legal_hold!(because:, released_by:) ::ActiveRecord::Base.transaction do locked_event = Event.lock.find(event.id) holds = LegalHold.lock.for_event(event.id).in_effect.to_a return nil if holds.empty? holds.each do |hold| hold.release!(because: because, released_by: released_by) end locked_event.set_legal_hold!(LegalHold.for_event(event.id).in_effect.exists?) Lifecycle.append_lifecycle_event!(event: locked_event, event_type: "legal_hold_released", reason: because, actor: released_by) end end |
#request_evidence ⇒ Object
169 |
# File 'lib/clickwrap/receipt.rb', line 169 def request_evidence = event.request_evidence |
#statements ⇒ Object
167 |
# File 'lib/clickwrap/receipt.rb', line 167 def statements = event.statements |
#subject ⇒ Object
164 |
# File 'lib/clickwrap/receipt.rb', line 164 def subject = event.subject |
#to_canonical_json ⇒ Object
208 |
# File 'lib/clickwrap/receipt.rb', line 208 def to_canonical_json = CanonicalJson.generate(to_h) |
#to_h ⇒ Object
The canonical, digestible body.
integrity.receipt_digest covers this exact body with only the
self-referential integrity.receipt_digest field removed — the digest
cannot cover itself, and the exclusion has to be one a verifier in another
language can reproduce without guessing. It is a
different value from integrity.event_digest, which the application
computed over the event's own canonical body when the event was written.
Both are reported, because they answer different questions: one says this
file has not been edited, the other says the row it describes has not been.
An export that reveals request evidence is a different document from a redacted one, so it carries a different receipt digest. That is correct: each file verifies as the file it actually is.
189 190 191 |
# File 'lib/clickwrap/receipt.rb', line 189 def to_h build_receipt_body(revealed_request_evidence: []) end |
#to_html(view_context: nil) ⇒ Object
The human-readable projection. Same facts, rendered — never a different set of facts, and never a stronger claim than the canonical body makes.
216 |
# File 'lib/clickwrap/receipt.rb', line 216 def to_html(view_context: nil) = ReceiptHtml.new(self, view_context: view_context).render |
#to_json ⇒ Object
210 |
# File 'lib/clickwrap/receipt.rb', line 210 def to_json(*) = to_canonical_json |
#to_pdf ⇒ Object
PDF is optional rendering of the receipt, never the source of truth. The gem ships no PDF dependency; a host configures a renderer if it wants one.
245 246 247 248 249 250 |
# File 'lib/clickwrap/receipt.rb', line 245 def to_pdf(*) raise ConfigurationError, "Clickwrap does not render PDFs itself — a PDF library is not a dependency of an " \ "evidence gem, and a PDF is a rendering of the receipt rather than the record. " \ "Render `to_html` with your own PDF pipeline if you need one." end |
#to_s ⇒ Object
307 |
# File 'lib/clickwrap/receipt.rb', line 307 def to_s = event_id |
#verify ⇒ Object
--- Verification ---------------------------------------------------------
254 255 256 |
# File 'lib/clickwrap/receipt.rb', line 254 def verify Verification.verify(event.id) end |