sequel-privacy 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 644015302ea6057f9b102b24803bafb4c67059e51a6a66c02d825216404ae05f
4
+ data.tar.gz: 8504cb1ca168b0f34b2fa7705a782ca74567aa1839d4f13816f69aaa5e27546e
5
+ SHA512:
6
+ metadata.gz: 8a714fa412538a84dbed42f938eb419a85c626a045a5fc6fa8865014b039bc49eee75eb429c5adeb6d946908ab3e09c04bf5b8240c231090e955b6f8dbed5d4c
7
+ data.tar.gz: d82cd3eaf425d213c12d99a813a6de7656b377241a64c0c82bd6ba098e42e69334567f8b1e03d8e1ff209932b4e8b0310725b57be0a91eb720d30b752e63ca06
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2024-01-24
11
+
12
+ ### Added
13
+ - Initial release
14
+ - `plugin :privacy` for Sequel models
15
+ - Policy definition DSL (`policies` method)
16
+ - Field-level privacy protection (`protect_field` method)
17
+ - ViewerContext system (ActorVC, AllPowerfulVC, APIVC)
18
+ - Policy evaluation engine with caching
19
+ - Built-in policies: AlwaysDeny, AlwaysAllow, PassAndLog
20
+ - PolicyDSL module for defining custom policies
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Austin Bales
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,412 @@
1
+ # Sequel::Privacy
2
+
3
+ A Sequel plugin that allows you to define policies that are executed when your models are loaded, created or mutated.
4
+ Supports field-level policies to protect data based on actor/viewers' relationships to given models.
5
+
6
+ ## Installation
7
+
8
+ Add to your Gemfile:
9
+
10
+ ```ruby
11
+ gem 'sequel-privacy'
12
+ ```
13
+
14
+ Then require it after Sequel:
15
+
16
+ ```ruby
17
+ require 'sequel'
18
+ require 'sequel-privacy'
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ### 1. Define Your Policy Module
24
+
25
+ ```ruby
26
+ # policies/base.rb
27
+ module P
28
+ extend Sequel::Privacy::PolicyDSL
29
+
30
+ AlwaysDeny = Sequel::Privacy::BuiltInPolicies::AlwaysDeny
31
+ AlwaysAllow = Sequel::Privacy::BuiltInPolicies::AlwaysAllow
32
+ PassAndLog = Sequel::Privacy::BuiltInPolicies::PassAndLog
33
+
34
+ policy :AllowIfPublished, ->(subject) {
35
+ allow if subject.published
36
+ }
37
+
38
+ policy :AllowAdmins, ->(_subject, actor) {
39
+ allow if actor.is_role?(:admin)
40
+ }, 'Allow admin users', cacheable: true
41
+
42
+ policy :AllowMembers, ->(_subject, actor) {
43
+ allow if actor.is_role?(:member)
44
+ }, cacheable: true
45
+
46
+ policy :AllowSelf, ->(subject, actor) {
47
+ allow if subject == actor
48
+ }, 'Allow if subject is the actor', single_match: true
49
+
50
+ policy :AllowFriendsOfSubject, ->(subject, actor) {
51
+ allow if subject.includes_friend?(actor)
52
+ }
53
+ end
54
+ ```
55
+
56
+ ### 2. Add Privacy to Your Models
57
+
58
+ ```ruby
59
+ class Member < Sequel::Model
60
+ plugin :privacy
61
+
62
+ privacy do
63
+ # Define who can view this model; be strategic about the order of your policies so that You
64
+ # don't evaluate ones you don't need to.
65
+ can :view, P::AllowSelf, P::AllowMembers
66
+ can :edit, P::AllowSelf, P::AllowAdmins
67
+ can :create, P::AllowAdmins
68
+
69
+ field :email, P::AllowMembers
70
+ field :phone, P::AllowSelf, P::AllowFriendsOfSubject, P::AllowAdmins
71
+ end
72
+ end
73
+ ```
74
+
75
+ The `privacy` block provides:
76
+ - `can :action, *policies` - Define policies for an action (`:view`, `:edit`, `:create`, etc.)
77
+ - `field :name, *policies` - Protect a field (auto-creates `:view_#{field}` policy)
78
+ - `finalize!` - Prevent further modifications to privacy settings
79
+
80
+ `AlwaysDeny` is automatically appended to all policy chains (fail-secure by default).
81
+
82
+ ### 3. Query with Privacy Enforcement
83
+
84
+ ```ruby
85
+ # Create a viewer context
86
+ vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
87
+
88
+ # Query - results are automatically filtered by :view policy
89
+ members = Member.for_vc(vc).where(org_id: 1).all
90
+
91
+ # Check permissions explicitly
92
+ member.allow?(vc, :view) # => true/false
93
+ member.allow?(vc, :edit) # => true/false
94
+
95
+ # Protected fields return nil if denied
96
+ member.email # => nil if :view_email denies
97
+ member.phone # => nil if :view_phone denies
98
+ ```
99
+
100
+ ## Policy Definition
101
+
102
+ Policies are lambdas that execute in the context of an `Actions` struct, giving access to `allow`, `deny`, and `pass` outcome methods, as well as the `all` combinator. Policies accept up to three parameters: `actor`, `subject` & `actor` or `subject`, `actor` and `direct_object`.
103
+
104
+
105
+ ```ruby
106
+
107
+ policy :AlwaysAllow, -> { allow }
108
+
109
+ policy :AllowIfPublished, ->(subject) {
110
+ allow if subject.published
111
+ }
112
+
113
+ policy :AllowAdmins, ->(_subject, actor) {
114
+ allow if actor.is_role?(:admin)
115
+ }
116
+
117
+ policy :AllowOwner, ->(subject, actor) {
118
+ allow if subject.owner_id == actor.id
119
+ }
120
+
121
+ policy :AllowSelfJoin, ->(_group, actor, target_user) {
122
+ allow if actor.id == target_user.id
123
+ }
124
+
125
+ policy :AllowSelfRemove, ->(_group, actor, target_user) {
126
+ allow if actor.id == target_user.id
127
+ }
128
+ ```
129
+
130
+ ### Policy Return Values
131
+
132
+ - `allow` - Permits the action, stops evaluation
133
+ - `deny` - Rejects the action, stops evaluation
134
+ - `pass` (or no explicit return) - Continues to the next policy in the chain
135
+
136
+ ### Policy Options
137
+
138
+ ```ruby
139
+ policy :MyPolicy, ->() { ... },
140
+ 'Human-readable description', # For logging
141
+ cacheable: true, # Cache results (default: true)
142
+ single_match: false # Only one subject can match
143
+ ```
144
+
145
+ **`cacheable: true`** (default): Results are cached for the duration of the request, keyed by policy + arguments. Use for policies that don't depend on mutable state.
146
+
147
+ **`single_match: true`**: Optimization for policies for which there is only one matching Actor possible for a given Subject. For example in `AllowAuthors`, since a `Post` can have only one other, it's not worth a potentially expensive check on other combinations once you've found the winner.
148
+
149
+ ### Policy Combinators
150
+
151
+ Use `all()` to require multiple conditions:
152
+
153
+ ```ruby
154
+ policy :AllowMemberToRemoveSelf, ->(subject, actor, direct_object) {
155
+ all(
156
+ P::AllowIfIncludesMember,
157
+ P::AllowIfDirectObjectIsActor
158
+ )
159
+ }
160
+ ```
161
+
162
+ All sub-policies must return `:allow` for the combinator to return `:allow`. Any `:deny` results in `:deny`.
163
+
164
+ ## Viewer Contexts
165
+
166
+ Viewer Contexts should be created by the router/controller layer of your application, you should generally
167
+ have one VC for the entire request lifecycle. The plugin provides several VC types for different use-cases.
168
+
169
+ Anonymous VCs are useful for logged out users, and can check that their access is properly constrained to things
170
+ that are meant to be fully public.
171
+
172
+ Omniscient VCs are most useful when your application needs to see an object that a user cannot for some reason.
173
+ Handle them with care. Login is the most salient example.
174
+
175
+ All-Powerful VCs bypass all privacy checks and are used in situations where the system needs unfettered access
176
+ to models. In a production setting, your application should prohibit raw Database access outside of the privacy-aware
177
+ system, so these VCs give you an escape hatch for things like scripts while also keeping an audit trail.
178
+
179
+ `omniscient` and `all_powerful` require a reason (symbol) for audit logging.
180
+
181
+ ```ruby
182
+ # Standard viewer (most common)
183
+ current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
184
+ users_groups = Group.for_vc(current_vc).where(creator: current_user).all
185
+
186
+ # API-specific (can be distinguished in policies)
187
+ vc = Sequel::Privacy::ViewerContext.for_api_actor(current_user)
188
+
189
+ # Anonymous viewer (logged-out users)
190
+ logged_out_vc = Sequel::Privacy::ViewerContext.anonymous
191
+ posts = Post.for_vc(logged_out_vc).where(published: true).all
192
+
193
+ # Omniscient VCs can read any object in the system, but are incapable of writes.
194
+ # Dispose of these ViewerContexts quickly.
195
+ current_user = Sequel::Privacy::ViewerContext.omniscient(:login).then {|vc| User.for_vc(vc)[authenticated_user_id] }
196
+ current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
197
+
198
+ # All-powerful ViewerContexts dangerously bypass all read and write checks.
199
+ admin_vc = Sequel::Privacy::ViewerContext.all_powerful(:admin_migration)
200
+ ```
201
+
202
+ ## Mutation Enforcement
203
+
204
+ When a viewer context is attached, mutations are automatically checked:
205
+
206
+ ```ruby
207
+ member = Member.for_vc(vc).first
208
+
209
+ # Check :edit policy before saving existing records
210
+ member.name = "New Name"
211
+ member.save # Raises Unauthorized if :edit denies
212
+
213
+ # Create new records with privacy enforcement
214
+ new_member = Member.for_vc(vc).create(name: "Test")
215
+ # or
216
+ new_member = Member.for_vc(vc).new(name: "Test")
217
+ new_member.save # Raises Unauthorized if :create denies
218
+
219
+ # Check field-level policies when modifying protected fields
220
+ member.update(email: "new@example.com") # Raises FieldUnauthorized if :view_email denies
221
+ ```
222
+
223
+ ### Association Privacy
224
+
225
+ For operations involving associations (like adding/removing members from a group), use the `association` block in the privacy DSL. This automatically wraps Sequel's association methods (`add_*`, `remove_*`, `remove_all_*`) with privacy checks.
226
+
227
+ ```ruby
228
+ class Group < Sequel::Model
229
+ plugin :privacy
230
+
231
+ many_to_many :members, class: :User,
232
+ join_table: :group_memberships,
233
+ left_key: :group_id,
234
+ right_key: :user_id
235
+
236
+ privacy do
237
+ can :view, P::AllowMembers
238
+ can :edit, P::AllowAdmins
239
+
240
+ association :members do
241
+ can :add, P::AllowGroupAdmin, P::AllowSelfJoin
242
+ can :remove, P::AllowGroupAdmin, P::AllowSelfRemove
243
+ can :remove_all, P::AllowGroupAdmin
244
+ end
245
+ end
246
+ end
247
+ ```
248
+
249
+ The `association` block supports three actions:
250
+ - `:add` - Wraps `add_*` method (e.g., `add_member`)
251
+ - `:remove` - Wraps `remove_*` method (e.g., `remove_member`)
252
+ - `:remove_all` - Wraps `remove_all_*` method (e.g., `remove_all_members`)
253
+
254
+ Association policies use 3-arity, receiving `(subject, actor, direct_object)`:
255
+ - `subject` - The model instance (e.g., the group)
256
+ - `actor` - The current user from the viewer context
257
+ - `direct_object` - The object being added/removed (e.g., the user being added to the group)
258
+
259
+ For `remove_all`, the direct object is `nil` since there's no specific target.
260
+
261
+ ```ruby
262
+ # Allow users to add/remove themselves
263
+ policy :AllowSelfJoin, ->(_group, actor, target_user) {
264
+ allow if actor.id == target_user.id
265
+ }, single_match: true
266
+
267
+ policy :AllowSelfRemove, ->(_group, actor, target_user) {
268
+ allow if actor.id == target_user.id
269
+ }, single_match: true
270
+
271
+ # Allow group admins to add/remove anyone
272
+ policy :AllowGroupAdmin, ->(group, actor, _target_user) {
273
+ allow if GroupAdmin.where(group_id: group.id, user_id: actor.id).exists?
274
+ }
275
+ ```
276
+
277
+ Usage:
278
+
279
+ ```ruby
280
+ group = Group.for_vc(vc).first
281
+
282
+ # User joins themselves (allowed by AllowSelfJoin)
283
+ group.add_member(current_user)
284
+
285
+ # Admin removes another user (allowed by AllowGroupAdmin)
286
+ group.remove_member(other_user)
287
+
288
+ # Admin removes all members
289
+ group.remove_all_members
290
+
291
+ # Non-admin trying to add someone else raises Unauthorized
292
+ group.add_member(other_user) # Raises Sequel::Privacy::Unauthorized
293
+ ```
294
+
295
+ Association privacy methods:
296
+ - Require a viewer context (raises `MissingViewerContext` if missing)
297
+ - Deny operations with `OmniscientVC` (read-only context cannot mutate)
298
+ - Work with both `one_to_many` and `many_to_many` associations
299
+
300
+ ### Exception Types
301
+
302
+ - `Sequel::Privacy::Unauthorized` - Action denied at the record level
303
+ - `Sequel::Privacy::FieldUnauthorized` - Action denied at the field level
304
+ - `Sequel::Privacy::MissingViewerContext` - Attempted privacy-aware query without a viewer context
305
+
306
+ ## Logging
307
+
308
+ Configure a logger to see policy evaluation:
309
+
310
+ ```ruby
311
+ Sequel::Privacy.logger = Logger.new(STDOUT)
312
+ # or with SemanticLogger
313
+ Sequel::Privacy.logger = SemanticLogger['Privacy']
314
+ ```
315
+
316
+ Log output shows:
317
+ - Policy evaluation results (ALLOW/DENY/PASS)
318
+ - Cache hits
319
+ - Single-match optimizations
320
+ - All-powerful/omniscient context bypasses
321
+
322
+ ## Cache Management
323
+
324
+ Policy results are cached per-request to avoid redundant evaluation. Clear between requests:
325
+
326
+ ```ruby
327
+ # In Rack middleware
328
+ class PrivacyCacheMiddleware
329
+ def initialize(app)
330
+ @app = app
331
+ end
332
+
333
+ def call(env)
334
+ Sequel::Privacy.clear_cache!
335
+ @app.call(env)
336
+ end
337
+ end
338
+ ```
339
+
340
+ Or manually:
341
+
342
+ ```ruby
343
+ Sequel::Privacy.cache.clear
344
+ Sequel::Privacy.single_matches.clear
345
+ ```
346
+
347
+ ## Actor Interface
348
+
349
+ Your user/member model must implement `Sequel::Privacy::IActor`:
350
+
351
+ ```ruby
352
+ class Member < Sequel::Model
353
+ include Sequel::Privacy::IActor
354
+
355
+ def id
356
+ self[:id]
357
+ end
358
+ end
359
+ ```
360
+
361
+ The interface requires:
362
+ - `id` - Returns the actor's unique identifier
363
+
364
+ You can add additional methods like `is_role?` for use in your policies, but they are not required by the interface.
365
+
366
+ ## Policy Inheritance
367
+
368
+ Child classes inherit privacy policies from their parents:
369
+
370
+ ```ruby
371
+ class User < Sequel::Model
372
+ plugin :privacy
373
+
374
+ privacy do
375
+ can :view, P::AllowAdmins
376
+ end
377
+ end
378
+
379
+ class Admin < User
380
+ # Inherits :view policy
381
+ privacy do
382
+ can :edit, P::AllowSelf
383
+ end
384
+ end
385
+ ```
386
+
387
+ ## Built-in Policies
388
+
389
+ - `Sequel::Privacy::BuiltInPolicies::AlwaysDeny` - Always denies (fail-secure default)
390
+ - `Sequel::Privacy::BuiltInPolicies::AlwaysAllow` - Always allows
391
+ - `Sequel::Privacy::BuiltInPolicies::PassAndLog` - Passes with a log message (useful for debugging)
392
+
393
+
394
+ ## Type Safety (Sorbet)
395
+
396
+ The gem is mostly fully typed with Sorbet. Type definitions are provided for all public APIs.
397
+
398
+ ## AI Statement
399
+
400
+ The core of this project was written by me (arbales) over the course of 2025 for a platform that
401
+ manages mailing lists and member information for a social group. Claude assisted substantially with
402
+ extracting it into a Gem and wrote the tests in their entirety.
403
+
404
+ ## TODO
405
+
406
+ I'd like to support generation of Sorbet signatures on models that use the plugin so that
407
+ Sorbet users don't have to shim their models to use it. In my own work, I've shimmed my
408
+ base model class, but this isn't ideal.
409
+
410
+ ## License
411
+
412
+ MIT