0
votes

Let's say that there is this modeling: Company, has many users, has many conferences, has many plannings through conferences, has many events through plannings, has many questions through events.

Let's say there is also a User model, having a roles field mixing binary additions of various roles. Few of those roles are:

  • Cross-Company.
  • Moderator.
  • Administrator (Pretty much the same as moderator for this question needs).

Then, there is this Participation object. A participation belongs to a user, as well as a conference, and defines some more roles, which can be anything but cross-company. Basically, it allows a user to be moderator on a set of conferences and not globally. Any role defined on the user supersedes the roles on the participation.

Now, i'm struggling to find the correct Cancancan ability definition for such model. The hard part seems to be taking the participation roles definitions into consideration.

At the beginning of the abilities declaration, i decompose rights for easier usage:

# Decompose rights.
cross         = user.is? :cross_company
admin         = user.is? :administrator
modo          = user.is? :moderator

Only user roles are decomposed, there is not yet anything regarding the participation roles.

Here is an example of what i got working already.

# Any user can read it's own company. Any cross-company user can read any company.
can     :read,                  Company, cross ? nil : { id: user.company_id }
# Only cross-company admins can create a new company.
can     :create,                Company if cross && admin
# Updating a company.
can     :update,                Company do |c|
  # User is admin and...
  admin &&
  (
    # User is cross or...
    cross ||
    # User belongs to this company.
    user.company_id==c.id
  )
end
cannot  :destroy,               Company

So far so good, i'm able to do more complex scenarios. For instance, the event object is authorized like this:

conditions                      = {}
# User can only see events he is participating into, unless he is admin.
conditions.deep_merge!          id: user.event_ids unless admin
# User can only see events related to his company, unless he is cross-company.
conditions.deep_merge!          planning: { conference: { company_id: user.company_id } } unless cross
can     :read,                  Event, conditions

Very good. The RESTful controllers listing events objects are displaying only authorized objects.

Now, let's move on the the Question object (Question belongs to an event, which belongs to a planning, which belongs to a conference, itself belonging to a company). Basically, any user should be able to see question he asked (Easy-peasy, question.user_id==user_id). A user with the global "moderator" flag should be able to see any question another user has asked. A cross-company moderator should be able to see any question.

There is however, an exception to this behavior. When a user (Non-admin non-modo) has a participation object linking him to an event with some overriding roles on the participations (E.g. a given user could be moderator for a single conference), he should be able to see any questions from any user within the events from the conference he is authorized as a moderator. i'm stuck at this point:

# Only questionable events can have questions.
conditions                      = { event: { questionable: true } }
# Administrator / Moderator can see all questions, others can't.
conditions.deep_merge!          user_id: user.id unless admin || modo
# The conference a question belongs to has to be owned by the same company as the user, unless he's cross company.
conditions.deep_merge!          event: { planning: { conference: { company_id: user.company_id } } }  unless cross
can     :read,                  Question,  conditions

How could i override the line reading

conditions.deep_merge!          user_id: user.id unless admin || modo

So not only global admins / moderators can do this, but also users with a single role defined in participation? The objective is of course for the ability to define the SQL that is used when accessing a controller, but i can't seem to find a good solution for this problem.

Is there anyone who solved such paradigm with maybe a different approach?

Hint: the questions controller is using nested resources (/api/events/1/questions.json). How can i get the ID of the event being used from within the ability.rb file, so i might also check for the current user participations to this specific event?

Thanks! Pierre.

1

1 Answers

0
votes

i might have found one solution for this problem, but i don't think it's very optimal. Basically, thinking about the problem, i realised that roles defined on the user-level apply to classes of models, while roles defined in the participations apply on instances of model. This means that at least instances IDs for the later needs to be known at run time for the SQL to be generated. Here is my solution.

On the User model, define some helper methods, allowing to quickly retrieve IDs of conferences authorized by participations for a given role:

  def participations_as (*roles)
    @participations_as_cache ||= {}
    roles.flatten.map do |role|
      @participations_as_cache[role] ||= participations.select { |p| p.is?(role) ? p : false }
    end.flatten.uniq
  end
  def conference_ids_as (*roles)
    participations_as(roles).map{ |p| p.conference_id }
  end

This allows to get a list of authorized conferences for any given role(s) for a user, like this:

user.conference_ids_as role1, role2, ..., roleN

Now, as you might know, defining two times the same ability actually OR' them. Let's use this to our advantage:

# This first block will build up authorization query hash regardless of per-instance roles defined via participations.
conditions                      = { event: { questionable: true } }
conditions.deep_merge!          event: { planning: { conference: { company_id: user.company_id } } }  unless cross
conditions.deep_merge!          user_id: user.id unless admin || modo
can     :read,                  Question,  conditions

# This second block will add an OR'ed condition to the first one, allowing to filter questions via conferences authorized by the participation roles.
conditions                      = { event: { questionable: true } }
conditions.deep_merge!          event: { planning: { conference_id: user.conference_ids_as(:moderator, :administrator) } }
can     :read,                  Question,  conditions

The resulting SQL seems correct:

SELECT "questions"."id"            AS t0_r0, 
       "questions"."user_id"       AS t0_r1, 
       "questions"."event_id"      AS t0_r2, 
       "questions"."status"        AS t0_r3, 
       "questions"."anonymous"     AS t0_r4, 
       "questions"."contents"      AS t0_r5, 
       "questions"."created_at"    AS t0_r6, 
       "questions"."updated_at"    AS t0_r7, 
       "events"."id"               AS t1_r0, 
       "events"."planning_id"      AS t1_r1, 
       "events"."room_id"          AS t1_r2, 
       "events"."starts_at"        AS t1_r3, 
       "events"."ends_at"          AS t1_r4, 
       "events"."questionable"     AS t1_r5, 
       "events"."created_at"       AS t1_r6, 
       "events"."updated_at"       AS t1_r7, 
       "plannings"."id"            AS t2_r0, 
       "plannings"."conference_id" AS t2_r1, 
       "plannings"."created_at"    AS t2_r2, 
       "plannings"."updated_at"    AS t2_r3, 
       "conferences"."id"          AS t3_r0, 
       "conferences"."company_id"  AS t3_r1, 
       "conferences"."name"        AS t3_r2, 
       "conferences"."created_at"  AS t3_r3, 
       "conferences"."updated_at"  AS t3_r4 
FROM   "questions" 
       LEFT OUTER JOIN "events" 
                    ON "events"."id" = "questions"."event_id" 
       LEFT OUTER JOIN "plannings" 
                    ON "plannings"."id" = "events"."planning_id" 
       LEFT OUTER JOIN "conferences" 
                    ON "conferences"."id" = "plannings"."conference_id" 
WHERE  "questions"."event_id" = ? 
       AND ( ( "events"."questionable" = 't' 
               AND "plannings"."conference_id" IN ( 1 ) ) 
              OR ( "events"."questionable" = 't' 
                   AND "conferences"."company_id" = 1 
                   AND "questions"."user_id" = 1 ) ) 

As a result, only questions a user is authorized to see are displayed, including the ones authorized via the participations-level roles.

i'm still looking for a more efficient solution, maybe by using a different approach. Do you know any? Basically, and formulated in other words: how one would accomplish Class-level authorizations as well as Instance-level authorizations using the Cancancan GEM without to much database IO every time a check needs to be made?