How Render Enforces Access Controls with Go Generics
April 16, 2024

How Render Enforces Access Controls with Go Generics

Shawn Moore
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.

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").
The question was, how could we most effectively incorporate a strictly-enforced, fine-grained permissions model into our existing Go codebase?

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 resulting AuthorizedProject 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:
An in-IDE generics mismatch error
An in-IDE generics mismatch error
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.

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 an UnsafeAuthorize() 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.

Building from here

Compile-time permission checks are extremely helpful, but they aren't a free lunch. Adding a new permission does involve updating more total code than a runtime check would require. But in important ways, those additional updates are beneficial: we get a complete punch list of codepaths to update for each new set of assumptions. This already exposed a couple of subtle security holes before we launched, and it will surely protect us from many more in the future. Security is non-negotiable for every feature we build—our users rightly demand impeccable execution here. The architecture we've created for role-based access control aims to minimize the opportunity for privilege escalation. By embedding permission checks directly into the type system, we minimize the vigilance required by code authors and reviewers alike. This enables us to spend more time building improvements to the Render platform.