simple_form: Dynamically disabled inputs based on Pundit policy
I use Pundit for managing authorisation in Rails apps. There's generally a 1:1 ratio of models to policy classes with an inheritance strategy that makes implementing authorisation quite clean. It allows you to control who can create, update, and delete which kinds of or which specific records.
I wanted to take this one step further and get into attribute-specific authorisation. Take for example my setups where I have Person
models and then a person may have an associated User
. This allows for cleaner separation of concerns with person info an user account info. When an admin creates a new User
they'll pick the person its associated with. But once it's setup, it should not be allowed to change the Person
relation. This is how I've achieved this both on the backend (strong params) and the front-end (simple_form)
In my ApplicationPolicy
base class for Pundit policies I setup a few methods to make this easier:
class ApplicationPolicy
...
# Default permitted attributes for create and update
def permitted_attributes
# slug is generated by friendly_id and should never be updatable
# id and timestamp fields are never directly updatable
record.attributes.keys.map(&:to_sym) - %i(id slug created_at updated_at deleted_at)
end
def permitted_create_attributes
permitted_attributes
end
def permitted_update_attributes
permitted_attributes
end
end
Then, in the policy for a Users
, we build on this to get more more granular.
class UserPolicy < ApplicationPolicy
def permitted_attributes
# fields maintained by devise are not directly updatable
super - %i(encrypted_password reset_password_token reset_password_sent_at)
end
def permitted_update_attributes
# for update, it's not allowed to change the person or email (username)
permitted_attributes - %i(person person_id email)
end
end
Now I'll use these lists of attributes in two places. The first is in my controller in place of the usual list of strong parameters.
protected
def permitted_params
policy(@user).send("permitted_#{action_name}_attributes")
end
Now this is great and gives us exactly the list of writable attributes we want, but now we want that reflected in the UI so fields are greyed-out.
To do this I've had to monkey-patch the SimpleForm::Inputs::Base
class a bit. Here's how:
# lib/simple_form_extensions/disabled_extensions.rb
module SimpleForm
module DisabledExtensions
private
# I didn't name this method, so don't yell at me for its name!
# rubocop:disable Style/PredicateName
def has_disabled?
super || disabled_by_policy?
end
def disabled_by_policy?
# permit attributes by name or relation name. e.g. person or person_id
!(permitted_attributes.include?(attribute_name) || permitted_attributes.include?(reflection_or_attribute_name))
end
def permitted_attributes
# load the Pundit policy for the object
policy = template.controller.policy(object)
# map action names
action = { edit: :update, new: :create }[template.controller.action_name.to_sym]
# Get the list of permitted attributes as an array of attribute names
# For attributes that permit array values, the entry is not a name, but
# a hash with the key as the attribute name and a value of []
# So for such cases, pluck the hash key as the name
@permitted_attributes ||= policy.send("permitted_#{action}_attributes".to_sym)
.map { |a| a.is_a?(Hash) ? a.keys.first : a }
end
end
end
SimpleForm::Inputs::Base.send :prepend, SimpleForm::DisabledExtensions
Then at the top of config/initializers/simple_form.rb
require 'simple_form_extensions/disabled_extensions'
Restart your server and now the UI automatically disables fields that are not updatable as per your Pundit policy!