More Authorization with Pundit
Ok the whole reason why I wrote my previous post on authorization with Pundit was so that I could talk about more advanced usages of Pundit in our application.
The Learn app started as a student facing curriculum management system with very simple admin interfaces. The admin tasks included adding students to certain batches and deploying curriculum out to them. However as our use cases grew, the complexity of the admin tasks grew as well. Until finally, we ended up with two large admin apps, the Organizations app and the Curriculum app (among other small admin UIs).
We now have a third admin app in development and, though it’s tied to the students, it no longer has the concept of student’s batches. This poses a new kind of challenge since we haven’t been doing authorization by apps. We’ve been inferring the authorization of an app by the combination of the user’s role and the object they have the role for.
With that in mind, let’s talk about a solution to handle app specific authorization.
Creating the concept of sub-apps
Since our current implementation requires us to associate a user + role + object, we need to create the concept of sub-apps. The way that I addressed this was to create a new table in our Rails app called sub_apps
and an associated model.
# Migration
class AddSubAppTable < ActiveRecord::Migration
def change
create_table :sub_apps do |t|
t.string :name
t.text :description
t.timestamps
end
end
end
# Model
class LearnApp < ActiveRecord::Base
end
Create user roles for the sub-apps
We now have all three parts of user + role + object (sub-app), so we can go forward with creating some user roles. For brevity, we’ll only focus on one of the apps. I just threw this into my rakefile
:
namespace :sub_app_roles do
task seed_curriculum_app: :environment do
curriculum_app = SubApp.find_or_create_by(name: 'Curriculum App', description: 'Helps deploy stuff to students')
admin_role = Role.admin
deployer_role = Role.deployer
UserRole.find_or_create_by(user: User.find_by(username: 'AdminUser'), roleable: curriculum_app, role: admin_role)
UserRole.find_or_create_by(user: User.find_by(username: 'DeployerUser'), roleable: curriculum_app, role: deployer_role)
end
end
Running the above task should build an admin user role for AdminUser
and a deployer role for AdminUser
, both for the curriculum app.
Configuring our policy object
Next, we’ll want to configure a policy object for our SubApp
objects called SubAppPolicy
. In app/policies/
create a file called sub_app_policy.rb
and create some general authorization rules for the sub apps:
# app/policies/sub_app_policy.rb
class SubAppPolicy < ApplicationPolicy
def general_access?
!!user.user_role_for(record)
end
def admin_view?
user.has_role?(record, :admin)
end
def send_invitations?
user.has_role?(record, :admin)
end
end
Remember from the previous post about Pundit
that the policy objects take in a user and a record object. Each of the instance methods defined can be used in the controller to authorize a particular action:
# app/controllers/curriculum_controller.rb
class CurriculumController < ApplicationController
def index
curriculum_app = SubApp.find_by(name: 'Curriculum App')
authorize curriculum_app, :general_access?
end
end
Great, now we can use the SubAppPolicy
to authorize users. So another example might be authorizing a user to deploy curriculum out to students:
# app/policies/sub_app_policy.rb
class SubAppPolicy < ApplicationPolicy
# ...
def deploy_access?
user.has_role?(record, :deployer)
end
# ...
end
# app/controllers/deployment_controller.rb
class DeploymentController < ApplicationController
def create
curriculum_app = SubApp.find_by(name: 'Curriculum App')
authorize curriculum_app, :deploy_access?
end
end
Hmm… that particular implementation feels weird though. All SubApps
should have an authorization method called deploy_access?
. It would be confusing for anyone working with this code in the future to figure out where I’m authorizing the user to deploy content.
Method delegation to other policy objects
Remember that at the core of it, the authorize
helper method is doing the following things:
- Get the class of the object and instantiate the policy object for that class. For our particular case:
policy_class = "#{curriculum_app.class.to_s}Policy".classify
policy_object = policy_class.new(current_user, curriculum_app)
- Send the method passed in as the second argument:
policy_object.deploy_access?
- If it returns a truthy value, then allow, otherwise raise an authorization error.
Armed with this knowledge, we can delegate missing methods out to more sub app specific policy objects.
When implementing something like this, I usually like to whitelist the objects that the method calls will be delegated out to. In order to do so, let’s just create a mapping:
# app/policies/sub_app_policy.rb
class SubAppPolicy < ApplicationPolicy
# Mapping of app name to policy object
APP_POLICY_MAPPING = {
'Curriculum App': SubApp::CurriculumPolicy
}
# ...
end
Next, we’ll override the method_missing
method for the SubAppPolicy
objects to try to delegate out to the specific app policy objects if allowed.
# app/policies/sub_app_policy.rb
class SubAppPolicy < ApplicationPolicy
# ...
def method_missing(method, *args, &block)
if policy_class = APP_POLICY_MAPPING[record.name.to_sym]
policy_object = policy_class.new(user, record)
policy_object.send(method, *args, &block)
else
super
end
end
end
Cool, now all we have to do is set up the deploy_access?
method in the SubApp::CurriculumPolicy
.
# app/policies/sub_app/curriculum_policy.rb
class SubApp::CurriculumPolicy < ApplicationPolicy
def deployer_access?
user.has_role?(record, :admin, :deployer)
end
end
Friendly errors
Almost done! The last thing we want to do is that if the specific app policy object also doesn’t know how to respond to the method, we want to give a more descriptive error message. We can rescue a NoMethodError
and add our own messaging to it:
# app/policies/sub_app_policy.rb
class SubAppPolicy < ApplicationPolicy
# ...
def method_missing(method, *args, &block)
if policy_class = APP_POLICY_MAPPING[record.name.to_sym]
policy_object = policy_class.new(user, record)
begin
policy_object.send(method, *args, &block)
rescue NoMethodError
raise MethodDelegationError.new "undefined method \"#{method}\" for <SubAppPolicy> and <#{policy_object.class.to_s}>"
end
else
super
end
end
class MethodDelegationError < StandardError
end
end
With the above custom error, our message will look something like SubAppPolicy::MethodDelegationError: undefined method "some_crazy_method_name" for <SubAppPolicy> and <SubApp::CurriculumPolicy>
Conclusion
Now that we have this in place, it should be more easy to find app specific authorization calls as well as general authorization across the apps.
Note that there are definitely other ways to go about solving this issue, like starting to create separate apps as opposed to a monolithic Rails app, but given the constraints and requirements, this was the best way to tackle the issue in this case.