Last year, Render introduced support for member roles, enabling admins to restrict access to team-level actions (like updating payment details or toggling mandatory 2FA). This week, we added the ability for admins to restrict potentially destructive actions in project environments, such as deleting a service.
It's vital to get role enforcement right. From the day we kicked off the project, our priority has been to ensure that access policies are applied consistently, with no escape hatches that can lead to privilege escalation.
To achieve this, we wanted compile-time guarantees in our Go codebase that would prevent our engineers from creating such an escape hatch even by accident. Let's take a peek under the hood and explore how we implemented these guarantees using Go generics.
Unfortunately, we haven't been able to merge this last step yet. Generics are still relatively new to Go, and they aren't yet supported by all of the industry tools we use. We've submitted some PRs upstream to help advance the ecosystem.
Background
Prior to the addition of member roles, Render's permissions model was as basic as it gets: every member of a Render team could perform every team action. This usually worked fine for small teams, but our larger customers were (rightly) wary of granting full account access to tens or hundreds of people. An improved permissions system became one of our most requested features. As design began, we knew our implementation would use the industry best practice of Role-Based Access Control (RBAC):- Each team member is assigned a role (we currently support two roles: Admin and Developer).
- Render defines a set of individual permissions (such as "can invite new team members" or "can view billing information").
- Render enforces a set of policies that map roles to permissions ("Admins can delete their team, Developers cannot").
Non-starter: Basic runtime checks
As a thought experiment, we considered the simplest approach to implement: folding permission checks into existing runtime logic that verifies a user's team membership before authorizing an action. Every time our code made a membership check, we'd make an additional check for the user's role. This would enable us to differentiate protected team actions (like "remove team member") from universal actions (like "list team members"): We immediately rejected this strategy, because it was opt-in—nothing would have protected us from forgetting to add the correct role check for a particular action. Because of this (not to mention the immense amount of existing code we'd need to audit), we knew we couldn't trust that this solution was fully secure.Pivoting to compile time
We needed to enforce an invariant across our entire codebase—namely, one that prevented engineers from accidentally circumventing permission checks. The typical approach would lead to user-facing errors at runtime: By instead enforcing this at compile time, we could surface errors to engineers before those errors even made it into a pull request: Conveniently for us, in a strongly-typed language like Go, the type system itself is ideal for doing exactly this. We used the opaque type pattern, which builds on top of Go's visibility rules. Opaque types ensure at compile time that the necessary permission check always occurs. Let's look at an example. In the snippet below, we create a simple wrapper struct (AuthorizedProject
) around our existing Project
type:
(Projects enable teams to organize their Render services by application and environment.)
The AuthorizedProject
struct is public so that other packages can refer to it, but its project
field is private. Only code in this rbac
package—such as the AuthorizeProject
constructor—can directly access the inner Project
.
The only way to create an AuthorizedProject
struct is via the AuthorizeProject()
constructor, which ensures that a permission check occurs as part of initialization. This in turn helps us avoid needlessly re-checking permissions throughout our stack: by passing around an AuthorizedProject
instead of a plain Project
, we guarantee that the original caller performed a permission check.
Note that this design requires separate execution paths for reads and writes to the same entity.
The snippets above show the write path for
Project
, where the caller performs a permission check to access the opaque Project
that the model layer requires. Reads are the inverse: the model layer vends an opaque object, and the caller performs a permission check to gain access to it.
Pulling in generics
At this point, our design guaranteed that we made a permission check whenever a user attempted to interact with a team resource. This was already a major improvement, because it gave us a comprehensive list of codepaths that we needed to update for each access policy. However, we weren't yet guaranteeing that the correct permission was checked! It was still possible to perform a permission check for "can update project", then pass the resultingAuthorizedProject
object to the "delete project" model method.
To avoid this mismatch, we considered extending the AuthorizedProject
struct with a permission
field, which would be populated by the AuthorizeProject()
constructor. Each model method would then be able to confirm at runtime that permission
was set to a requisite value.
But again, we didn't just want to verify the permission level at runtime, which could result in our API returning spurious errors. We wanted to do it at compile time! This would enable us to surface any mismatches during active development, while ensuring that we avoid merging an incorrect permission check. Let's look at how Go generics made this possible.
First, we defined each project-related permission as a distinct struct that implements a common ProjectPermission
interface:
Next, we created an instance of each permission struct so we could refer to them at runtime:
We then added a generic type parameter to our AuthorizedProject
struct. The AuthorizeProject()
constructor populates this parameter, which must be an implementation of ProjectPermission
:
The new signature for AuthorizeProject()
has a subtle but important effect: the type parameter T
dictates which runtime permission can be passed in. This way, we enforce that the runtime value permission
always matches the compile-time type T
. (For example, a caller can't accidentally ask for conflicting permissions, as with AuthorizeProject[ProjectDeleteT](project, user, ProjectUpdate)
.)
Finally, at the model layer we enforce that the type parameter matches the expected permission:
With these changes, misuse of the permission system is flagged as a compile-time error. This provides an ideal developer experience for Render engineers, because IDEs can find and display type errors inline at edit time:
Putting the "safe" in unsafe
Some logic in our code does need to sidestep the permissions system, most notably actions related to bootstrapping. For example, whenever a user creates a new team, we need to add them to that team. But the "add user to team" API requires the user have the Admin role for that team, which they of course don't have yet. To handle these uncommon cases, we've defined anUnsafeAuthorize()
function. The "unsafe" keyword declares to readers that the compiler can't guarantee the permission check at compile time. Render engineers review all uses of this function with extra care and scrutiny, and we require an accompanying comment to explain how each use maintains RBAC integrity.