How are you handling user permissions in more complex projects?

Hi all,

The main project I work on has a sizeable database with a fairly deep taxonomy/relationship hierarchy. I am not exactly working at Silicon Valley startup scale, but to give you a sense of size, my “bottom-most” table in the taxonomy has ~1m rows and is growing fairly quickly.

Per-object permissions are vital for my application’s business logic as—apart from company staff—nobody has ‘global’ permissions. For example, if my project had a School model, it is extremely unlikely that a user would have permission to create a Class model without that permission being scoped to a certain School. This in itself is a toy scenario and does not fully show the complexity of some permissions determinations. The relationship between the model hierarchy and permissions determination is not ‘strict’ and there is some ‘dynamic’ behaviour involved in determining if a certain user has a certain permission on a certain object.

The object permissions package people usually cite is django-guardian, which at it heart seems to maintain per-object permission records in the database. This sounds like it has its merits as it’s easy to determine the permissions for any user/instance pair. Either I am misunderstanding how this is meant to be used in practice, overreacting to the theoretical performance impact, or this is not an appropriate strategy for my project. The way I see it, if I wanted to grant a user access to an object that is high in the hierarchy (like my School example from before), I would then need to calculate how those permissions should filter down to child models. This sounds like it could be an expensive operation.

My application is much more read-heavy than write-heavy, so I understand that having the expensive operations performed on-write may be ideal. However I am not entirely convinced that this is the right approach regardless.

I currently have a custom auth backend in place that essentially climbs the hierarchy for every permissions lookup. I am not caching these lookups either! as caching tends to be hard to get right, and permissions caching is not something I want to get wrong given my application’s domain. This is obviously quite taxing on the database, and does not cover all use-cases (there are permisisons edge-cases that I have DIYd outside of the Django permissions system / my auth backend).

I am curous if anyone has had any similar thoughts and/or if I have a gross misunderstanding of django-guardian or even Django permissions altogether.

3 Likes

Our use-case is similar for one of our applications in that we have complex requirements for granting access on a per-user / per-object / per-date / per-object-status basis. As a result of this, caching is not an option. (Think of a timesheet system - a person may only enter data for the current / previous-period timesheet, but only if the timesheet hasn’t been submitted, and only if they’ve been authorized to work on a task or a task above it in the WBS hierarchy, and only if the task is “active” for the date for which they’re entering time. There’s more to it than that, but I think you’re getting the idea. Something like django-guardian wouldn’t work for us because the permissions can change at any time - they’re not static by user by object.)

Anyway, our approach has been 100% dynamic. We’ve implemented our own has_perm method on our custom user model. Our views all use a specific mixin that end up calling our has_perm method. That method takes the type and instance of model being requested, and calls one of some number of specific functions to perform the test.

These tests can be very complex, in some cases involving as many as 15 different tables through various relationships in order to get the “yes/no” decision.

Bottom line for us here, to get to the root of your question, is that performance is not an issue. When you’re using a performant database like PostgreSQL, it does a lot of internal caching, and can optimize these types of queries internally.

What’s more important than the number of queries or tables themselves is ensuring that your data is structured in such a way to facilitate these queries, including the proper selection of indices.

Ken

3 Likes

A completely dynamic domain-specific has_perm() is am interesting approach, and not one that I had considered. Are you saying that your has_perm() is in essence a mapping of models to model-specific permissions calculation methods?

For those playing along at home, I had forgotten that there are other (popular) non-guardian object permissions packages, including some that tout more ‘dynamic’ behaviour: https://djangopackages.org/grids/g/perms/.

I haven’t looked into any of these yet, however django-rules looks very compelling and is at the very least practicing good open-source hygiene.

Yes.

We have a table that maps view_name to a list of target permissions (e.g. “add_timesheet”). We then have a table that maps the search criteria (“target permission” - e.g. “add_timesheet”) to a function name.

Our has_perms method then retrieves all the permissions needed for that view, and then does:

all((self.has_perm(perm, obj) for perm in perm_list))

The has_perm method then retrieves the method name from the function table and calls it:

getattr(PermissionChecks, permission_function)(perm, obj)

(Greatly simplified, but I’m sure you get the idea.)

1 Like

As a side-note - we had looked at all the available (as of 2017) row-level permission libraries and none of them (at that time) had the degree of flexibility we’ve needed, which is why we decided to roll-our-own around this mechanism.

Ha! 2017 is also when we decided to roll our own.

Thanks for sharing your experience.

1 Like

@KenWhitesell I’d really love more details on how you managed to solve this. My project has very similar needs to what you described. From that list of packages, the only one that was pretty close to what I need is django_sieve. But that one is not maintained.

What “more details” are you interested in seeing? I’m glad to share what I can. The basic idea isn’t all that complex - it’s in the implementation of all the individual permission tests where the complexity lies.

In brief, our system boils down to:

  1. Class-based views for all views
  2. Uses a custom mixin class calling our has_perm method
  3. Our has_perm method
    1. Takes the view name, and (optionally) the object being requested as input parameters
    2. Uses the view name and object as a lookup into a “permissions mapping” table to see what permission(s) are required. Each permission is mapped to a “permissions-check” function.
    3. Calls each of the permission-check functions. Each function returns True or False
    4. If all functions return True, access is granted to that object for the operation requested.
3 Likes

Thank you for taking the time,
Well, say we have a big queryset, about 30k objects.
I have 4 roles . Thing is admins can dynamically change users access on querysets based on fields on objects.
Let me paint it a bit:
we have roles: admins, grandmas, kids, parents
They all need to have some acces on apples based on their role - that’s fine, doable, normal behaviour.
Now admin goes to frontend and has a settings page with apples attributes.
They can check or uncheck : red, green, yellow, tasty, fullofworms, big, small for each user in the other roles.
Say grandmas have role permission eat_apples.
Admin can decide that one grandma can only eat yellow apples.
What we previously did was storing a user_apples_url in the user object.
We were sending the formatted expression and kept it there. So whenever someone changed it, we could change it too. How it worked was user has something like apples?icontains=“yellow” blah, a chain of attributes the user had access to.
This thing worked but the problem was for those edgecases where an attribute was contained in some other attribute name. Say department: “IT” was also contained in deparitblah.
So we dropped django_filters and thought of trying to add more clever permissions.
I have the feeling something like GitHub - alixedi/django_sieve: Serve user-wise data beautifully, minimally and correctly. would be close to our usecase but what do I know, I just have 2 years experience, grrr.
Hey can I bug you more? privately? If yes, where?
:smiley:

I’ll gladly answer any questions I can, here. I’d rather not go private because that won’t help other people on this forum in the future.

I don’t have any information to provide regarding any public third-party packages, because we decided to roll-our-own for this project about 4 years ago, and haven’t found any reason to reconsider that decision.

I will add something I hadn’t mentioned previously - we also have added the concept of “roles”. A role works similar to groups, but in our case, it’s more specific and targeted. A user may have different roles based on date and target object class. So a person may have role “X” on class “A” but role “Y” on class “B” - affecting what permissions that person has on each of those classes.

Probably the best suggestion I can make is never assign permissions directly to a person. Use groups or roles or some similar structure. Your permission checks end up being consistent, and giving or revoking permissions ends up being an assignment to groups or roles, and not directly changing the user.

2 Likes

I understand and agree talking here is more helpful, just didn’t want to add more noise.
It’s absolutely fine to talk here.
Hmmm, interesting approach. The unclear part for me is how to create roles for all possible combos?
Say , in our example, all grandmas have the permission can_eat. But some grandmas will only need to eat yellow apples, other grandmas red apples.
Thing is the attributes on “apples” are quite lengthy - say 20 colors, 40 dimensions, etc.
That would make creating roles a pain - grandmas that can eat apples of all colours, but only small, grandmas that can only eat big yellow apples, etc.
Maybe this can be achieved with filtering after all? Instead of permissions?

I’m not sure you need to do that in advance. A role don’t need to be created until someone decides that a particular set of permissions are required. At most, you need one role per user.

Some of the decisions will depend upon your specific situation and the nature of the permissions required. This definitely isn’t a one-size-fits-all type of need - which, IMO, is one of the reasons why there are a number of different third-party packages to attempt to manage access rights, with room for many more.

What I frequently see is that over time, you end up with situations like “Person X needs to have the same permissions as Person Y” - something much easier to manage with permissions being assigned to roles than to individual users. (Could be temporary assignments, job rotations, replacements, etc) And, in our case, since these role assignments are “date-bounded”, an admin can pre-assign a role in advance of it being necessary. (For example, providing for backup coverage of a key role in case of a vacation.) And since the roles are additive, they supplement rather than replace their current / permanent role.

(As a side note, and not at all a recommendation here, I worked with a system a long time ago where each attribute had a table with a column for each possible value. For example, there would be a color table with columns red, yellow, green, etc; a size table with columns small, medium, large; a type table with columns gala, granny_smith, red_delicious, etc.
Each column was a ‘Y’/‘N’ boolean field identifying whether or not permission was granted to that attribute.
A role would have multiple fields, each a many-to-many relationship to a different reference table. If the many-to-many exists, that attribute needs to be checked. So for example, if someone could eat any Red apple, then the role would have an entry for the color table, with a ‘Y’ in the red column, but the other fields would be null. If a person could only eat small yellow granny_smith apples, they would have references to the color, size, and type tables, with ‘Y’ in small, yellow, and granny_smith respectively. It took a bit to wrap your head around it, but it worked surprisingly well. It worked out that there were more possibilities of roles than the number of people who have ever lived or will ever live - times 10,000 - so it made no sense to try and generate them all. But it was sufficiently granular to allow the admins to tailor access rights to the degree necessary. In practice, with about 200 users, we probably had about 20 roles.)

2 Likes

Thank you for the lengthy explanation! I will read it carefully and come back at you after I write some code.