I have User and Role models in many-to-many relations via UserRoleAssoc in Ruby-on-Rails.
I need a page (web interface) from which a user can add/delete roles associated with a user, where ordinary users but administrators can edit the roles for themselves only.
My question is how to implement the scheme, particularly authorization.
Here are the models of User and Role (just the standard many-to-many):
class User < ApplicationRecord
has_many :user_role_assocs, dependent: :destroy
has_many :roles, through: :user_role_assocs
end
class Role < ApplicationRecord
has_many :user_role_assocs
has_many :users, through: :user_role_assocs
end
class UserRoleAssoc < ApplicationRecord
belongs_to :user
belongs_to :role
end
According to DHH's principle (cf. "How DHH Organizes His Rails Controllers" by Jerome Dalbert), such actions should be implemented as if a controller, say, ManageUserRolesController, does one or more of the CRUD actions. In this case, ManageUserRolesController either or both of create and delete multiple records on UserRoleAssoc.
Since the web user interface should enable one to manage a list of roles (with a select box) in one go from a URL, I made the create method of ManageUserRolesController does both, receiving User-ID (user) and an Array of Role-IDs (roles) in params (I'm open to suggestions, though!). routes.rb is as follows:
resources :manage_user_role, only: [:create] # index may be provided, too.
Now, to restrict a user to add/delete roles to any other users, I would like to write in models/ability.rb something like, along with a Controller:
# models/ability.rb`
can :create, ManageUserRoles, :PARAMS => {user: user} # "PARAMS" is invalid!! Any alternative ideas?
can :manage, ManageUserRoles if user.administrator?
# controllers/manage_user_roles_controller.rb
class ManageUserRolesController < ApplicationController
load_and_authorize_resource
end
It seems possible to achieve it in the way described in an answer to "Passing params to CanCan in RoR" and CanCan wiki, though I think the model corresponding to the controller has to be defined to point the non-standard table, in models/manage_user_role.rb
class ManageUserRole < ApplicationRecord
self.table_name = 'user_role_assocs'
end
But this seems quite awkward…
What is the 'Rails' way (Version 6+) to implement authorization of many-to-many models? To be specific, what is a good interface to add/delete multiple roles to a user with some constraint?
Note that the route doesn't have to be like the sample code above; the route can be set so that a user-ID is passed as a part of the path like /manage_user_role/:user_id instead of via params, as long as authorization works.
Here is an answer, a solution I have used in the end.
Background
Many-to-many relation is by definition complex and I do not think there are any simple solutions that fit all cases. Certainly, Ability in CanCanCan does not support it in default (unless you do some complicated hacks, such as the way the OP wanted to avoid, as mentioned in the Question).
In this particular case of question, however, the situation which the OP wants to deal with is a constraint based on the user ID, which is basically a one-to-many (or has_many) relation, namely one-user having many roles. Then, it can actually fit in the standard way as Cancancan/Ability works.
General speaking, there are three ways to deal with the OP's case of many-to-many relation between users and roles (i.e., each user can have many roles and a role may belong to many users):
handling it as in the User (Controller) model,
handling it as in the Role (Controller) model,
or UserRoleAssoc (Controller), that is, a model associated with the join table between User and Role (n.b., this Controller is not created by default and so you must create it manually if you use it).
Let me discuss below which one of the three best fits the purpose with Cancancan authorization.
How Cancancan authorizes with Ability and what would fit this case best
For the default CRUD actions, Cancancan deals with a can statement as follows (in my understanding); this is basically a brief summary with regard to this case of the official reference of Cancancan:
for the action index, only the information Cancancan has is the User, the Model Class (with/without scopes), in addition to the action type index. So, basically, Cancancan does not and cannot do much. Importantly, a Ruby block associated with the can statement, if any, is not called.
if the (primary) ID of the model is given in the path, namely for the actions of show, edit, update, destroy, Cancancan retrieves the model from the DB and it is fed to the algorithm you provide with the can statement, including a Ruby block, if given.
In the OP's case, a user should not be authorized to handle the roles of any other users but of her/himself. Then, the judgement must be based on the two user-IDs, i.e., the one of current_user and the one given in the path/route. For Rails to pick up the latter from the path automatically, the route must be set accordingly.
Then, because the "ID" is a User-ID, the most natural solution to deal with this case is to use UsersController (case 1 in the description above); then the ID included in the default route is interpreted as User#id by Rails and Cancancan. By contrast, if you adopt case 2, the default ID in the path will be interpreted as Role#id, which does not work well with this case. As for case 3 (which was mentioned in the question), UserRoleAssoc#id is just a random number given to an association and has nothing to do with User#id or Role#id. Therefore, it does not fit this case, either.
Solution
As explained above, the action of the Controller must be selected carefully so that Cancancan correctly sets the User based on the given ID in the path.
The OP mentions create and delete (destroy) for the Controller. It is technically true in this case that the required actions are either or both of to create and delete new associations between a User and Roles. However, in Rails' default routing, create does not take the ID parameter (of course not, given the ID is given in creation by the DB!). Therefore, the action name of create is not really appropriate in this case. update would be most appropriate. In the natural language, we interpret it such that a user's (Role-association) status will be update-d with this action of a Controller. The default HTTP method for update is PATCH/PUT, which fits the meaning of the operation, too.
Finally, here is the solution I have found to work (with Rails 6.1):
routes.rb
resources :manage_user_roles, only: [:update]
# => Route: manage_user_role PATCH /manage_user_roles/:id(.:format) manage_user_roles#update
manage_user_roles_controller.rb
class ManageUserRolesController < ApplicationController
load_and_authorize_resource :user
# This means as far as authorization is concerned,
# the model and controller are User and UsersController.
my_params = params.permit('add_role_11', 'del_role_11', 'add_role_12', 'del_role_12')
end
View (to submit the data)
This can be in show.html.erb of User or whatever.
<%= form_with(method: :patch, url: manage_user_role_path(#user)) do |form| %>
Form components follow...
app/models/ability.rb
def initialize(user)
if user.present?
can :update, User, id: user.id
end
end
A key take is, I think, simplifying the case. Though many-to-many relations are inherently complex, you probably better deal with each case in smaller and more simple fragments. Then, they may fit in the existing scheme without too much hustle.
Here's the scenario to illustrate my question. I have 2 models:
# models/post.rb
belongs_to :user
validates_presence_of :comment
And we have a devise model called Users
# models/user.rb
has_many :posts
What I would like to achieve:
Person comes to the website, is able to create a Post, after creating the Post, they are prompted to create an account. After creating the account, the Post that they just created would be associated to the User they just created.
Usually i'd make use of routes to hold the params[:id] which can be accessed in the controller method. For example the URL may look something like this:
www.foo.com/foo/new/1
And then I can do this:
# foo_controller.rb
def new
#foo = Foo.new
#parent = Parent.find(params[:id])
end
And in the view I can simply access #parent and use a hidden field to fill the parent ID.
But when routing through so many different pages (such as creating a Devise User), how do I hold onto the parent/child ID such that I can still create that association?
Using an hidden field or the route to store the id, with no authorization in the process, would not be secure. What if I just use the browser inspector and change the value of the id ? Your cool post would be mine.
What you could do is, for instance, add a field called guest_id to the Post, in which the value is unique (like SecureRandom.uuid), and also store that value in the session.
Thus, after the user is created, you could do something like that
if (post = Post.find_by(guest_id: session[:guest_id])).present?
post.update(user_id: current_user.id)
end
I have a Workflow model, an Action model, and a Role model. Actions are nested attributes of a workflow, and an action has and belongs to many roles.
The associations work fine. However, in my form view, I need to add a role to the last action that has been build (note but not created).
The Workflow controller:
def create
#workflow = Workflow.new(workflow_params)
if params[:add_role] # from a submit button
Action.last.roles << Role.find(params[:role_id])
# doesn't work as no actions have been created
...
elsif params[:add_notify_action]
#workflow.actions.build # cannot save because parent hasn't been saved
end
In short, how do I get to the last Action that has been built in my controller? By definition, it's not in the database.
In long, if I can't, what's another option to get the roles added to the actions?
If an Action is a nested attribute of Workflow, when you initialize a new workflow passing the params, you initialize a new Action association. Then you could just go ahead and do:
#workflow.actions
to access the Actions. You cannot get the last one unless the Action has some attribute that defines that "last" characteristic (like a date given by the user). So consider saving them and then getting you can the last one by ordering them (created_at and updated_at fields). And before adding the persisted Roles, you'll need to also persist the action.
In the end, I decided to create a #current_action_id in my controller that I would update each time I called #workflow.actions.build. I ended up with this add_role method.
def add_role
unless #workflow.actions.empty?
#workflow.save!
role = Role.find(params[:role_id])
roles = Action.find(current_action_id).roles
roles << role unless roles.include? role or current_action_id <= 0
end
end
Thank you #engineersmnky for the tip on saving my workflow before adding the role.
In my view, I need a User object to display a few different properties. There is an instance variable #comments that's being sent from the controller. I loop through the comments and get the User information through a helper method in order to reduce db calls.
Here is the helper method:
def user(id)
if #user.blank? == false && id == #user.id
return #user
else
return #user = User.find(id)
end
end
And in the view, I display the details as follows:
<h4> <%=user(comment.user_id).name%> </h4>
<p><%=user(comment.user_id).bio%></p>
<p><%=user(comment.user_id).long_bio%></p>
<p><%=user(comment.user_id).email%></p>
<hr>
<p><%=user(comment.admin_id).bio%></p>
<p><%=user(comment.admin_id).long_bio%></p>
<p><%=user(comment.admin_id).email%></p>
I was told that assigning a variable in the view is bad practice and hence I am calling the helper method multiple times instead of assigning the returned User object.
Is there a better way to do this?
I think you are overcomplicating things here.
Let's say you have a user model
class User < ActiveRecord::Base
has_many :comments
end
an admin model
class Admin < User
end
a comment model
class Comment < ActiveRecord::Base
belongs_to :user
end
Now you only need a type column in your users table and you can do things like this:
Admin.all (All users with type "Admin")
User.all (Really all users including type "Admin" and all other types)
and for every comment you can just use
comment.user.bio
and it doesn't matter if it's an admin or not.
See http://www.therailworld.com/posts/18-Single-Table-Inheritance-with-Rails for example
Additional info: To reduce db calls in general(N+1 queries) watch http://railscasts.com/episodes/372-bullet
It's perfectly fine to pass models to your view and build the data on the view off of the data contained in the model. Keep in mind that I'm not entirely certain how you want your page to work, but one option you may have is to use a partial view and pass it the user object. This allows you to still only have the one model in your partial view without setting additional variables.
Also, without knowing what kind of database you're using or if your models have any associations, and assuming that you're doing some input validation, you may not need this helper method and may be able to lean on your ORM to get the user object.
For Example:
<%= comment.user.age %>
This isn't any more efficient than what you've currently got, but it certainly makes the code look cleaner.
Another alternative: set a user variable in the view. You're not performing logic in your view at this point, you're simply storing some data to the heap for later use.
I have an extra position attribute on my many-to-many link table. Its purpose is to define hand-ordered position of an Item in a Group. The link model is called ItemGroupMembership.
I am allowing the user to edit this by jQuery UI sortables in a Backbone application.
What is the correct way to update this attribute. Do I treat the link table like a regular model and have a item_group_memberships_controller?
How are Item's assigned to Groups in the first place?
This should be done in the item_group_memberships_controller.
So,
class ItemGroupMembershipsController < Applicationontroller
def create
#create the relationship, passing in :position
end
def update
#update the relationship by updating position
end
end