Sequel::Privacy
A Sequel plugin that allows you to define policies that are executed when your models are loaded, created or mutated. Supports field-level policies to protect data based on actor/viewers' relationships to given models.
Installation
Add to your Gemfile:
gem 'sequel-privacy'Then require it after Sequel:
require 'sequel'
require 'sequel-privacy'Quick Start
1. Define Your Policy Module
# policies/base.rb
module P
extend Sequel::Privacy::PolicyDSL
AlwaysDeny = Sequel::Privacy::BuiltInPolicies::AlwaysDeny
AlwaysAllow = Sequel::Privacy::BuiltInPolicies::AlwaysAllow
PassAndLog = Sequel::Privacy::BuiltInPolicies::PassAndLog
policy :AllowIfPublished, ->(subject) {
allow if subject.published
}
policy :AllowAdmins, ->(_subject, actor) {
allow if actor.is_role?(:admin)
}, 'Allow admin users', cacheable: true
policy :AllowMembers, ->(_subject, actor) {
allow if actor.is_role?(:member)
}, cacheable: true
policy :AllowSelf, ->(subject, actor) {
allow if subject == actor
}, 'Allow if subject is the actor', single_match: true
policy :AllowFriendsOfSubject, ->(subject, actor) {
allow if subject.includes_friend?(actor)
}
end2. Add Privacy to Your Models
class Member < Sequel::Model
plugin :privacy
# Include this module if this model can be used to create a viewer context.
include Sequel::Privacy::IActor
privacy do
# Define who can view this model; be strategic about the order of your policies so that You
# don't evaluate ones you don't need to.
can :view, P::AllowSelf, P::AllowMembers
can :edit, P::AllowSelf, P::AllowAdmins
can :create, P::AllowAdmins
field :email, P::AllowMembers
field :phone, P::AllowSelf, P::AllowFriendsOfSubject, P::AllowAdmins
end
endThe privacy block provides:
-
can :action, *policies- Define policies for an action (:view,:edit,:create, etc.) -
field :name, *policies- Protect a field (auto-creates:view_#{field}policy) -
finalize!- Prevent further modifications to privacy settings
AlwaysDeny is automatically appended to all policy chains if you don't include it, but it's better to add it explictly.
This behavior may change.
3. Query with Privacy Enforcement
# Create a viewer context; one viewer context for each request.
# In a Roda app, you could do this at the top of your routing tree,
# in Sinatra you could do this in a `before` filter. See the dedicated
# section below for more information about VCs.
current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
# Results will filter out records that your VC can't see.
members = Member.for_vc(vc).where(org_id: current_user.org_id).all
# But DON'T rely on the privacy checker in place of refining your query
# with privacy/permissions in-mind.
my_groups = Group.for_vc(vc).all # DONT: This results on tons of records being returned, processed and filtered for no reason.
my_groups = Group.for_vc(vc).where(creator: current_user).all # DO
# Field-level privacy policies will be enforced on access.
member.email # => nil if :view_email denies
member.phone # => nil if :view_phone deniesPolicy Definition
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. allow and deny will end evaluation of the chain of policies, whereas pass will continue to the next policy in the chain.
Policies accept up to three parameters: actor, subject & actor or subject, actor and direct_object.
policy :AlwaysAllow, -> { allow }
policy :AllowIfPublished, ->(subject) {
allow if subject.published
}
policy :AllowAdmins, ->(_subject, actor) {
allow if actor.is_role?(:admin)
}
policy :AllowOwner, ->(_subject, actor) {
allow if subject.owner_id == actor.id
}
policy :AllowIfDirectObjectIsActor, ->(_subject, actor, direct_object) {
allow if actor.id == direct_object.id
}If you have lots of different objects and want to make your policies more specific, you can define policies in different modules.
module P
module Groups
extend Sequel::Privacy::PolicyDSL
policy :AllowIfOpen, -> (subject, _actor) {
allow if subject.open?
}
policy :AllowIfMember, -> (subject, actor) {
allow if subject.includes_member? actor
}
end
endPolicy Options
policy :MyPolicy, ->() { ... },
'Human-readable description', # For logging
cacheable: true, # Cache results (default: true)
single_match: false # Only one subject can matchcacheable: 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.
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.
Policy Combinators
Use all() to require multiple conditions:
policy :AllowAddSelfToOpenGroup, ->(subject, actor, direct_object) {
all(
P::AllowIfGroupIsOpen
P::AllowIfDirectObjectIsActor
)
}
policy :AllowRemoveSelf, ->(subject, actor, direct_object) {
all(
P::AllowIfIncludesMember,
P::AllowIfDirectObjectIsActor
)
}All sub-policies must return :allow for the combinator to return :allow. Any :deny results in :deny.
Viewer Contexts
Viewer Contexts should be created by the router/controller layer of your application, you should generally have one VC for the entire request lifecycle. The plugin provides several VC types for different use-cases.
Anonymous VCs are useful for logged out users, and can check that their access is properly constrained to things that are meant to be fully public.
Omniscient VCs are most useful when your application needs to see an object that a user cannot for some reason. Handle them with care. Login is the most salient example (see note below for more detail).
All-Powerful VCs bypass all privacy checks and are used in situations where the system needs unfettered access to models. In a production setting, your application should prohibit raw Database access outside of the privacy-aware system, so these VCs give you an escape hatch for things like scripts while also keeping an audit trail.
omniscient and all_powerful require a reason (symbol) for audit logging.
You could also create lint rules that prevent the casual creation of these viewer contexts.
# Standard viewer (most common)
current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
users_groups = Group.for_vc(current_vc).where(creator: current_user).all
# API-specific (can be distinguished in policies)
vc = Sequel::Privacy::ViewerContext.for_api_actor(current_user)
# Anonymous viewer (logged-out users)
logged_out_vc = Sequel::Privacy::ViewerContext.anonymous
posts = Post.for_vc(logged_out_vc).where(published: true).all
# Omniscient VCs can read any object in the system, but are incapable of writes.
# Dispose of these ViewerContexts quickly.
current_user = Sequel::Privacy::ViewerContext.omniscient(:login).then {|vc| User.for_vc(vc)[authenticated_user_id] }
current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
# All-powerful ViewerContexts dangerously bypass all read and write checks.
admin_vc = Sequel::Privacy::ViewerContext.all_powerful(:admin_migration)A Note Login & Authenticated Users
If your User or equivalent model is privacy-aware and is protected by
policies that would complicating fetching (or login), then you will have
trouble creating a current_user for an ActorVC.
In both cases you can use an OmniscientVC to make your initial User query.
before do
if session[:user_id]
current_user = Sequel::Privacy::ViewerContext.omniscient(:session).then {|vc| User.for_vc(vc)[session[:user_id]] }
current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
else
current_user = nil
current_vc = Sequel::Privacy::ViewerContext.anonymous
end
end
post '/auth/password' do
user = Sequel::Privacy::ViewerContext.omniscient(:login).then {|vc| User.for_vc(vc).first(email: params[:email]) }
pass unless user
pass unless user.password == params[:password]
session[:user_id] = user.id
redirect '/'
endMutation Enforcement
When a viewer context is attached, mutations are automatically checked:
member = Member.for_vc(vc).first
# Check :edit policy before saving existing records
member.name = "New Name"
member.save # Raises Unauthorized if :edit denies
# Create new records with privacy enforcement
new_member = Member.for_vc(vc).create(name: "Test")
# or
new_member = Member.for_vc(vc).new(name: "Test")
new_member.save # Raises Unauthorized if :create denies
# Check field-level policies when modifying protected fields
member.update(email: "new@example.com") # Raises FieldUnauthorized if :view_email deniesAssociation Privacy
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.
class Group < Sequel::Model
plugin :privacy
many_to_many :members, class: :User,
join_table: :group_memberships,
left_key: :group_id,
right_key: :user_id
privacy do
can :view, P::AllowMembers
can :edit, P::AllowAdmins
association :members do
can :add, P::AllowGroupAdmin, P::AllowSelfJoin
can :remove, P::AllowGroupAdmin, P::AllowSelfRemove
can :remove_all, P::AllowGroupAdmin
end
end
endThe association block supports three actions:
-
:add- Wrapsadd_*method (e.g.,add_member) -
:remove- Wrapsremove_*method (e.g.,remove_member) -
:remove_all- Wrapsremove_all_*method (e.g.,remove_all_members)
Association policies receive (subject, actor, direct_object):
-
subject- The model instance (e.g., the group) -
actor- The current user from the viewer context -
direct_object- The object being added/removed (e.g., the user being added to the group)
For remove_all, the direct object is nil since there's no specific target.
# Allow users to add/remove themselves
policy :AllowSelfJoin, ->(_subject, actor, direct_object) {
allow if actor.id == direct_object.id
}, single_match: true
policy :AllowSelfRemove, ->(_subject, actor, direct_object) {
allow if actor.id == direct_object.id
}, single_match: true
# Allow group admins to add/remove anyone
policy :AllowGroupAdmin, ->(subject, actor, direct_object) {
allow if subject.includes_admin?(actor)
}Usage:
group = Group.for_vc(vc).first
# User joins themselves (allowed by AllowSelfJoin)
group.add_member(current_user)
# Admin removes another user (allowed by AllowGroupAdmin)
group.remove_member(other_user)
# Admin removes all members
group.remove_all_members
# Non-admin trying to add someone else raises Unauthorized
group.add_member(other_user) # Raises Sequel::Privacy::UnauthorizedException Types
-
Sequel::Privacy::Unauthorized- Action denied at the record level -
Sequel::Privacy::FieldUnauthorized- Action denied at the field level -
Sequel::Privacy::MissingViewerContext- Attempted privacy-aware query without a viewer context
Logging
Configure a logger to see policy evaluation. It will show the evaluation results (ALLOW, DENY, PASS) as well as cache hits/optimizations and note when privacy is bypassed by an APVC or an OmniVC.
Sequel::Privacy.logger = Logger.new(STDOUT)
# or with SemanticLogger
Sequel::Privacy.logger = SemanticLogger['Privacy']Cache Management
Policy results are cached per-request to avoid redundant evaluation. Clear between requests:
# In Rack middleware
class PrivacyCacheMiddleware
def initialize(app)
@app = app
end
def call(env)
Sequel::Privacy.clear_cache!
@app.call(env)
end
endOr manually:
Sequel::Privacy.cache.clear
Sequel::Privacy.single_matches.clearActor Interface
Your user/member model must include and implement Sequel::Privacy::IActor.
This will be runtime checked by Sorbet.
class Member < Sequel::Model
include Sequel::Privacy::IActor
def id
self[:id]
end
endPolicy Inheritance
Child classes inherit privacy policies from their parents:
class User < Sequel::Model
include Sequel::Privacy::IActor
plugin :privacy
privacy do
can :view, P::AllowAdmins
end
end
class Admin < User
# Inherits :view policy
privacy do
can :edit, P::AllowSelf
end
endBuilt-in Policies
-
Sequel::Privacy::BuiltInPolicies::AlwaysDeny- Always denies; add it to the end of your policy chains. -
Sequel::Privacy::BuiltInPolicies::AlwaysAllow- Always allows -
Sequel::Privacy::BuiltInPolicies::PassAndLog- Passes with a log message (useful for debugging)
Type Safety (Sorbet)
The gem is mostly fully typed with Sorbet. Type definitions are provided for all public APIs. To ensure
that Tapioca imports the required definitions, you may need to add this to your sorbet/tapioca/require.rb:
require "sequel-privacy"
require "sequel/plugins/privacy"
# Force Tapioca to see the plugin modules by applying them to a dummy class
Class.new(Sequel::Model) do
plugin :privacy
endAI Statement
The core of this project was written by me (arbales) over the course of 2025 for a platform that manages mailing lists and member information for a social group. Claude assisted substantially with extracting it into a Gem and wrote the tests in their entirety.
License
MIT