agreements 0.4.0 → 0.4.1
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 +5 -0
- data/README.md +380 -263
- data/SECURITY.md +12 -1
- data/lib/agreements/version.rb +1 -1
- metadata +10 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b66e31588c3340afd328330ca457833673b95a0a37e446f21c8f792b1aa7bd9b
|
|
4
|
+
data.tar.gz: 301e25cb6b409ef8267c7aabf3f819e4877207bc85353ddcab988b48a6bf4aa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a847857ad1cd3391d5f97b4e1491023dfc6d853bb76589c5f7b24ac58554ac7dbfe598360b66b1f92a05a2ce4aa99e9d081eac7c8bd2ece7390081d2ac6ffc70
|
|
7
|
+
data.tar.gz: edfdbb93fb962b18232e1794f1aee4303ea0c4941ab8e392df2dae446a013771c701274f36b3c17a088f2d6cb83237c6dea73630cb83e8e32c815d808e1879a2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1 - 2026-11-12
|
|
4
|
+
|
|
5
|
+
- Rewrite the README around a quick start, guides and an API reference.
|
|
6
|
+
- Clearer gem summary and description, and a documentation link on RubyGems.
|
|
7
|
+
|
|
3
8
|
## 0.4.0 - 2026-11-12
|
|
4
9
|
|
|
5
10
|
- Add an optional `effective_at` to versions, so a version can be inserted when
|
data/README.md
CHANGED
|
@@ -1,64 +1,120 @@
|
|
|
1
1
|
# agreements
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://rubygems.org/gems/agreements)
|
|
4
|
+
[](https://github.com/yshmarov/agreements/actions/workflows/ci.yml)
|
|
5
|
+
[](MIT-LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
> Which agreement version and localized statement did this subject accept,
|
|
8
|
-
> who performed the acceptance, under what authority, and when?
|
|
9
|
-
|
|
10
|
-
It deliberately does not host legal documents or provide a legal CMS. Your
|
|
11
|
-
legal or marketing site remains authoritative; the gem stores immutable
|
|
12
|
-
version metadata and append-only acceptance evidence in your own database.
|
|
13
|
-
|
|
14
|
-
## Install
|
|
7
|
+
Record who accepted which version of your Terms, Privacy Notice or Data
|
|
8
|
+
Processing Agreement, and ask again when the documents change. For Rails.
|
|
15
9
|
|
|
16
10
|
```ruby
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
Agreements.pending_version("user_terms", subject: current_user)
|
|
12
|
+
# => nil when the user accepted the current Terms, or the version they still need to accept
|
|
19
13
|
```
|
|
20
14
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
15
|
+
## Why you need it
|
|
16
|
+
|
|
17
|
+
Most apps store terms acceptance as a timestamp, such as
|
|
18
|
+
`users.accepted_terms_at`. That works until somebody asks a real question:
|
|
19
|
+
|
|
20
|
+
- **Which version** of the Terms did this user accept, and what did it say?
|
|
21
|
+
- **What exactly did they tick?** In which language?
|
|
22
|
+
- **Who accepted for the company?** Was it the account owner or an ordinary
|
|
23
|
+
member?
|
|
24
|
+
- **The Terms changed last month.** Who still has to accept the new version?
|
|
25
|
+
- **The form was open during a deploy.** Did the user accept the old version or
|
|
26
|
+
the new one?
|
|
27
|
+
|
|
28
|
+
Procurement teams, auditors and lawyers ask these questions. A timestamp cannot
|
|
29
|
+
answer them. This gem can, with two database tables and a few method calls.
|
|
30
|
+
|
|
31
|
+
## What it looks like in your app
|
|
32
|
+
|
|
33
|
+
1. **First deploy.** Every signed-in user who has not accepted your Terms is
|
|
34
|
+
sent to your acceptance page on their next page view. They tick the box and
|
|
35
|
+
continue to the page they wanted.
|
|
36
|
+
2. **Your lawyer updates the Terms.** You publish the new document and add the
|
|
37
|
+
new version with the date it takes effect, for example in 14 days. Nothing
|
|
38
|
+
changes for users before that date. You can show a notice in the meantime.
|
|
39
|
+
3. **On the effective date,** each user is asked once to accept the new version.
|
|
40
|
+
4. **An auditor asks** "Who accepted the DPA for Acme, and what did they see?"
|
|
41
|
+
You answer with one query:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
agreement: organization_dpa, version 2026-09-13
|
|
45
|
+
subject: gid://my-app/Organization/42 the party bound by the agreement
|
|
46
|
+
actor: gid://my-app/User/7 the person who clicked
|
|
47
|
+
authority: organization_owner why that person could accept
|
|
48
|
+
statement: "J'accepte le contrat de traitement des données au nom de cette organisation."
|
|
49
|
+
locale: fr
|
|
50
|
+
accepted: 2026-09-14 09:12:03 UTC
|
|
25
51
|
```
|
|
26
52
|
|
|
27
|
-
|
|
28
|
-
view, initializer, JavaScript, CSS, or admin dashboard to integrate.
|
|
53
|
+
Records cannot be changed or deleted through the model API.
|
|
29
54
|
|
|
30
|
-
|
|
55
|
+
## Is it for you?
|
|
31
56
|
|
|
32
|
-
|
|
57
|
+
**Use it when**
|
|
33
58
|
|
|
34
|
-
|
|
59
|
+
- you have Terms, a Privacy Notice or a DPA, and you must prove acceptance later;
|
|
60
|
+
- a company accepts an agreement through one of its users (B2B SaaS);
|
|
61
|
+
- your documents change, and you must ask people again;
|
|
62
|
+
- you show the acceptance checkbox in more than one language.
|
|
35
63
|
|
|
36
|
-
|
|
37
|
-
bin/rails generate migration AddEffectiveAtToAgreementsVersions effective_at:datetime
|
|
38
|
-
bin/rails db:migrate
|
|
39
|
-
```
|
|
64
|
+
**Use something else when**
|
|
40
65
|
|
|
41
|
-
|
|
66
|
+
- you need signatures for contracts. Use an e-signature service;
|
|
67
|
+
- you need to record and withdraw GDPR consent, such as marketing consent;
|
|
68
|
+
- you need IP addresses, signed receipts or a ready-made form builder.
|
|
69
|
+
[clickwrap](https://github.com/rameerez/clickwrap) covers that bigger scope.
|
|
42
70
|
|
|
43
|
-
|
|
71
|
+
## Trust
|
|
44
72
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
73
|
+
- **In production.** [EthicsPortal](https://ethicsportal.eu), whistleblower
|
|
74
|
+
software for EU organisations, uses it for user Terms and organisation DPAs.
|
|
75
|
+
The gem was extracted from that code in August 2026.
|
|
76
|
+
- **Small.** About 250 lines of Ruby. You can read all of it in one sitting.
|
|
77
|
+
- **Tested** in CI on every push: Ruby 3.2 to 4.0 with Rails 7.1 to 8.1 on
|
|
78
|
+
SQLite, and Rails 8.1 on PostgreSQL.
|
|
79
|
+
- **Safe releases.** Releases are built and published from
|
|
80
|
+
[GitHub Actions](.github/workflows/release.yml) with RubyGems trusted
|
|
81
|
+
publishing. Gem ownership requires multi-factor authentication.
|
|
82
|
+
- **Few dependencies.** Action Pack, Active Record, Railties and Global ID. No
|
|
83
|
+
JavaScript, no background jobs, no external services.
|
|
84
|
+
- **Your data stays in your database.** The gem makes no network calls and
|
|
85
|
+
collects no telemetry. It does not store IP addresses or user agents.
|
|
86
|
+
- **Security:** see [SECURITY.md](SECURITY.md). Report vulnerabilities through
|
|
87
|
+
[private reporting](https://github.com/yshmarov/agreements/security/advisories/new).
|
|
88
|
+
- **Changes** are listed in the [CHANGELOG](CHANGELOG.md). See
|
|
89
|
+
[Versioning](#versioning) for what may break before 1.0.
|
|
48
90
|
|
|
49
|
-
|
|
91
|
+
## Quick start
|
|
50
92
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
93
|
+
Ruby 3.2 or later, Rails 7.1 or later. The example uses a `Current.user`, as
|
|
94
|
+
created by the Rails 8 authentication generator. Use your own current user.
|
|
95
|
+
|
|
96
|
+
### 1. Install
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Gemfile
|
|
100
|
+
gem "agreements"
|
|
101
|
+
```
|
|
55
102
|
|
|
56
|
-
|
|
103
|
+
```bash
|
|
104
|
+
bundle install
|
|
105
|
+
bin/rails generate agreements:install
|
|
106
|
+
bin/rails db:migrate
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The generator adds one migration with two tables: `agreements_versions` and
|
|
110
|
+
`agreements_acceptances`. It adds no routes, views or configuration.
|
|
57
111
|
|
|
58
|
-
|
|
59
|
-
|
|
112
|
+
### 2. Add your first version
|
|
113
|
+
|
|
114
|
+
A version is data that you deploy. Add it in a migration:
|
|
60
115
|
|
|
61
116
|
```ruby
|
|
117
|
+
# db/migrate/20260816000000_add_user_terms_2026_08_16.rb
|
|
62
118
|
class AddUserTerms20260816 < ActiveRecord::Migration[8.1]
|
|
63
119
|
class Version < ActiveRecord::Base
|
|
64
120
|
self.table_name = "agreements_versions"
|
|
@@ -91,193 +147,221 @@ class AddUserTerms20260816 < ActiveRecord::Migration[8.1]
|
|
|
91
147
|
end
|
|
92
148
|
```
|
|
93
149
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
A version
|
|
100
|
-
|
|
101
|
-
immutable row with a new version label.
|
|
150
|
+
- Use your own Rails version in `Migration[8.1]`.
|
|
151
|
+
- The documents stay on your website. The gem stores only their title, URL and
|
|
152
|
+
an optional SHA-256 digest.
|
|
153
|
+
- The migration defines its own model, so it keeps working when the gem changes.
|
|
154
|
+
- The final check stops a deploy if the version already exists with different
|
|
155
|
+
content. A deployed version must never change.
|
|
156
|
+
- Add the same versions to `db/seeds.rb`, so a fresh database has them too.
|
|
102
157
|
|
|
103
|
-
###
|
|
104
|
-
|
|
105
|
-
Terms often promise notice before a change applies, for example "at least 14
|
|
106
|
-
days before the changes take effect". Insert the version when you send the
|
|
107
|
-
notice, with the date it takes effect:
|
|
158
|
+
### 3. Add the acceptance page
|
|
108
159
|
|
|
109
160
|
```ruby
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
version: "2026-12-01",
|
|
113
|
-
effective_at: Time.utc(2026, 12, 1),
|
|
114
|
-
# acceptance_statement, documents ...
|
|
115
|
-
}.freeze
|
|
161
|
+
# config/routes.rb
|
|
162
|
+
resource :terms_acceptance, only: %i[show create]
|
|
116
163
|
```
|
|
117
164
|
|
|
118
|
-
Until that time the previous version stays current, nobody is asked to accept,
|
|
119
|
-
and `accept!` refuses the new version. From that time the new version is
|
|
120
|
-
current and every subject who has not accepted it is pending. Show the notice
|
|
121
|
-
in your app with:
|
|
122
|
-
|
|
123
165
|
```ruby
|
|
124
|
-
|
|
125
|
-
|
|
166
|
+
# app/controllers/terms_acceptances_controller.rb
|
|
167
|
+
class TermsAcceptancesController < ApplicationController
|
|
168
|
+
skip_before_action :require_terms
|
|
169
|
+
|
|
170
|
+
def show
|
|
171
|
+
@version = Agreements.current_version("user_terms")
|
|
172
|
+
raise ActiveRecord::RecordNotFound unless @version
|
|
173
|
+
end
|
|
126
174
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
def create
|
|
176
|
+
@version = Agreements.current_version("user_terms")
|
|
177
|
+
return render(:show, status: :unprocessable_content) unless params.dig(:acceptance, :confirmed) == "1"
|
|
178
|
+
|
|
179
|
+
Agreements.accept!(
|
|
180
|
+
"user_terms",
|
|
181
|
+
version_id: params.dig(:acceptance, :agreement_version_id),
|
|
182
|
+
subject: Current.user,
|
|
183
|
+
actor: Current.user,
|
|
184
|
+
authority: "self",
|
|
185
|
+
acceptance_statement: t("terms_acceptances.statement"),
|
|
186
|
+
locale: I18n.locale.to_s
|
|
187
|
+
)
|
|
188
|
+
redirect_to agreement_return_location || root_path, status: :see_other
|
|
189
|
+
rescue Agreements::VersionNotCurrent => error
|
|
190
|
+
raise ActiveRecord::RecordNotFound unless error.current_version
|
|
191
|
+
|
|
192
|
+
@version = error.current_version
|
|
193
|
+
flash.now[:alert] = t("terms_acceptances.changed")
|
|
194
|
+
render :show, status: :unprocessable_content
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
```
|
|
130
198
|
|
|
131
|
-
|
|
132
|
-
|
|
199
|
+
```erb
|
|
200
|
+
<%# app/views/terms_acceptances/show.html.erb %>
|
|
201
|
+
<%= form_with scope: :acceptance, url: terms_acceptance_path do |form| %>
|
|
202
|
+
<%= form.hidden_field :agreement_version_id, value: @version.id %>
|
|
203
|
+
|
|
204
|
+
<%= form.label :confirmed do %>
|
|
205
|
+
<%= form.check_box :confirmed, required: true %>
|
|
206
|
+
<%= t("terms_acceptances.statement") %>
|
|
207
|
+
<% end %>
|
|
208
|
+
|
|
209
|
+
<ul>
|
|
210
|
+
<% @version.documents.each do |document| %>
|
|
211
|
+
<li><%= link_to document["title"], document["url"], target: "_blank" %></li>
|
|
212
|
+
<% end %>
|
|
213
|
+
</ul>
|
|
214
|
+
|
|
215
|
+
<%= form.submit t("terms_acceptances.accept") %>
|
|
216
|
+
<% end %>
|
|
217
|
+
```
|
|
133
218
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
219
|
+
```yaml
|
|
220
|
+
# config/locales/en.yml
|
|
221
|
+
en:
|
|
222
|
+
terms_acceptances:
|
|
223
|
+
statement: I accept the Terms of Service and acknowledge the Privacy Notice.
|
|
224
|
+
accept: Accept and continue
|
|
225
|
+
changed: The Terms changed while this page was open. Please review them again.
|
|
226
|
+
```
|
|
137
227
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
the
|
|
142
|
-
|
|
143
|
-
|
|
228
|
+
- `require_terms` is defined in step 4. Add both steps before you restart the
|
|
229
|
+
server.
|
|
230
|
+
- The hidden field sends the version the user saw. If a newer version became
|
|
231
|
+
current in the meantime, `accept!` raises `Agreements::VersionNotCurrent` and
|
|
232
|
+
the page shows the new version.
|
|
233
|
+
- `acceptance_statement` is the exact sentence the user saw, in their language.
|
|
234
|
+
It is stored with the acceptance.
|
|
235
|
+
- Pass `subject`, `actor` and `authority` from the server. Never take them from
|
|
236
|
+
request parameters.
|
|
237
|
+
- A double click or a retry returns the first acceptance. It does not create a
|
|
238
|
+
second one.
|
|
144
239
|
|
|
145
|
-
|
|
240
|
+
### 4. Require acceptance
|
|
146
241
|
|
|
147
242
|
```ruby
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
243
|
+
# app/controllers/application_controller.rb
|
|
244
|
+
class ApplicationController < ActionController::Base
|
|
245
|
+
include Agreements::Enforcement
|
|
151
246
|
|
|
152
|
-
|
|
153
|
-
non-empty opaque strings. The gem stores only the resulting keys; it does not
|
|
154
|
-
own authentication, tenancy, roles, or authorization.
|
|
247
|
+
before_action :require_terms, if: -> { Current.user }
|
|
155
248
|
|
|
156
|
-
|
|
157
|
-
`gid://my-app/User/1`. If either changes, old acceptances no longer match and
|
|
158
|
-
every subject is asked to accept again. Pin the application name now so a
|
|
159
|
-
module rename cannot change it:
|
|
249
|
+
private
|
|
160
250
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
251
|
+
def require_terms
|
|
252
|
+
require_agreement("user_terms", subject: Current.user, location: main_app.terms_acceptance_path)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
164
255
|
```
|
|
165
256
|
|
|
166
|
-
|
|
167
|
-
rows are read only through the model API.
|
|
257
|
+
When the user has not accepted the current version:
|
|
168
258
|
|
|
169
|
-
|
|
259
|
+
- an HTML or Turbo Stream request is redirected to the acceptance page. After
|
|
260
|
+
acceptance, `agreement_return_location` sends the user back to the page they
|
|
261
|
+
asked for (GET and HEAD requests only; a form submission is never replayed);
|
|
262
|
+
- any other request (JSON, CSV, PDF) gets an empty `403 Forbidden`.
|
|
170
263
|
|
|
171
|
-
|
|
172
|
-
|
|
264
|
+
Use `main_app.` for the location so the check also works inside mounted
|
|
265
|
+
engines.
|
|
173
266
|
|
|
174
|
-
|
|
175
|
-
<%= form.hidden_field :agreement_version_id, value: @version.id %>
|
|
176
|
-
<%= form.check_box :confirmed, required: true %>
|
|
177
|
-
```
|
|
267
|
+
### 5. Update your tests
|
|
178
268
|
|
|
179
|
-
|
|
269
|
+
With enforcement on, most tests need users who have already accepted:
|
|
180
270
|
|
|
181
271
|
```ruby
|
|
182
|
-
|
|
272
|
+
# test/test_helper.rb
|
|
273
|
+
require "agreements/test_helper"
|
|
183
274
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
subject: current_user,
|
|
188
|
-
actor: current_user,
|
|
189
|
-
authority: "self",
|
|
190
|
-
acceptance_statement: statement,
|
|
191
|
-
locale: I18n.locale.to_s
|
|
192
|
-
)
|
|
275
|
+
class ActiveSupport::TestCase
|
|
276
|
+
include Agreements::TestHelper
|
|
277
|
+
end
|
|
193
278
|
```
|
|
194
279
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
host. Do not accept either value from browser parameters. Retries, double
|
|
199
|
-
clicks, and concurrent submissions return the original acceptance evidence.
|
|
280
|
+
```ruby
|
|
281
|
+
accept_agreement("user_terms", subject: users(:alice))
|
|
282
|
+
```
|
|
200
283
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
is `nil` when no version exists for that key yet:
|
|
284
|
+
Add the current versions to your fixtures or test setup first. The helper raises
|
|
285
|
+
when no current version exists.
|
|
204
286
|
|
|
205
|
-
|
|
206
|
-
rescue Agreements::VersionNotCurrent => error
|
|
207
|
-
raise ActiveRecord::RecordNotFound unless error.current_version
|
|
287
|
+
## Guides
|
|
208
288
|
|
|
209
|
-
|
|
210
|
-
render :show, status: :unprocessable_content
|
|
211
|
-
end
|
|
212
|
-
```
|
|
289
|
+
### Agreements for a company (DPA)
|
|
213
290
|
|
|
214
|
-
|
|
215
|
-
the actor
|
|
291
|
+
The organisation is the **subject**: it is bound by the agreement. The user who
|
|
292
|
+
clicks is the **actor**. Check in your app that the actor may accept, then
|
|
293
|
+
record the authority you checked:
|
|
216
294
|
|
|
217
295
|
```ruby
|
|
218
296
|
Agreements.accept!(
|
|
219
297
|
"organization_dpa",
|
|
220
298
|
version_id: params.dig(:acceptance, :agreement_version_id),
|
|
221
|
-
subject:
|
|
222
|
-
actor:
|
|
299
|
+
subject: Current.organization,
|
|
300
|
+
actor: Current.user,
|
|
223
301
|
authority: "organization_owner",
|
|
224
|
-
acceptance_statement:
|
|
302
|
+
acceptance_statement: t("dpa_acceptances.statement", organization: Current.organization.name),
|
|
225
303
|
locale: I18n.locale.to_s
|
|
226
304
|
)
|
|
227
305
|
```
|
|
228
306
|
|
|
229
|
-
|
|
230
|
-
does not
|
|
231
|
-
|
|
232
|
-
## Enforce an agreement
|
|
233
|
-
|
|
234
|
-
Include the small controller concern in your application controller:
|
|
235
|
-
|
|
236
|
-
```ruby
|
|
237
|
-
class ApplicationController < ActionController::Base
|
|
238
|
-
include Agreements::Enforcement
|
|
239
|
-
end
|
|
240
|
-
```
|
|
307
|
+
The acceptance belongs to the organisation. If ownership moves to another user
|
|
308
|
+
later, the organisation does not have to accept again.
|
|
241
309
|
|
|
242
|
-
|
|
243
|
-
|
|
310
|
+
`require_agreement` returns `nil`, `:redirected` or `:forbidden`. Use it to
|
|
311
|
+
skip work after a halted request, for example Pundit's `verify_authorized`:
|
|
244
312
|
|
|
245
313
|
```ruby
|
|
246
|
-
|
|
247
|
-
require_agreement(
|
|
248
|
-
"organization_dpa",
|
|
249
|
-
subject: Current.organization,
|
|
250
|
-
location: organization_dpa_path(Current.organization)
|
|
251
|
-
)
|
|
252
|
-
end
|
|
314
|
+
skip_authorization if require_agreement("organization_dpa", subject: Current.organization, location: dpa_path)
|
|
253
315
|
```
|
|
254
316
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
- an HTML or Turbo Stream request is redirected to `location` with
|
|
258
|
-
`303 See Other`, and the method returns `:redirected`;
|
|
259
|
-
- any other request (JSON, CSV, PDF, API) gets an empty `403 Forbidden`, and
|
|
260
|
-
the method returns `:forbidden`.
|
|
317
|
+
### Change your Terms with notice
|
|
261
318
|
|
|
262
|
-
|
|
263
|
-
|
|
319
|
+
Terms often promise notice before a change applies, such as "at least 14 days
|
|
320
|
+
before the changes take effect". Add the new version when you send the notice,
|
|
321
|
+
with the date it takes effect:
|
|
264
322
|
|
|
265
323
|
```ruby
|
|
266
|
-
|
|
324
|
+
VERSION = {
|
|
325
|
+
agreement_key: "user_terms",
|
|
326
|
+
version: "2026-12-01",
|
|
327
|
+
effective_at: Time.utc(2026, 12, 1),
|
|
328
|
+
acceptance_statement: "I accept the Terms of Service and acknowledge the Privacy Notice.",
|
|
329
|
+
documents: [{ key: "terms_of_service", title: "Terms of Service", url: "https://example.com/terms-2026-12-01" }]
|
|
330
|
+
}.freeze
|
|
267
331
|
```
|
|
268
332
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
333
|
+
- Until `effective_at`, the previous version stays current. Nobody is asked to
|
|
334
|
+
accept, and `accept!` refuses the new version.
|
|
335
|
+
- `Agreements.upcoming_version("user_terms")` returns the scheduled version, so
|
|
336
|
+
you can show a notice in the app.
|
|
337
|
+
- From `effective_at`, the new version is current, and everyone who has not
|
|
338
|
+
accepted it is asked once.
|
|
339
|
+
- Without `effective_at`, a version is current as soon as it is inserted.
|
|
340
|
+
- The current version is the one that took effect most recently. If two take
|
|
341
|
+
effect at the same time, the one inserted later wins.
|
|
342
|
+
- To cancel a scheduled version before its date, delete it in a data migration
|
|
343
|
+
with a migration-local model. It cannot have acceptances yet.
|
|
344
|
+
- Publish the new document URL before the version takes effect. Use a new URL
|
|
345
|
+
for each version, so old acceptances keep pointing at the text people saw.
|
|
346
|
+
|
|
347
|
+
### Show translated document links
|
|
348
|
+
|
|
349
|
+
Give each document a `key`. Look up the link title in your locale files, so
|
|
350
|
+
your views do not depend on the English `title`:
|
|
272
351
|
|
|
273
|
-
```
|
|
274
|
-
|
|
352
|
+
```erb
|
|
353
|
+
<% @version.documents.each do |document| %>
|
|
354
|
+
<%= link_to t("documents.#{document["key"]}"), document["url"] %>
|
|
355
|
+
<% end %>
|
|
275
356
|
```
|
|
276
357
|
|
|
277
|
-
|
|
358
|
+
### APIs and JSON
|
|
359
|
+
|
|
360
|
+
`require_agreement` answers non-HTML requests with an empty `403`. If your API
|
|
361
|
+
has its own error format, check first:
|
|
278
362
|
|
|
279
363
|
```ruby
|
|
280
|
-
def
|
|
364
|
+
def require_dpa
|
|
281
365
|
if api_request?
|
|
282
366
|
if Agreements.pending_version("organization_dpa", subject: Current.organization)
|
|
283
367
|
render json: { error: "agreement_required" }, status: :forbidden
|
|
@@ -285,139 +369,172 @@ def require_organization_dpa
|
|
|
285
369
|
return
|
|
286
370
|
end
|
|
287
371
|
|
|
288
|
-
require_agreement("organization_dpa", subject: Current.organization, location:
|
|
372
|
+
require_agreement("organization_dpa", subject: Current.organization, location: main_app.dpa_path)
|
|
289
373
|
end
|
|
290
374
|
```
|
|
291
375
|
|
|
292
|
-
|
|
293
|
-
Insert the first version before you rely on enforcement.
|
|
294
|
-
|
|
295
|
-
## Test your application
|
|
376
|
+
### Answer an audit request
|
|
296
377
|
|
|
297
|
-
|
|
298
|
-
accepted. Include the test helper:
|
|
378
|
+
Acceptances are ordinary Active Record rows:
|
|
299
379
|
|
|
300
380
|
```ruby
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
include Agreements::TestHelper
|
|
306
|
-
end
|
|
381
|
+
Agreements::Acceptance
|
|
382
|
+
.includes(:agreement_version)
|
|
383
|
+
.where(subject_key: organization.to_global_id.to_s)
|
|
384
|
+
.order(:accepted_at)
|
|
307
385
|
```
|
|
308
386
|
|
|
387
|
+
The gem has no admin screen and no export. Build one when a real audit tells
|
|
388
|
+
you which format and access controls it needs.
|
|
389
|
+
|
|
390
|
+
### Keep subject keys stable
|
|
391
|
+
|
|
392
|
+
Subjects and actors are stored as Global ID strings, such as
|
|
393
|
+
`gid://my-app/User/1`. The string contains your app name and the model class.
|
|
394
|
+
If either changes, old acceptances no longer match and everyone is asked again.
|
|
395
|
+
Pin the app name now:
|
|
396
|
+
|
|
309
397
|
```ruby
|
|
310
|
-
|
|
311
|
-
|
|
398
|
+
# config/application.rb
|
|
399
|
+
config.global_id.app = "my-app"
|
|
312
400
|
```
|
|
313
401
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
in your fixtures or setup first.
|
|
402
|
+
Before you rename a model, plan how to carry its acceptances over. You can also
|
|
403
|
+
pass your own non-empty string as subject or actor.
|
|
317
404
|
|
|
318
|
-
##
|
|
405
|
+
## API reference
|
|
319
406
|
|
|
320
|
-
|
|
407
|
+
### Module methods
|
|
321
408
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
409
|
+
| Method | Returns |
|
|
410
|
+
|---|---|
|
|
411
|
+
| `Agreements.current_version(key)` | The `Agreements::Version` in effect now, or `nil` |
|
|
412
|
+
| `Agreements.upcoming_version(key)` | The next scheduled version, or `nil` |
|
|
413
|
+
| `Agreements.pending_version(key, subject:)` | The current version if `subject` has not accepted it, otherwise `nil` |
|
|
414
|
+
| `Agreements.accept!(key, version_id:, subject:, actor:, authority:, acceptance_statement:, locale:)` | The new or existing `Agreements::Acceptance` |
|
|
415
|
+
| `Agreements.identity_key(record_or_string)` | The stored key for a subject or actor |
|
|
328
416
|
|
|
329
|
-
`
|
|
417
|
+
`accept!` raises:
|
|
330
418
|
|
|
331
|
-
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
-
|
|
335
|
-
|
|
336
|
-
-
|
|
419
|
+
- `Agreements::VersionNotCurrent` when `version_id` is missing, malformed, for
|
|
420
|
+
another agreement or no longer current. `error.current_version` is the
|
|
421
|
+
version to show, or `nil` when none exists;
|
|
422
|
+
- `Agreements::InvalidIdentity` when a subject or actor is neither a non-empty
|
|
423
|
+
string nor a record with `to_global_id`;
|
|
424
|
+
- `ActiveRecord::RecordInvalid` when `authority`, `acceptance_statement` or
|
|
425
|
+
`locale` is blank, or `authority` has characters other than `a-z`, `0-9` and `_`.
|
|
337
426
|
|
|
338
|
-
|
|
339
|
-
does not claim protection from a privileged database owner.
|
|
427
|
+
### Controller methods (`include Agreements::Enforcement`)
|
|
340
428
|
|
|
341
|
-
|
|
429
|
+
| Method | Behaviour |
|
|
430
|
+
|---|---|
|
|
431
|
+
| `require_agreement(key, subject:, location:)` | `nil` when nothing is pending. Otherwise redirects HTML and Turbo Stream requests to `location` (returns `:redirected`) or renders `403` for others (returns `:forbidden`) |
|
|
432
|
+
| `agreement_return_location` | The same-origin path saved by `require_agreement`, read once, or `nil` |
|
|
342
433
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
434
|
+
### Test helper (`require "agreements/test_helper"`)
|
|
435
|
+
|
|
436
|
+
| Method | Behaviour |
|
|
437
|
+
|---|---|
|
|
438
|
+
| `accept_agreement(key, subject:, actor: subject, authority: "self", locale: "en")` | Accepts the current version with its canonical statement |
|
|
348
439
|
|
|
349
|
-
|
|
350
|
-
controls.
|
|
440
|
+
### Models
|
|
351
441
|
|
|
352
|
-
|
|
442
|
+
`Agreements::Version`
|
|
353
443
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
444
|
+
| Attribute | Notes |
|
|
445
|
+
|---|---|
|
|
446
|
+
| `agreement_key` | `a-z`, `0-9` and `_`, for example `user_terms` |
|
|
447
|
+
| `version` | Your label, for example `2026-08-16`. Unique per key |
|
|
448
|
+
| `acceptance_statement` | The canonical statement for this version |
|
|
449
|
+
| `documents` | At least one `{ title:, url:, sha256:, key: }`. `url` must be HTTPS. `sha256` and `key` are optional |
|
|
450
|
+
| `effective_at` | Optional. The version is current from this time |
|
|
360
451
|
|
|
361
|
-
|
|
362
|
-
|
|
452
|
+
`Agreements::Acceptance`
|
|
453
|
+
|
|
454
|
+
| Attribute | Notes |
|
|
455
|
+
|---|---|
|
|
456
|
+
| `agreement_version` | The accepted version |
|
|
457
|
+
| `subject_key`, `actor_key` | Global ID strings or your own strings |
|
|
458
|
+
| `authority` | Why the actor could accept, for example `self` or `organization_owner` |
|
|
459
|
+
| `acceptance_statement`, `locale` | The exact sentence shown and its language |
|
|
460
|
+
| `accepted_at` | Server time of acceptance |
|
|
461
|
+
|
|
462
|
+
Both models are read-only once saved. Updates and deletes through the model API
|
|
463
|
+
raise `ActiveRecord::ReadOnlyRecord`. A database user with direct SQL access
|
|
464
|
+
can still change rows.
|
|
363
465
|
|
|
364
466
|
## What the evidence proves
|
|
365
467
|
|
|
366
468
|
The gem records which version and which sentence a subject accepted, who did
|
|
367
|
-
it, under what authority, and when.
|
|
368
|
-
prove later. Some questions it cannot answer for you:
|
|
469
|
+
it, under what authority, and when. Some questions it cannot answer for you:
|
|
369
470
|
|
|
370
471
|
- **The whole page counts.** Courts look at the entire screen, not only the
|
|
371
472
|
checkbox. In *Berman v. Freedom Financial Network* (9th Cir. 2022) the terms
|
|
372
473
|
did not bind the user: the notice was small grey text, the links did not
|
|
373
474
|
look like links, and the button said "Continue" without saying that it meant
|
|
374
|
-
acceptance. Your layout, contrast and button text decide this.
|
|
375
|
-
not see them.
|
|
475
|
+
acceptance. Your layout, contrast and button text decide this.
|
|
376
476
|
- **It is not a qualified electronic signature.** Under eIDAS (Regulation (EU)
|
|
377
477
|
No 910/2014, Article 25), an electronic signature cannot be denied legal
|
|
378
478
|
effect only because it is electronic or not qualified. A checkbox is still
|
|
379
479
|
not a qualified electronic signature, and this gem does not create one.
|
|
380
480
|
- **Acknowledging a privacy notice is not consent.** Under the GDPR you inform
|
|
381
|
-
people (Article 13). Consent (Articles 6 and 7) is a separate, specific
|
|
382
|
-
|
|
481
|
+
people (Article 13). Consent (Articles 6 and 7) is a separate, specific
|
|
482
|
+
choice that people can withdraw. Keep the two apart.
|
|
383
483
|
- **The text of your terms still matters.** In the EU, an unfair term in a
|
|
384
484
|
consumer contract does not bind the consumer (Directive 93/13/EEC), however
|
|
385
485
|
clearly it was accepted.
|
|
386
|
-
- **Identity and authority are
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
Your
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
-
|
|
396
|
-
-
|
|
397
|
-
-
|
|
398
|
-
-
|
|
399
|
-
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
486
|
+
- **Identity and authority are your checks.** The gem stores the actor and the
|
|
487
|
+
authority you pass. It does not verify who the person is or whether they may
|
|
488
|
+
bind an organisation.
|
|
489
|
+
|
|
490
|
+
Your lawyer decides what your agreements need. The gem keeps the record.
|
|
491
|
+
|
|
492
|
+
## What this gem does not do
|
|
493
|
+
|
|
494
|
+
- Host, edit or render legal documents.
|
|
495
|
+
- Drafts, approval workflows or an admin dashboard.
|
|
496
|
+
- Electronic signatures or identity checks.
|
|
497
|
+
- IP addresses, user agents or other request data.
|
|
498
|
+
- Consent withdrawal, retention rules or reports.
|
|
499
|
+
- Your routes, views, copy, onboarding or authorization.
|
|
500
|
+
|
|
501
|
+
## Versioning
|
|
502
|
+
|
|
503
|
+
The gem follows semantic versioning. Before 1.0, a minor release (0.x.0) may
|
|
504
|
+
change behaviour or the schema. Each such release lists the change and the
|
|
505
|
+
upgrade steps in the [CHANGELOG](CHANGELOG.md) and below. Patch releases
|
|
506
|
+
(0.x.y) never break your app.
|
|
507
|
+
|
|
508
|
+
### Upgrading from 0.3
|
|
509
|
+
|
|
510
|
+
Version 0.4 adds a nullable `effective_at` column to `agreements_versions`:
|
|
511
|
+
|
|
512
|
+
```bash
|
|
513
|
+
bin/rails generate migration AddEffectiveAtToAgreementsVersions effective_at:datetime
|
|
514
|
+
bin/rails db:migrate
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Existing rows keep `effective_at` empty and stay in effect from their creation.
|
|
518
|
+
|
|
519
|
+
### Upgrading from 0.2
|
|
520
|
+
|
|
521
|
+
Version 0.3 has no schema change. `require_agreement` now answers a pending
|
|
522
|
+
non-HTML request with `403 Forbidden` instead of letting it through. If your
|
|
523
|
+
API already checks `pending_version` before calling it, nothing changes.
|
|
524
|
+
|
|
525
|
+
### Upgrading from 0.1
|
|
526
|
+
|
|
527
|
+
Version 0.2 adds non-null `acceptance_statement` and `locale` columns to
|
|
528
|
+
`agreements_acceptances`. Add them in a migration before upgrading. For existing
|
|
529
|
+
rows, copy each version's canonical statement and use `und` as the locale when
|
|
530
|
+
the displayed language is unknown.
|
|
531
|
+
|
|
532
|
+
## Contributing
|
|
533
|
+
|
|
534
|
+
Bug reports and pull requests are welcome on
|
|
535
|
+
[GitHub](https://github.com/yshmarov/agreements/issues). See
|
|
536
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for setup and scope.
|
|
420
537
|
|
|
421
538
|
## License
|
|
422
539
|
|
|
423
|
-
MIT.
|
|
540
|
+
[MIT](MIT-LICENSE). Maintained by [Yaroslav Shmarov](https://github.com/yshmarov).
|
data/SECURITY.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# Security
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Reporting
|
|
4
|
+
|
|
5
|
+
Report vulnerabilities privately through
|
|
6
|
+
[GitHub private reporting](https://github.com/yshmarov/agreements/security/advisories/new).
|
|
7
|
+
Do not open a public issue.
|
|
8
|
+
|
|
9
|
+
## Supported versions
|
|
10
|
+
|
|
11
|
+
Security fixes go to the latest release. Before 1.0 that is the latest 0.x
|
|
12
|
+
version.
|
|
13
|
+
|
|
14
|
+
## Scope
|
|
4
15
|
|
|
5
16
|
The gem resolves actor, subject, authority, and acceptance time from values the
|
|
6
17
|
host passes server-side. A submitted version ID is accepted only when it matches
|
data/lib/agreements/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agreements
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -84,11 +84,12 @@ dependencies:
|
|
|
84
84
|
- !ruby/object:Gem::Version
|
|
85
85
|
version: '9'
|
|
86
86
|
description: |
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
Versioned acceptance of legal agreements for Rails. Stores which version
|
|
88
|
+
and which localized sentence was accepted, by whom, for whom (a user or a
|
|
89
|
+
company), under what authority and when. Redirects users who still have to
|
|
90
|
+
accept, supports a notice period before new terms take effect, and rejects
|
|
91
|
+
submissions of an outdated version. Two tables, no UI, no external services.
|
|
92
|
+
In production at EthicsPortal.
|
|
92
93
|
email:
|
|
93
94
|
- yashm@outlook.com
|
|
94
95
|
executables: []
|
|
@@ -120,6 +121,7 @@ metadata:
|
|
|
120
121
|
homepage_uri: https://github.com/yshmarov/agreements
|
|
121
122
|
source_code_uri: https://github.com/yshmarov/agreements/tree/main
|
|
122
123
|
changelog_uri: https://github.com/yshmarov/agreements/blob/main/CHANGELOG.md
|
|
124
|
+
documentation_uri: https://github.com/yshmarov/agreements#readme
|
|
123
125
|
bug_tracker_uri: https://github.com/yshmarov/agreements/issues
|
|
124
126
|
rubygems_mfa_required: 'true'
|
|
125
127
|
rdoc_options: []
|
|
@@ -138,5 +140,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
138
140
|
requirements: []
|
|
139
141
|
rubygems_version: 4.0.20
|
|
140
142
|
specification_version: 4
|
|
141
|
-
summary:
|
|
143
|
+
summary: Record who accepted which version of your Terms, Privacy Notice or DPA, and
|
|
144
|
+
ask again when they change.
|
|
142
145
|
test_files: []
|