00:00:02
Our next guest came to us from San Francisco yesterday. He's still a little bit jetlagged, but he's ready to talk about authorization in Rails. Ladies and gentlemen, please welcome Yatish Mehta.
00:00:22
Thank you everyone, and welcome to my talk, 'No Pun Intended: The Power of Rails Authorization.' Yes, the pun is intended. Before we start, I have a simple trivia question: What should we put in the place of the question mark so that it prints both statements? I have a book giveaway.
00:01:06
You could fork... no, okay, it's way simpler than you guys think. You want both sentences to be printed, so the output should be: 'I like Sernik' and 'I like Paknik.'
00:01:38
I think you could do if puts 'I like Sernik' and false. Yep, that is correct. But we have two people who answered that question, so I'll find another book for you.
00:01:56
My name is Yatish Mehta. I live in San Francisco, California, in the States. I've been a Rubist for more than 10 years. I'm active on Twitter and GitHub. I'm the tech lead at Asana for authorization. If you don't know what Asana is, Asana is a work collaboration platform to manage your projects and tasks. We are based out of San Francisco as the HQ, but we have an engineering office in Warsaw, and we are hiring, so feel free to talk to me after the session if you're interested.
00:02:44
Let's start with authentication and authorization. These are terms that people often get mixed up. Authentication deals with who you are. It makes sure the user is what they claim they are — username, password, SSO, SAML. These terms are related to authentication. In Rails, we'll use gems like Devise or Rails to manage authentication. Authorization, on the other hand, deals with what the user can do, what actions and permissions are allowed for this user. Since I'm traveling, a good example is: passport is for authentication, but the visa stamp is for authorization.
00:03:18
Let's talk about authorization in Rails and the different approaches we use. First, basic implicit authorization: we fetch the project scoped to the current user, and if the project exists, we allow the user; otherwise, we render unauthorized. Technically, there's no explicit authorization check, but our query scopes ensure only the projects the current user owns are shown. So queries and scopes take care of authorization implicitly. It's simple to implement, but now the business logic and authorization rules are combined, and as our app grows, it becomes difficult to manage authorization this way.
00:04:48
The next solution is the CanCan gem. It allows you to create an ability.rb file where you define all the rules and permissions for the user. For example, we've defined the read permission for the post resource for the user, and then you can check the permission in the controller using the authorize method, and also check it in views. Simple, right? But as your app grows, the ability.rb file also grows and becomes unmanageable. This is a 60-line snippet from a real production app ability file. Your permissions might depend on each other, making refactoring very difficult.
00:06:00
With CanCan, we get separation of concerns since all authorization logic is in one place. But as the app grows, the ability file keeps growing, which is not scalable. Next is Pundit, the most popular authorization gem in Rails. There's also Action Policy, a revised version of Pundit with additional features but the same structure and philosophy. Pundit provides the concept of policies; you define a plain old Ruby class (PORO) for your resource with instance methods as permission checks. Since it's Ruby, you can define any logic within those methods. You can use it in controllers and views easily.
00:07:04
Let's build a policy for a project management app. The features are: organize work with projects and tasks; tasks can belong to multiple projects; users get added to projects with roles: admin, editor, commenter; you can also add whole teams to projects with the same roles. Users can be part of multiple teams. Visually, users, projects, tasks, and teams are related: tasks can belong to multiple projects; projects have users and teams; users belong to teams.
00:09:14
We define who can edit a task: if you're the creator of the task, or if you're an admin or editor of the project, you can edit the task. You can have admin or editor access either directly or through a team. So the update permission checks these conditions. If any are true, you can edit the task. A new use case: if the task is sensitive, only project admins can edit it. So the policy checks if the task is sensitive; only then allow admin access. Otherwise, use the old path. As you can see, complexity increases. Limitations of Pundit as the app grows: refactoring is difficult since changing one permission can affect others. There's no automated way to find these dependencies, so you must navigate policies carefully.
00:11:00
Performance issues: calculating permissions might cause N+1 query problems. Permissions can't be cached between requests because permission logic is a black box; you evaluate every time. For example, Figma mentioned 23% of their SQL queries relate to access control. Debugging why a user was not allowed needs manual checks in the Rails console. Similarly, no easy way to audit or debug why a user was granted access. Pundit doesn't store data about who has access, so you can't request 'list of users who can edit this task.' It's always opposite: given a user and resource, returns if allowed.
00:13:00
Is there a better way to model authorization? Yes, fine-grained authorization (FGA), or relationship-based access control, inspired by Google's Zanzibar project. It stores relationships between entities as tuples (subject, relationship, object), called access control lists (ACLs). This forms a graph where subjects can be resources in other relationships. You define authorization data statically, then rules for permissions on this graph. Multiple FGA providers exist, each with their own DSL but similar concepts. Checking a permission is asking if there's a path from user to resource in this graph according to rules.
00:16:20
How to implement FGA or relationship-based access control in Ruby/Rails? I created a gem called Granity to support this. Add the gem and artifacts to create a migration and model to store tuples. Define the authorization schema in an initializer: define resource types (user, team, project, task), their relationships (team members, project admins/editors/commenters), and build a graph out of it. Define permissions with a DSL that supports any/or logic combining relationships and permissions, e.g., who can edit tasks. Create tuples whenever users are added/removed or roles changed. Check permissions via a method passing user, resource, and permission name, which evaluates the data graph and rules.
00:21:01
Advantages over previous approaches: since the model is a schema, you can track dependencies, making refactoring easier. Performance is improved: no loading of intermediate objects, it traverses IDs directly. Granity supports smart caching, caching results between requests and invalidating cache upon changes. Auditing is possible: you can get the path that enabled access, helping debug why users have permissions. Reverse lookups allow asking which users have a permission for a resource. Caveat: no pagination, so for large data sets be careful. There are also external open-source authorization-as-a-service providers for fine-grained authorization, such as OpenFGA, OSO, and Permit.io, all based on Zanzibar, with advanced features and playgrounds for testing. Using those services fits distributed microservice architectures.
00:23:26
To summarize: start simply with Pundit. For fine-grained authorization, use something like Granity. For truly distributed systems, consider authorization-as-a-service providers. That is it. Any questions?
00:25:54
Question 1: How to model the sensitive task case? Answer: Create a new entity 'SensitiveTask' with a relationship to the actual task indicating it's sensitive. In the authorization model, this relationship defines sensitivity. Even if not a model in the main app, you create it in the authorization schema.
00:26:18
Question 2: How difficult is it to keep all relationships in sync? Answer: It's a challenge because you duplicate or move your many-to-many relationships from your models into a normalized table. You must manage it explicitly, ensuring after creating or updating, corresponding authorization model entries exist. Within a monolith, this can be in a single transaction. Using an external service needs synchronization between your Rails app and service, requiring manual management and logic duplication, but only for relationships relevant for authorization.
00:26:57
Question 3: In Pundit, you can define scopes in policies. How do you define scopes for relationship-based access control? Answer: Relationship-based access control lacks scopes as in Pundit. You must explicitly query. This is a limitation compared to Pundit. The logic is constrained; you define relations explicitly without arbitrary Ruby code. This is a trade-off and constraint of this model.
00:29:00
Any other questions? No? Thank you very much. Awesome, thank you!