Adding a permissions DSL to Active Record
Robert Thau
Smartleaf
The tease...
class Order < ActiveRecord::Base
access_control_keys ['id', 'owner_id', 'paid']
require_privilege :place,
:for_action => :create,
:to_update_attribute => [:payment_authenticator, :paid]
require_privilege :edit, # LineItem also checks this for attr changes
:to_associate_as => ['LineItem#order'],
:to_dissociate_as => ['LineItem#order'],
:to_update_attribute => [ :shipping_address ]
require_privilege :ship, :to_update_attribute => :shipped
...
end
The tease...
<% form_for resource, :if_not_permitted => :present_text do |f| %>
<%= error_messages_for resource_name %>
Store: <%= f.collection_select :store_id,
Store.find( :all ), :id, :name %>
Shipped: <%= f.check_box :shipped, {}, true, false %>
Ship to: <%= f.text_field :shipping_address %>
Notes: <%= f.text_area :notes %>
...
<% end %>
Talk outline:
- Motivation
- Language
- Implementation
So, why do this?
- We do financial data services
- Clients asking for a lot of permissioning flexibility
- Want declarations of policy to be simple and localized,
as much as possible, as with validations.
- Existing tools (hobo, Model Security) did some of what we want, but not all.
Users have access rights
Based on...
- Who they are (this manager manages that store)
- Affiliation (by store, shipping company...)
- Role (clerk, supervisor, compliance/audit...)
Users might be allowed to...
- ... view a record
- ... update a record
- ... update some fields in a record
- ... perform some other operation
Supporting user "levels"
- All sorts of reasons: trust, training, extra-price features...
- "Can set attribute" an important part
- So, how much can we get out of Rails natively?
Stock Rails: Threats and defenses
Threat: faked IDs
- The threat: https://secure.example.com/orders/gofish
- Usual solution:
User.current.orders.find params[:id]
- Assumes homogeneous rules, buried in the data model.
- Problems:
- Customers can see their own orders, and enter payment data
- Customer support can see all orders, but can't pay
- What do the queries look like...
- ... when we outsource shipping?
Threat: forged inputs
- The threat: hostile user adds a value for an input that
wasn't on the form
- Usual solution: attr_protected, etc.
- But for us, the set we want to protect depends on user level:
- Shipping clerk can't set order.special_notes
- But for customer support, it's just another field on the form
What we'd like...
Users have permission to:
- ... find, update, destroy, create
- ... update selected attributes
- ... perform selected operations
... model objects matching some pattern or test.
Permissioning model objects:
- A User...
- ... has many Roles ...
- ... which are bags of Permissions ...
- ... implemented pretty much as the obvious has_many :through and
has_many
Permissions...
- ... grouped into roles
- ... enforced at the model layer
- ... have a nice, declarative syntax, which everything respects:
- Attempts to perform the operations
- Form presentation (disable inputs, etc.)
- Administrative interface (role editor)
Permissions...
- Permissions grant a privilege on some set of objects
- Privilege covers one or more operations
- Set can be denoted by:
- Specific object
- All "owned by grantee"
- All within firm
- More generally: Class-specific "access control keys" ---
value of some attribute (owner_id, owner_firm_id,
public_access_status, ...)
Permissions:
- Privilege
- general: :create, :update
- special: :pay (for an order)
- :any
- Class
- For each access control key foo on the class, a
nullable target_foo permission attribute
- Grant attributes...
Permission examples
- "pay all orders owned by grantee":
- class: Order
- privilege: Pay
- "target owned by grantee": true
- "edit all products for company X"
- class: Product
- privilege: Update
- target_owner_firm_id: X
Declarations, examples
class Order < ActiveRecord::Base
access_control_keys ['id', 'owner_id', 'paid']
require_privilege :place,
:for_action => :create,
:to_update_attribute => [:payment_authenticator, :paid]
require_privilege :edit, # LineItem also checks this for attr changes
:to_associate_as => ['LineItem#order'],
:to_dissociate_as => ['LineItem#order'],
:to_update_attribute => [ :shipping_address ]
require_privilege :ship, :to_update_attribute => :shipped
...
end
Declarations, the language
require_privilege :privilege,
:on_associated => attr,
:to_invoke => [:method, :method, ...],
:to_initialize_attribute => [:attr, :attr, ...],
:to_update_attribute => [:attr, :attr, ...],
:to_set_attribute => [:attr, :attr, ...],
:to_access_attribute => [:attr, :attr, ...],
:to_associate_as => ["Class#assoc_name", ...],
:to_dissociate_as => ["Class#assoc_name", ...],
:at_callback => {:after_find, :before_create, ... },
:for_action => {:create, :find, :update, :destroy}
Implementation
- Data model
- Checking privileges: does user x have privilege y
on this order?
- Finding all orders where user x has
privilege y
- Adding privilege checks in interesting places...
- On events: create, update...
- On attribute sets
- For associations
Permission checks: in core
- Have all permissions: read 'em out in before_filter
- Have the object
- Do the math
Permission checks: in core --- the guts
return false if perm.target_owned_by_self &&
obj.owner_id != user.id
obj.class.access_control_keys.each do |obj_attr|
target_attr = 'target_' + obj_attr
target_attr_val = perm[ target_attr ]
if !target_attr_val.nil? &&
target_attr_val != obj[ obj_attr ]
return false
end
end
return true
Enumerating objects
- API: Order.all_permitting {:find, :trade, ...}
- The primitive:
Order.find ..., :conditions =>
Order.where_permits( :find )
Enumerating objects --- the guts:
clauses = ["(p.target_owned_by_self = 0 or #{table}.owner_id = :user)"]
self.access_control_keys.each do |attr|
clauses <<
"(p.target_#{attr} is null or " +
"#{table}.#{attr} = p.target_#{attr})"
end
return <<-END_SQL
exists
(select 'x' from permissions p
where exists (select 'x' from role_assignments
where user_id = :user
and role_assignments.role_id = p.role_id)
and (p.privilege = :privilege or p.privilege = 'any')
and (p.class_name = :class_name)
and #{sanitize_sql( [clauses.join(' and '),
{ :user => ... }] )
)
END_SQL
Declarations: what do they do?
- Insert permission checks (including on associations)
- Make privilege known to the permissioning UI
- Enable other reflection (can user x alter attribute paid
on this order?)...
- ... which, in turn, drives access-sensitive form helpers
- Support queries for all objects on which a user has some privilege:
needed for select boxes, etc.
Privilege checks: on events
- Basic idea: use callbacks: before_save_on_create, etc.
- Complications: after_find works...
- ... if an explicit after_find method is defined...
- ... but now fixtures, etc., need permission to read stuff out of the
database!
Privilege checks: on attribute read/write
- Basic idea: hook read_attribute, write_attribute
- But also want these to work for synthetic attributes, e.g.
attr_accessor, facade attrs, etc. ...
- ... which may not be defined when the declaration is processed
- ... so a method_added hook lies in wait!
Privilege checks: on associations
- May need privileges on both sides of the association.
(In shop management context: permission to offer this product
and permission to make offers in this store).
- So, when setting the foreign key on a belongs_to,
(e.g., the store_id and product_id on an offer)
we have to do a privilege check on the proposed associates
(the store and the product).
- And if it's not in core? Have to do a query...
Reflection
It's just Ruby! Class variables and class methods:
- All declared privileges (for choosers in the UI)
- Dual-keyed hash: reflected_privilege[type][key]
- ... e.g., reflected_privilege[:read_attribute][attr]
- ... e.g., reflected_privilege[:associate][assoc_key]
- Class helpers (permits_update_attr?, etc.) just read the
hash, and do the appropriate check.
Limitations of current code
- Doesn't work with STI (where_permits gets... tricky)
- Association priv checks don't work with polymorphic associations
- User object bundled with application-specific functionality
(e.g. single-sign-on support) which may not be of general use.
Questions?