clickwrap 0.0.0 → 0.1.0
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/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module Integrity
|
|
5
|
+
# The adapter contract for a timestamp provider, plus a reference base
|
|
6
|
+
# implementation that issues no tokens and says so. Configuration defaults
|
|
7
|
+
# to nil.
|
|
8
|
+
#
|
|
9
|
+
# config.timestamp_receipts_with = MyRfc3161TimestampProvider.new
|
|
10
|
+
#
|
|
11
|
+
# WHAT THIS SEAM IS FOR. Clickwrap records `recorded_at_by_server`, and calls
|
|
12
|
+
# it exactly that: the application server's own clock, which the application
|
|
13
|
+
# controls. An RFC 3161 time-stamp authority
|
|
14
|
+
# (https://www.rfc-editor.org/info/rfc3161/) or a qualified trust service
|
|
15
|
+
# supplies something different — a token from a third party over a digest
|
|
16
|
+
# you gave it, whose value depends entirely on that party, its practice
|
|
17
|
+
# statement, its certificate status, and what a reader is willing to accept
|
|
18
|
+
# about it.
|
|
19
|
+
#
|
|
20
|
+
# WHAT SUCH A TOKEN CLAIMS. Exactly what that provider supplies and nothing
|
|
21
|
+
# more. Clickwrap stores the token and the validation status the adapter
|
|
22
|
+
# reports, and preserves both verbatim; it never upgrades a provider receipt
|
|
23
|
+
# into a guarantee the provider did not make, never treats a token as
|
|
24
|
+
# identity, never treats it as a signature by a person, and never restates
|
|
25
|
+
# it in stronger words than the provider used. If the provider's own status
|
|
26
|
+
# is "unknown" or "expired", that is what travels into the receipt. eIDAS
|
|
27
|
+
# gives distinct legal effect to qualified signatures and seals
|
|
28
|
+
# (https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng); whether a
|
|
29
|
+
# given provider's output has that effect is a question about that provider,
|
|
30
|
+
# not about this gem.
|
|
31
|
+
#
|
|
32
|
+
# WHAT THIS FILE DELIBERATELY IS NOT. It is not an RFC 3161 client. Clickwrap
|
|
33
|
+
# ships no ASN.1 encoder, no HTTP client, and no certificate-chain
|
|
34
|
+
# validation, and it adds no dependency that would. Timestamping is an
|
|
35
|
+
# optional integration. A host that needs it supplies an adapter that speaks
|
|
36
|
+
# to its own chosen authority; assigning this base class is useful only when
|
|
37
|
+
# an explicit unavailable result is wanted.
|
|
38
|
+
#
|
|
39
|
+
# WRITING ONE. Implement `#timestamp(digest)` and `#verify(token, digest)`,
|
|
40
|
+
# and report honestly from `#capabilities`.
|
|
41
|
+
# `Configuration#timestamp_receipts_with=` checks that the object responds to
|
|
42
|
+
# `#timestamp`.
|
|
43
|
+
class Timestamp
|
|
44
|
+
# What an adapter returns from `#timestamp`. `issued: false` is an
|
|
45
|
+
# ordinary outcome — no provider configured, provider unavailable, request
|
|
46
|
+
# declined — and the record says which rather than leaving a gap where a
|
|
47
|
+
# token should be.
|
|
48
|
+
Token = Data.define(:issued, :token, :digest, :provider_name, :protocol, :issued_at, :detail) do
|
|
49
|
+
def initialize(issued: false, token: nil, digest: nil, provider_name: nil, protocol: nil,
|
|
50
|
+
issued_at: nil, detail: nil)
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
"issued" => issued,
|
|
57
|
+
"token" => token,
|
|
58
|
+
"digest" => digest,
|
|
59
|
+
"provider_name" => provider_name,
|
|
60
|
+
"protocol" => protocol,
|
|
61
|
+
# The provider's own time, described as the provider's own time.
|
|
62
|
+
"provider_reported_time" => issued_at && Receipt.format_time(issued_at),
|
|
63
|
+
"detail" => detail
|
|
64
|
+
}.compact
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The result of checking a token. `status` carries the provider's own
|
|
69
|
+
# validation vocabulary unchanged, because "valid according to this
|
|
70
|
+
# authority today" is a narrower and more useful statement than "valid".
|
|
71
|
+
Verification = Data.define(:checked, :verified, :status, :provider_name, :protocol, :detail) do
|
|
72
|
+
def initialize(checked: false, verified: false, status: nil, provider_name: nil,
|
|
73
|
+
protocol: nil, detail: nil)
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
"checked" => checked,
|
|
80
|
+
"verified" => verified,
|
|
81
|
+
"provider_reported_status" => status,
|
|
82
|
+
"provider_name" => provider_name,
|
|
83
|
+
"protocol" => protocol,
|
|
84
|
+
"detail" => detail
|
|
85
|
+
}.compact
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Asks the provider to timestamp one digest. Never the raw receipt, never
|
|
90
|
+
# the personal data inside it: a digest is what a timestamp authority needs
|
|
91
|
+
# and the only thing it should ever be given.
|
|
92
|
+
#
|
|
93
|
+
# Called outside the capture transaction. A provider cannot join a
|
|
94
|
+
# database transaction, and a capture must never fail because a third
|
|
95
|
+
# party was slow.
|
|
96
|
+
def timestamp(digest)
|
|
97
|
+
Token.new(
|
|
98
|
+
issued: false,
|
|
99
|
+
digest: digest,
|
|
100
|
+
provider_name: provider_name,
|
|
101
|
+
detail: "This timestamp adapter issues no token, so the only " \
|
|
102
|
+
"recorded time remains the application server's own."
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Checks a stored token against the digest it was issued over, and reports
|
|
107
|
+
# what the provider said. A token nobody re-checks is a stored blob.
|
|
108
|
+
def verify(_token, _digest)
|
|
109
|
+
Verification.new(
|
|
110
|
+
checked: false,
|
|
111
|
+
verified: false,
|
|
112
|
+
provider_name: provider_name,
|
|
113
|
+
detail: "This timestamp adapter issues no token, so there is nothing to check in this " \
|
|
114
|
+
"token."
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# What this adapter supplies, in the provider's own terms. Read by the
|
|
119
|
+
# receipt's integrity fragment and by `clickwrap:doctor`, which report the
|
|
120
|
+
# tier honestly rather than inferring a stronger one from the mere
|
|
121
|
+
# presence of an adapter.
|
|
122
|
+
def capabilities
|
|
123
|
+
{
|
|
124
|
+
"name" => provider_name,
|
|
125
|
+
"available" => available?,
|
|
126
|
+
"protocol" => nil,
|
|
127
|
+
"supplies" => "Nothing. This is the default placeholder that reports the absence of a " \
|
|
128
|
+
"timestamp provider for explicit adapter-contract tests. Configuration " \
|
|
129
|
+
"normally remains nil.",
|
|
130
|
+
"note" => "A configured provider supplies exactly the assurance and validation status " \
|
|
131
|
+
"that provider supplies. Clickwrap preserves it and never restates it more " \
|
|
132
|
+
"strongly."
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def available? = false
|
|
137
|
+
|
|
138
|
+
def provider_name = "no_timestamp_provider"
|
|
139
|
+
|
|
140
|
+
def to_s = "#{self.class.name} (#{provider_name})"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module IpGeolocation
|
|
5
|
+
# One provider's estimate about ONE IP address, at one moment.
|
|
6
|
+
#
|
|
7
|
+
# Read that sentence literally, because the whole design follows from it.
|
|
8
|
+
# This object never describes where a person was. It describes what a
|
|
9
|
+
# provider's database or edge network said about a network address, with
|
|
10
|
+
# enough provenance attached that someone reading it in four years can tell
|
|
11
|
+
# how much weight it deserves. That is why provider name, source, estimated
|
|
12
|
+
# state, resolution time, and any database or accuracy metadata are members
|
|
13
|
+
# of the same value object as the country code: a country without the
|
|
14
|
+
# provider that guessed it, or coordinates without an accuracy radius, read
|
|
15
|
+
# as far more certain than they are.
|
|
16
|
+
#
|
|
17
|
+
# Every member defaults to nil so a resolver fills in only what it actually
|
|
18
|
+
# has. A resolver must NEVER substitute a placeholder — "Unknown", "XX",
|
|
19
|
+
# "N/A" — for a field it could not determine. A placeholder is
|
|
20
|
+
# indistinguishable from a real provider answer once it is written down, and
|
|
21
|
+
# Clickwrap keeps "we did not collect this", "the provider had no value",
|
|
22
|
+
# "the lookup failed", and "the provider answered" as four different states.
|
|
23
|
+
# nil is the honest answer; `unavailable_reason` is how a resolver explains
|
|
24
|
+
# a failure.
|
|
25
|
+
#
|
|
26
|
+
# `to_h` exists because this is a Data object. It is not a persistence
|
|
27
|
+
# format: the extractor copies out exactly the fields the server-owned
|
|
28
|
+
# policy authorized, one at a time, and a resolver that gains a new field
|
|
29
|
+
# upstream never widens what Clickwrap stores by accident.
|
|
30
|
+
Location = Data.define(
|
|
31
|
+
:country_code,
|
|
32
|
+
:country_name,
|
|
33
|
+
:region_name,
|
|
34
|
+
:region_code,
|
|
35
|
+
:city_name,
|
|
36
|
+
:postal_code,
|
|
37
|
+
:latitude,
|
|
38
|
+
:longitude,
|
|
39
|
+
:timezone,
|
|
40
|
+
:continent_code,
|
|
41
|
+
:metro_code,
|
|
42
|
+
:provider_name,
|
|
43
|
+
:provider_source,
|
|
44
|
+
:database_version,
|
|
45
|
+
:database_sha256,
|
|
46
|
+
:accuracy_radius_in_kilometers,
|
|
47
|
+
:accuracy_radius_confidence_percentage,
|
|
48
|
+
:estimated,
|
|
49
|
+
:source_was_verified_by_host,
|
|
50
|
+
:resolved_at,
|
|
51
|
+
:unavailable_reason
|
|
52
|
+
) do
|
|
53
|
+
# `estimated` defaults to true and `source_was_verified_by_host` to false
|
|
54
|
+
# because those are the answers that overclaim least. A resolver has to
|
|
55
|
+
# say something deliberate to move either one, and moving
|
|
56
|
+
# `source_was_verified_by_host` requires a host decision about its own
|
|
57
|
+
# network path — never the mere presence of a provider's headers.
|
|
58
|
+
def initialize(country_code: nil, country_name: nil, region_name: nil, region_code: nil,
|
|
59
|
+
city_name: nil, postal_code: nil, latitude: nil, longitude: nil,
|
|
60
|
+
timezone: nil, continent_code: nil, metro_code: nil,
|
|
61
|
+
provider_name: nil, provider_source: nil, database_version: nil,
|
|
62
|
+
database_sha256: nil, accuracy_radius_in_kilometers: nil,
|
|
63
|
+
accuracy_radius_confidence_percentage: nil, estimated: true,
|
|
64
|
+
source_was_verified_by_host: false, resolved_at: nil,
|
|
65
|
+
unavailable_reason: nil)
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The answer a resolver returns when it has nothing to report. It still
|
|
70
|
+
# names the provider that was asked, because "MaxMind had no row for this
|
|
71
|
+
# address" and "no resolver was configured at all" are different facts and
|
|
72
|
+
# a receipt has to be able to tell them apart.
|
|
73
|
+
def self.unavailable(reason:, provider_name: nil, **provenance)
|
|
74
|
+
new(unavailable_reason: reason.to_s, provider_name:, **provenance)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def unavailable? = !unavailable_reason.to_s.strip.empty?
|
|
78
|
+
|
|
79
|
+
# An IP-geolocation result is an estimate about an address. This reader
|
|
80
|
+
# reports what the resolver said about its own result rather than
|
|
81
|
+
# hard-coding the answer, but a resolver claiming otherwise still does not
|
|
82
|
+
# turn an observation about a network address into a statement about where
|
|
83
|
+
# anyone was, and the receipt goes on labeling the value provider-reported
|
|
84
|
+
# either way.
|
|
85
|
+
def estimated? = estimated != false
|
|
86
|
+
|
|
87
|
+
# True only when the HOST told Clickwrap that this result arrived over a
|
|
88
|
+
# path it has verified. No adapter may set it from the presence of a
|
|
89
|
+
# provider's own headers: headers are attacker-supplied until the
|
|
90
|
+
# deployment proves otherwise.
|
|
91
|
+
def source_was_verified_by_host? = source_was_verified_by_host == true
|
|
92
|
+
|
|
93
|
+
# Latitude and longitude are one coupled answer. Half a coordinate is not
|
|
94
|
+
# a result, so nothing downstream is allowed to store one without the
|
|
95
|
+
# other.
|
|
96
|
+
def coordinates? = !latitude.nil? && !longitude.nil?
|
|
97
|
+
|
|
98
|
+
def accuracy_radius? = !accuracy_radius_in_kilometers.nil?
|
|
99
|
+
|
|
100
|
+
# True when the provider actually reported something about the address, as
|
|
101
|
+
# opposed to handing back a row of blanks. An adapter uses this to turn an
|
|
102
|
+
# empty answer into an explicit unavailable result with a reason, which is
|
|
103
|
+
# the whole point of refusing to write "Unknown" into a name column: an
|
|
104
|
+
# empty result and a real one must never look the same.
|
|
105
|
+
def any_data_field?
|
|
106
|
+
[country_code, country_name, region_name, region_code, city_name, postal_code,
|
|
107
|
+
latitude, longitude, timezone, continent_code, metro_code,
|
|
108
|
+
accuracy_radius_in_kilometers].any? { |value| !value.nil? }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module IpGeolocation
|
|
5
|
+
# The resolver Clickwrap uses when the host configured none.
|
|
6
|
+
#
|
|
7
|
+
# It exists so that "no IP geolocation provider is installed" is an ordinary
|
|
8
|
+
# answer with a reason on it, rather than a branch that has to be handled
|
|
9
|
+
# separately everywhere. Every capture path runs the same code; a host with
|
|
10
|
+
# no geolocation gem gets `ip_geolocation_unavailable_reason` set to
|
|
11
|
+
# `no_resolver_configured`, which is a true statement about the application
|
|
12
|
+
# and reads correctly on a receipt years later.
|
|
13
|
+
#
|
|
14
|
+
# That also means the entire request-evidence feature — policies, field
|
|
15
|
+
# allowlists, retention, receipts, disposal — is testable and reviewable
|
|
16
|
+
# without a provider account, a database download, or a network call.
|
|
17
|
+
#
|
|
18
|
+
# A policy that authorizes IP-geolocation fields while no resolver is
|
|
19
|
+
# configured is caught earlier, by `Configuration#validate!`, with a
|
|
20
|
+
# sentence saying so. This resolver is the safety net under that check, not
|
|
21
|
+
# a way to pretend the configuration is fine.
|
|
22
|
+
class NullResolver < Resolver
|
|
23
|
+
REASON = "no_resolver_configured"
|
|
24
|
+
|
|
25
|
+
def resolve(_ip_address, http_request: nil)
|
|
26
|
+
Location.unavailable(reason: REASON)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Nothing. Not "everything but it always fails" — a host reading
|
|
30
|
+
# `capabilities` is asking what this resolver could ever supply, and the
|
|
31
|
+
# honest answer is no field at all.
|
|
32
|
+
def capabilities = [].freeze
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
# IP geolocation adapters.
|
|
5
|
+
#
|
|
6
|
+
# Clickwrap has no geolocation dependency and never will. What it has is this
|
|
7
|
+
# narrow contract, one no-op default that lets the whole feature be exercised
|
|
8
|
+
# without any provider, a deterministic fixture resolver for tests, and one
|
|
9
|
+
# optional official adapter for `trackdown`. A host that wants a different
|
|
10
|
+
# provider writes forty lines against the contract below and configures it.
|
|
11
|
+
module IpGeolocation
|
|
12
|
+
# The adapter contract every IP geolocation resolver implements.
|
|
13
|
+
#
|
|
14
|
+
# class MyResolver < Clickwrap::IpGeolocation::Resolver
|
|
15
|
+
# def resolve(ip_address, http_request: nil)
|
|
16
|
+
# row = MyProvider.lookup(ip_address, http_request:) or
|
|
17
|
+
# return Location.unavailable(reason: "provider_had_no_row", provider_name: "my_provider")
|
|
18
|
+
#
|
|
19
|
+
# Location.new(
|
|
20
|
+
# country_code: row.country_iso,
|
|
21
|
+
# provider_name: "my_provider",
|
|
22
|
+
# provider_source: "my_provider_city_database",
|
|
23
|
+
# database_version: row.database_build,
|
|
24
|
+
# estimated: true,
|
|
25
|
+
# resolved_at: Clickwrap.now
|
|
26
|
+
# )
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def capabilities = %i[country]
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Subclassing is optional — `Clickwrap.config.ip_geolocation_resolver`
|
|
33
|
+
# accepts anything that responds to `#resolve` — but inheriting documents
|
|
34
|
+
# the intent and gives you the contract's own error messages when a method
|
|
35
|
+
# is missing.
|
|
36
|
+
#
|
|
37
|
+
# Three obligations that are easy to miss, and that the rest of the gem
|
|
38
|
+
# depends on:
|
|
39
|
+
#
|
|
40
|
+
# 1. Return nil ONLY for a field the provider genuinely did not supply.
|
|
41
|
+
# Never a placeholder string. "Unknown" written into a country name is
|
|
42
|
+
# indistinguishable from a country a provider actually reported, and
|
|
43
|
+
# collapsing those two states is exactly what a receipt must not do.
|
|
44
|
+
#
|
|
45
|
+
# 2. Say who answered. Set `provider_name`, and `provider_source` when the
|
|
46
|
+
# provider has more than one source (a local city database and an edge
|
|
47
|
+
# network are not the same evidence). Attach `database_version`,
|
|
48
|
+
# `database_sha256`, `accuracy_radius_in_kilometers`, and
|
|
49
|
+
# `accuracy_radius_confidence_percentage` whenever the provider gives
|
|
50
|
+
# them: the extractor stores that provenance with any coordinate it
|
|
51
|
+
# keeps, because coordinates without uncertainty overclaim.
|
|
52
|
+
#
|
|
53
|
+
# 3. Never infer trust from a provider's own headers. Leave
|
|
54
|
+
# `source_was_verified_by_host` false unless the HOST has explicitly
|
|
55
|
+
# told the adapter that its deployment blocks direct origin access or
|
|
56
|
+
# sanitizes those headers. A `CF-*` header is a client-supplied string
|
|
57
|
+
# until the deployment proves otherwise.
|
|
58
|
+
#
|
|
59
|
+
# A resolver is called SYNCHRONOUSLY, before the evidence and domain
|
|
60
|
+
# transaction opens, so it must be fast and must not raise for ordinary
|
|
61
|
+
# conditions. A private, loopback, or reserved address is an ordinary
|
|
62
|
+
# condition: return `Location.unavailable(...)`, do not raise. Clickwrap
|
|
63
|
+
# rescues a raising resolver and records the failure rather than losing it,
|
|
64
|
+
# but a reason string you chose is better evidence than an exception class
|
|
65
|
+
# Clickwrap had to guess a name from.
|
|
66
|
+
class Resolver
|
|
67
|
+
# Estimate a location for one observed IP address.
|
|
68
|
+
#
|
|
69
|
+
# Returns a Location — populated, or `Location.unavailable(reason:,
|
|
70
|
+
# provider_name:)`. Returning nil is permitted for an adapter that has
|
|
71
|
+
# nothing at all to say; Clickwrap records it as `resolver_returned_no_result`,
|
|
72
|
+
# which is a less useful receipt than a reason you wrote yourself.
|
|
73
|
+
def resolve(ip_address, http_request: nil)
|
|
74
|
+
raise NotImplementedError,
|
|
75
|
+
"#{self.class} must implement #resolve(ip_address, http_request: nil) and return a " \
|
|
76
|
+
"Clickwrap::IpGeolocation::Location (or nil). See the contract in " \
|
|
77
|
+
"lib/clickwrap/ip_geolocation/resolver.rb."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Which of `Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS` this
|
|
81
|
+
# resolver can supply at all, as symbols.
|
|
82
|
+
#
|
|
83
|
+
# This is a statement about the adapter and its provider, not about any
|
|
84
|
+
# one lookup: a Cloudflare header set structurally supplies no accuracy
|
|
85
|
+
# radius, which is a different fact from a MaxMind database that supplies
|
|
86
|
+
# one but had no value for this address. Clickwrap uses the difference to
|
|
87
|
+
# explain an empty result honestly, and `clickwrap:doctor` uses it to tell
|
|
88
|
+
# a host that a policy authorizes a field its resolver can never fill.
|
|
89
|
+
def capabilities
|
|
90
|
+
raise NotImplementedError,
|
|
91
|
+
"#{self.class} must implement #capabilities and return the subset of " \
|
|
92
|
+
"Clickwrap::Vocabulary::IP_GEOLOCATION_DATA_FIELDS it can supply, as symbols. " \
|
|
93
|
+
"Return [] if it can supply none."
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clickwrap
|
|
4
|
+
module IpGeolocation
|
|
5
|
+
# A resolver that answers from a fixed table of addresses.
|
|
6
|
+
#
|
|
7
|
+
# Clickwrap.config.ip_geolocation_resolver =
|
|
8
|
+
# Clickwrap::IpGeolocation::StaticResolver.new(
|
|
9
|
+
# "203.0.113.10" => {
|
|
10
|
+
# country_code: "ES",
|
|
11
|
+
# country_name: "Spain",
|
|
12
|
+
# city_name: "Madrid",
|
|
13
|
+
# latitude: 40.4168,
|
|
14
|
+
# longitude: -3.7038,
|
|
15
|
+
# accuracy_radius_in_kilometers: 20,
|
|
16
|
+
# provider_source: "test_city_database",
|
|
17
|
+
# database_version: "2026-08-01"
|
|
18
|
+
# }
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# This is what the gem's own tests run against, and it is the right resolver
|
|
22
|
+
# for a host's test and development environments too. A real provider makes
|
|
23
|
+
# request-evidence tests non-deterministic in the one place where
|
|
24
|
+
# determinism matters most: an assertion about exactly which fields a policy
|
|
25
|
+
# stored has to fail because the ALLOWLIST changed, never because a database
|
|
26
|
+
# was rebuilt or an address was reassigned between two runs.
|
|
27
|
+
#
|
|
28
|
+
# Values may be given as attribute hashes or as Location objects. An address
|
|
29
|
+
# that is not in the table resolves to an explicit unavailable result — not
|
|
30
|
+
# to a blank one, and not to a plausible-looking default, because a fixture
|
|
31
|
+
# that quietly invents a country is a test that proves nothing.
|
|
32
|
+
class StaticResolver < Resolver
|
|
33
|
+
PROVIDER_NAME = "static_ip_geolocation_fixture"
|
|
34
|
+
NO_FIXTURE_REASON = "no_fixture_for_this_ip_address"
|
|
35
|
+
|
|
36
|
+
# The fixture table can be written straight into the call, which is how it
|
|
37
|
+
# reads best in a test:
|
|
38
|
+
#
|
|
39
|
+
# StaticResolver.new("203.0.113.10" => { country_code: "ES" })
|
|
40
|
+
#
|
|
41
|
+
# Ruby hands a trailing bare hash over as keyword arguments, so those
|
|
42
|
+
# addresses arrive in `fixtures` and are merged with the positional form
|
|
43
|
+
# below. Wrap the table in braces when you also pass an option:
|
|
44
|
+
#
|
|
45
|
+
# StaticResolver.new({ "203.0.113.10" => { ... } }, capabilities: %i[country])
|
|
46
|
+
#
|
|
47
|
+
# `capabilities:` is worth setting deliberately. It models what a provider
|
|
48
|
+
# can supply AT ALL, which is a different fact from what it happened to
|
|
49
|
+
# return for one address: a Cloudflare visitor-header set supplies no
|
|
50
|
+
# accuracy radius ever, while a MaxMind city database supplies one but may
|
|
51
|
+
# have no value for a particular address. Clickwrap explains an empty
|
|
52
|
+
# result differently in those two cases, so a fixture that wants to
|
|
53
|
+
# exercise the Cloudflare shape should say
|
|
54
|
+
# `capabilities: %i[country region city latitude_and_longitude timezone]`.
|
|
55
|
+
def initialize(locations_by_ip_address = {}, capabilities: nil, provider_name: PROVIDER_NAME,
|
|
56
|
+
**fixtures)
|
|
57
|
+
super()
|
|
58
|
+
# Written as an explicit keyword, the table lands in `fixtures` like any
|
|
59
|
+
# other trailing hash. Take it back out so both spellings mean the same
|
|
60
|
+
# thing, rather than one of them quietly building a table with a single
|
|
61
|
+
# nonsense entry named after the parameter.
|
|
62
|
+
declared = fixtures.delete(:locations_by_ip_address).to_h
|
|
63
|
+
|
|
64
|
+
@provider_name = provider_name.to_s
|
|
65
|
+
@locations_by_ip_address = build_table(locations_by_ip_address.to_h.merge(declared).merge(fixtures)).freeze
|
|
66
|
+
@capabilities = normalize_capabilities(capabilities).freeze
|
|
67
|
+
|
|
68
|
+
freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :capabilities
|
|
72
|
+
|
|
73
|
+
def resolve(ip_address, http_request: nil)
|
|
74
|
+
@locations_by_ip_address.fetch(ip_address.to_s.strip) do
|
|
75
|
+
Location.unavailable(reason: NO_FIXTURE_REASON, provider_name: @provider_name)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def build_table(declaration)
|
|
82
|
+
declaration.to_h { |ip_address, location| [ip_address.to_s.strip, build_location(location)] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A fixture written as a hash still comes back as a fully formed result:
|
|
86
|
+
# provider name and resolution time are filled in when the hash omits
|
|
87
|
+
# them, because a stored IP-geolocation value without its provenance is
|
|
88
|
+
# exactly what the extractor refuses to write, and a fixture that could
|
|
89
|
+
# not survive that rule would be testing the wrong thing.
|
|
90
|
+
def build_location(location)
|
|
91
|
+
return location if location.is_a?(Location)
|
|
92
|
+
|
|
93
|
+
attributes = location.to_h.transform_keys(&:to_sym)
|
|
94
|
+
attributes[:provider_name] ||= @provider_name
|
|
95
|
+
attributes[:resolved_at] ||= Clickwrap.now
|
|
96
|
+
|
|
97
|
+
Location.new(**attributes)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_capabilities(declared)
|
|
101
|
+
return Vocabulary::IP_GEOLOCATION_DATA_FIELDS.map(&:to_sym) if declared.nil?
|
|
102
|
+
|
|
103
|
+
Array(declared).map { |field| field.to_s.to_sym }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|