Inducing how I should build permissions systems
2025-02-28
I have been warned that your permissions framework for your application is something that is part of the architecture, i.e., that you had better get it right early or you will suffer. I haven't built very many projects that require an incredibly detailed permissions framework, but I do feel the need to be ready when the time does come.
Now, while I haven't built projects that require permissions, I have certainly used projects that require permissions. I'm sure that there are a number of existing conceptual frameworks for permissions, but before I do the right thing and read up on those, I'll do the wrong thing and try to derive the principles from an existing implementation.
Components
I recently got certified with AWS. Permissions are managed in AWS using their "Identity and Access Management" service, or IAM. AWS IAM policies are hardly paragons of elegance, but their complexity comes directly from AWS needing to meet the needs of thousands of real enterprises.
If we start from AWS IAM's current state, we can identify the following components as core to a permissions system:
- The action, which is just a class of, well, action that can be taken upon a resource. For AWS, this is something like
"sqs:SendMessage"
. - The resource, which are specific instances of objects which actions may affect. IAM policies identify AWS resources by their "Amazon Resource Name", which is a meaningful formatted string rather than a meaningless integer ID.
- The user. An IAM policy can be attached to three things: a user, a group, or a role. All three of these things are ultimately a proxy for the "thing that would like to perform the action," which is sometimes a human and sometimes a computer. IAM policies themselves do not contain this user information, but it is available from what the IAM policies are attached to.
- The conditions, which are miscellaneous truth statements that can block a policy from being valid.
Presumably these policies are evaluated when some user, human or otherwise, tries to invoke an action through the action's HTTPS API endpoint. The trivial way to implement permissions would of course be to write the logic directly into the endpoint handler function, but this is clearly not a feasible approach for an application where users may control the permissions of other users. Users are not able to edit code. That is why in IAM, these components are all expressed in JSON, except for the user, which is expressed by attaching the policy to an IAM user/group/role.
Of course, if you are familiar with AWS yourself, you may notice that this is an oversimplification of IAM. I excluded things like permissions boundaries, identity-based versus resource-based policies, and service control policies from the example. I hear that these are all a series of band-aids that were implemented as the result of the gradual emergence of customer use cases. If I wanted to overanalyze, I could get into these too, but for now I will not.
Permissions design for web applications
I suppose the AWS management console itself is a "web application," but I want to see if we can take the distilled IAM principles and reconstruct them into a practical framework for implementing permissions in other web applications.
- Most web applications will have some sort of user model, each instance of which will have a unique ID.
- Most web applications will also have many other models that may be considered resources, each instance of which will also have a unique ID.
- I expect that most web applications will also have various actions that are tied, tightly or loosely, to endpoints.
- By convention, actions usually involve creating, reading, updating, or deleting instances of some model. If this is where we can stop, then our framework becomes trivial; alas, we cannot stop here.
- The problem I have with this is that there are some endpoints which initiate long and complex chains of CRUD operations on multiple models (i.e., database tables). While this phenomenon is regrettable, I do not believe that it is desirable to require endpoints to couple tightly to models, so this is a reality I am willing to accept.
- It does seem that it is more acceptable to couple actions to models, for example
vehicle:Create
if you have a Vehicle model. If you have a complex endpoint, then you need to check for whether the user can perform the action on the given resources. You will need to perform these checks in the code. - Another downside to coupling actions to models but decoupling endpoints from both is that the permission policy needed to allow a user to use a complex endpoint may, itself, be complex. The way AWS seems to manage this is by providing pre-built "AWS-managed policies." Stranger use cases are enabled by letting the customer write their own bespoke policies. Of course, if your application and organization can pressure people to get certified, or otherwise learn the application in onerous detail, then you can take this approach too. If your application is more self-serve, then I believe may be worthwhile to publish "required permissions" per complex endpoint (or per endpoint in general).
Now, given this, there will be a number of implementation details based on these concepts:
- Your database for your web application must contain a model for your users. Following the IAM concepts, there should also be groups, or roles depending on whether you want to adhere to the RBAC terminology rather than the AWS IAM terminology. (We will refer to them as groups for consistency.)
- Most other tables will be a resource on which actions may be performed. You may have a separate catalog of actions expressed as data -- if you choose to do this, you may have to use metadata tables like Postgres's
information_schema.tables
, since you will be storing foreign keys to tables which are resources. - There will be a number of tables for expressing permissions (specifying resources and actions) and attaching said permissions to groups.
- Your handler functions for each endpoint will perform zero or more actions on some resources. In the handler function, you must perform permissions checks in guard clauses. In your endpoint documentation, you must publish which actions are relevant for the endpoint.
- Optionally, you may find a way to express conditions in your permissions model(s). Related to that, you must consider as well what the default behavior of some actions will be. AWS's default behavior is to deny by default -- policies that allow actions must be attached to enable actions. You may consider implementing different default behaviors than "automatically deny" by attaching implicit policies to users and groups that allow, for example, users full edit permissions on resources that they created. Of course, this leads to another question of how to implement this. My first instinct on this is to encode this as a condition that can be encoded as data that has an implementation in code when evaluated. Of course, before I commit to that, I would need to give it more thought.
Remember that this is only necessary if some of your users, who may be superusers, must be able to affect the permissions of non-superusers. If only you, the programmer, must be able to affect permissions, you may find it workable to simply implement permissioning logic in your endpoint handlers.