Building RBAC in Go
Aug 12th, 2024
Ronen Hilewicz
Roie Schwaber-Cohen
Authorization |
Integration |
Topaz
Editor's note: the original blog post has been updated to use the latest Topaz Go middleware.
Introduction
Role-Based Access Control (RBAC) is an access control pattern that governs the way users access applications based on the roles they are assigned. Roles are essentially groupings of permissions to perform operations on particular resources. Instead of assigning numerous permissions to each user, RBAC allows users to be assigned a role that grants them access to a set of resources. For example, a role could be something like mega-seed-owner
, or a portal-gun-writer
. A mega-seed-owner
like Morty Smith for example could have the permission to read
, write
, and delete
mega-seeds
, while a portal-gun-writer
like Rick would be able to read
and write
a portal-gun
.
We’ll demonstrate four Go implementations of an RBAC authorization pattern:
- A "vanilla" application that doesn't use any open-source authorization libraries.
- An application that uses the
casbin
open-source library. - An application that uses the
gorbac
open-source library. - An application that uses the
topaz
open-source authorizer and the Aserto Go SDK.
Prerequisites
In order to run the examples in this tutorial, you’ll need to have Go installed on your machine.
Setup
The code examples in this tutorial can be found in this repository. To run an example type:
go run ./<example>
For example, to run the vanilla example use:
go run ./vanilla
Shared dependencies
Our vanilla
, casbin
, and goRBAC
examples share a users.json
file that contains the users in our system and their respective roles.
[
{
"id": "summer@the-smiths.com",
"roles": ["space-cruiser-reader"]
},
{
"id": "morty@the-citadel.com",
"roles": [
"mega-seed-owner",
"portal-gun-owner",
"space-cruiser-writer"
]
},
{
"id": "rick@the-citadel.com",
"roles": [
"mega-seed-reader",
"portal-gun-writer",
"space-cruiser-owner"
]
}
]
In addition, we have three shared dependencies found in the pkg
directory in the go-rbac
repo. Let’s look at those first:
authz
In this package we define an interface for an Authorizer
:
type Authorizer interface {
HasPermission(userID, action, resource string) bool
}
An Authorizer
implements a single function, HasPermission()
, that takes a userID
, an action
, and a resource
, and returns a bool
indicating whether the user has permission to perform the action on the resource.
The ActionFromMethod
function is used to map HTTP methods to actions:
func ActionFromMethod(r *http.Request) string {
switch r.Method {
case "GET":
return "can_read"
case "PUT":
return "can_write"
case "DELETE":
return "can_delete"
default:
return ""
}
}
Next, we define the middleware function which invokes the authorizer for an incoming HTTP request. The middleware takes an Authorizer
as its sole argument and returns a http.Handler
function that inspects the request to determine the caller's identity, the kind of resource being accessed, and the requested action.
Then the middleware calls the authorizer to determine whether or not the user has permission to perform that action on the resource. If the user does not have permissions or if no user information is sent with the request, the middleware returns a 403 Forbidden response. Otherwise, it calls the next handler in the chain.
func Middleware(a Authorizer) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, _, ok := r.BasicAuth()
// This is where the password would normally be verified
resource := mux.Vars(r)["resource"]
action := ActionFromMethod(r)
if !ok || !a.HasPermission(username, action, resource) {
log.Printf("User '%s' is denied '%s' on resource '%s'", username, action, resource)
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
server
In the server
package we define the Start
function that simply starts an HTTP server that listens on port 8000.
const Port = 8000
func Start(handler http.Handler) {
addr := fmt.Sprintf("0.0.0.0:%d", Port)
fmt.Println("Staring server on", addr)
srv := http.Server{
Handler: handler,
Addr: addr,
}
log.Fatal(srv.ListenAndServe())
}
users
The users
package contains a helper function that loads the list of users defined in the users.json
file:
package users
import (
"github.com/aserto-demo/go-rbac/pkg/file"
"github.com/samber/lo"
)
type User struct {
ID string `json:"id"`
Roles []string `json:"roles"`
}
type Users map[string]User
func Load() (Users, error) {
var userList []User
if err := file.LoadJson("../users.json", &userList); err != nil {
return nil, err
}
users := lo.Associate(userList, func(u User) (string, User) {
return u.ID, u
})
return users, nil
}
Tests
To verify our server implementations we provide a test suite in rbac_test.go
.
Each test case verifies that a combination of user, action, and resource produces the expected 200 OK or 403 Forbidden response.
After starting one of the example servers tests, can be run in another shell using:
go test . -v -count=1
A successful run contains the following output:
--- PASS: TestRBAC (0.06s)
--- PASS: TestRBAC/summer@the-smiths.com:GET:mega-seed (0.01s)
--- PASS: TestRBAC/summer@the-smiths.com:GET:portal-gun (0.00s)
--- PASS: TestRBAC/summer@the-smiths.com:GET:space-cruiser (0.00s)
--- PASS: TestRBAC/summer@the-smiths.com:PUT:mega-seed (0.00s)
--- PASS: TestRBAC/summer@the-smiths.com:PUT:portal-gun (0.00s)
--- PASS: TestRBAC/summer@the-smiths.com:PUT:space-cruiser (0.00s)
--- PASS: TestRBAC/summer@the-smiths.com:DELETE:mega-seed (0.00s)
--- PASS: TestRBAC/summer@the-smiths.com:DELETE:portal-gun (0.00s)
--- PASS: TestRBAC/summer@the-smiths.com:DELETE:space-cruiser (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:GET:mega-seed (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:GET:portal-gun (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:GET:space-cruiser (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:PUT:mega-seed (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:PUT:portal-gun (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:PUT:space-cruiser (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:DELETE:mega-seed (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:DELETE:portal-gun (0.00s)
--- PASS: TestRBAC/morty@the-citadel.com:DELETE:space-cruiser (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:GET:mega-seed (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:GET:portal-gun (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:GET:space-cruiser (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:PUT:mega-seed (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:PUT:portal-gun (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:PUT:space-cruiser (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:DELETE:mega-seed (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:DELETE:portal-gun (0.00s)
--- PASS: TestRBAC/rick@the-citadel.com:DELETE:space-cruiser (0.00s)
PASS
ok github.com/aserto-demo/go-rbac (cached)
Vanilla Go RBAC
Our first example is a simple RBAC implementation in Go without the use of any authorization libraries.
The roles.json
file contains all the roles in the system. Each role maps an action to the set of resources on which it can be performed.
{
"mega-seed-owner": {
"can_read": ["mega-seed"],
"can_write": ["mega-seed"],
"can_delete": ["mega-seed"]
},
"mega-seed-writer": {
"can_read": ["mega-seed"],
"can_write": ["mega-seed"]
},
"mega-seed-reader": {
"can_read": ["mega-seed"]
},
"portal-gun-owner": {
"can_read": ["portal-gun"],
"can_write": ["portal-gun"],
"can_delete": ["portal-gun"]
},
"portal-gun-writer": {
"can_read": ["portal-gun"],
"can_write": ["portal-gun"]
},
"portal-gun-reader": {
"can_read": ["portal-gun"]
},
"space-cruiser-owner": {
"can_read": ["space-cruiser"],
"can_write": ["space-cruiser"],
"can_delete": ["space-cruiser"]
},
"space-cruiser-writer": {
"can_read": ["space-cruiser"],
"can_write": ["space-cruiser"]
},
"space-cruiser-reader": {
"can_read": ["space-cruiser"]
}
}
Next, let’s take a look at the main.go
file. The authorizer
struct holds the users found in users.json
and the roles loaded from roles.json
:
type authorizer struct {
users users.Users
roles Roles
}
It satisfies the Authorizer
interface by implementing the HasPermission
function that iterates over the user’s roles and looks for one that allows the specified action on the resource being accessed.
func (a *authorizer) HasPermission(userID, action, resource string) bool {
user, ok := a.users[userID]
if !ok {
// Unknown userID
log.Print("Unknown user:", userID)
return false
}
for _, roleName := range user.Roles {
role := a.roles[roleName]
if role == nil {
log.Printf("User '%s' has unknown role '%s'", userID, roleName)
continue
}
if allowed, ok := role[action]; ok {
if lo.Contains(allowed, resource) {
return true
}
}
}
return false
}
Finally, the program’s main function creates an HTTP router with authorization middleware that uses an authorizer initialized with the users and roles loaded from the corresponding files. It creates a gorilla/mux
router with a single handler that serves GET
, PUT
, and DELETE
requests to the /api/{resource}
path:
func main() {
users, err := users.Load()
if err != nil {
log.Fatal("Failed to load users:", err)
}
roles, err := LoadRoles()
if err != nil {
log.Fatal("Failed to load roles:", err)
}
router := mux.NewRouter()
router.Use(
authz.Middleware(&authorizer{users: users, roles: roles}),
)
router.HandleFunc("/api/{resource}", server.Handler).Methods("GET", "PUT", "DELETE")
server.Start(router)
}
Click here to view the full vanilla Go RBAC implementation.
Casbin
Casbin is a powerful and efficient open-source access control library. It has SDKs in many languages, including Javascript, Golang, Rust, Python, and others. It provides support for enforcing authorization based on various access control models: from a classic “subject-object-action” model, through RBAC and ABAC models to fully customizable models. It has support for many adapters for policy storage.
In Casbin, the access control model is encapsulated in a configuration file (src/rbac_model.conf
):
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
[policy_effect]
e = some(where (p.eft == allow))
Along with a policy/roles definition file (src/rbac_policy.conf
)
p, mega-seed-owner, mega-seed, can_delete
p, mega-seed-writer, mega-seed, can_write
p, mega-seed-reader, mega-seed, can_read
p, portal-gun-owner, portal-gun, can_delete
p, portal-gun-writer, portal-gun, can_write
p, portal-gun-reader, portal-gun, can_read
p, space-cruiser-owner, space-cruiser, can_delete
p, space-cruiser-writer, space-cruiser, can_write
p, space-cruiser-reader, space-cruiser, can_read
g, mega-seed-owner, mega-seed-writer
g, mega-seed-writer, mega-seed-reader
g, portal-gun-owner, portal-gun-writer
g, portal-gun-writer, portal-gun-reader
g, space-cruiser-owner, space-cruiser-writer
g, space-cruiser-writer, space-cruiser-reader
- The
request_definition
section defines the request parameters. In this case, the request parameters are the minimally required parameters: subject (sub
), object (obj
) and action (act
). It defines the parameters’ names as well as the order that the policy matcher uses to match the request. - The
policy_definitions
section dictates the structure of the policy. In our example, the structure matches that of the request, containing the subject, object, and action parameters. In the policy/roles definition file, we can see that there are policies (on lines beginning withp
) for each role (clone
,sidekick
, andevilGenius
) - The
role_definition
section is specific for the RBAC model. In our example, the model indicates that an inheritance group (g) is comprised of two members. In the policy/roles definition file, we can see several role inheritance rules for where a writer roles inherits from reader and owner inherits from writer (which means that an owner will also have all the writer permissions). - The
matchers
section defines the matching rules for policy and the request. In our example, the matcher is going to check whether each of the request parameters matches the policy parameters and that the roler.sub
is in the policy.
Our authorizer
struct now includes a casbin.Enforcer
field:
type authorizer struct {
users users.Users
enforcer *casbin.Enforcer
}
The implementation of the HasPermission()
function uses the enforcer
to check permissions. We iterate over each role assigned to the user, and get the decision from the enforcer for the iterated role as well as the action and asset passed to the function.
func (a *authorizer) HasPermission(userID, action, resource string) bool {
user, ok := a.users[userID]
if !ok {
// Unknown userID
log.Print("Unknown user:", userID)
return false
}
for _, role := range user.Roles {
if a.enforcer.Enforce(role, resource, action) {
return true
}
}
return false
}
Finally, we define the main
function of our program. We instantiate a new enforcer using the configuration and policy files we defined. We then load our users
file, initialize the router and middleware just like we did in the previous example.
func main() {
enforcer, err := casbin.NewEnforcerSafe("./rbac_model.conf", "./rbac_policy.csv")
if err != nil {
log.Fatal("Failed to create enforcer:", err)
}
users, err := users.Load()
if err != nil {
log.Fatal("Failed to load users:", err)
}
router := mux.NewRouter()
router.Use(
authz.Middleware(&authorizer{users: users, enforcer: enforcer}),
)
router.HandleFunc("/api/{resource}", server.Handler).Methods("GET", "PUT", "DELETE")
server.Start(router)
}
Click here to view the full Casbin implementation.
goRBAC
goRBAC
is a lightweight role-based access control module for Go. It is straightforward and easy to use, but isn’t as feature-rich as Casbin.
In this case, the role definition file looks like this:
{
"mega-seed-owner": [
"can_read-mega-seed",
"can_write-mega-seed",
"can_delete-mega-seed"
],
"mega-seed-writer": ["can_read-mega-seed", "can_write-mega-seed"],
"mega-seed-reader": ["can_read-mega-seed"],
"portal-gun-owner": [
"can_read-portal-gun",
"can_write-portal-gun",
"can_delete-portal-gun"
],
"portal-gun-writer": ["can_read-portal-gun", "can_write-portal-gun"],
"portal-gun-reader": ["can_read-portal-gun"],
"space-cruiser-owner": [
"can_read-space-cruiser",
"can_write-space-cruiser",
"can_delete-space-cruiser"
],
"space-cruiser-writer": ["can_read-space-cruiser", "can_write-space-cruiser"],
"space-cruiser-reader": ["can_read-space-cruiser"]
}
As you can see, each role is assign permissions which are the action name concatenated with the resource name.
The HasPermission()
function implementation is as follows:
type authorizer struct {
users users.Users
rbac *gorbac.RBAC
permissions gorbac.Permissions
}
func (a *authorizer) HasPermission(userID, action, resource string) bool {
user, ok := a.users[userID]
if !ok {
// Unknown userID
log.Print("Unknown user:", userID)
return false
}
for _, role := range user.Roles {
permission := action + "-" + resource
if a.rbac.IsGranted(role, a.permissions[permission], nil) {
return true
}
}
return false
}
The authorizer struct now includes both the rbac
field and the permissions
field, with the required gorbac.RBAC
object and the gorbac.Permissions
object respectively.
In the HasPermission
function implementation, we create the permission by concatenating the action and the asset passed to the function. Then, we iterate over each of the user’s assigned roles and apply the rbac.IsGranted
function to check if the user has the permission.
The main
function is implemented as follows:
func main() {
// map[RoleId]PermissionIds
var roles map[string][]string
// Load roles information
if err := file.LoadJson("roles.json", &roles); err != nil {
log.Fatal(err)
}
rbac := gorbac.New()
permissions := make(gorbac.Permissions)
// Build roles and add them to goRBAC instance
for rid, pids := range roles {
role := gorbac.NewStdRole(rid)
for _, pid := range pids {
_, ok := permissions[pid]
if !ok {
permissions[pid] = gorbac.NewStdPermission(pid)
}
role.Assign(permissions[pid])
}
rbac.Add(role)
}
users, err := users.Load()
if err != nil {
log.Fatal("Failed to load users:", err)
}
router := mux.NewRouter()
router.Use(
authz.Middleware(&authorizer{users: users, rbac: rbac, permissions: permissions}),
)
router.HandleFunc("/api/{resource}", server.Handler).Methods("GET", "PUT", "DELETE")
server.Start(router)
}
In this example, we initialize an gorbac
instance and a gorbac.Permissions
map. We then load the roles information from the roles.json
file, iterate over each role a permission tuple and add it to the gorbac
instance. Finally, we load the users
file and initialize the router and middleware just like in the previous example.
Click here to view the full goRBAC
implementation.
Topaz
Topaz takes a fundamentally different approach to authorization from the examples we’ve seen so far. First and foremost - topaz is an authorization service with an SDK that allows easy integration into applications. Topaz can be deployed as a microservice or sidecar within your application, which guarantees maximum availability as well as single-digit millisecond response time for authorization decisions.
Running the authorizer in its own service also means that authorization logic and data can be updated without modifying and redeploying application code. New kinds of resources and roles can be added with little or no changes to the application.
There are a couple of additional key differences that set topaz apart from the other libraries we’ve reviewed so far.
Policy as Code and Policy as Data
What we’ve seen in the examples so far could be grouped into an approach called “Policy as Data,” where the policy itself is expressed as data-structures that the application consumes directly or through a library.
Topaz supports that approach but also provides "Policy-as-Code" capabilities, where the policy is expressed as code. Policy code can consume data-structures much like the examples seen so far, but it can augment those checks with arbitrary logic that can handle more complex scenarios. See here for more about the differences between policy-as-code and policy-as-data.
Authorization Directory
Instead of relying on data files to describe things like users, resources, roles, and permissions in one propriatery format or another, topaz includes a directory service that provides fliexible and high-performance storage of authorization data.
The directory is with the application's domain model that describes the kinds of objects and subjects that can exist in the system and the possible relations between them.
The model we will use in this example includes definitions for types like user
and resource
. Objects of type resource
provide three roles (called relations in topaz): owner
, writer
, and reader
. Those roles are used to assign the can_read
, can_write
, and can_delete
permissions.
Here is the definition of the resource type used in this example:
resource:
relations:
owner: user
writer: user | group#member
reader: user | group#member
permissions:
can_read: reader | writer | owner
can_write: writer | owner
can_delete: owner
More about domain-model definition can be found in the docs.
Authoriztion Policy
If the topaz directory is the data store for authorization data then the authorization policy is the program that consumes the data and makes authorization decisions.
Detailed information about topaz policies can be found here but in our example we'll use topaz's built-in simple-rbac
template, which uses a ReBAC (Relation-Based Access Control) policy that makes authorization decisions based on relations between objects and subjects in the topaz directory.
Let's take a look at the policy. It consists of a single rule called check
:
package rebac.check
# default to a closed system (deny by default)
default allowed = false
# resource context is expected in the following form:
# {
# "relation": "relation or permission name",
# "object_type": "object type that carries the relation or permission",
# "object_id": "id of object instance with type of object_type"
# }
allowed {
ds.check({
"object_type": input.resource.object_type,
"object_id": input.resource.object_id,
"relation": input.resource.relation,
"subject_type": "user",
"subject_id": input.user.id,
})
}
The policy uses topaz's built-in ds.check()
function to determine if the user has the specified relation (or permission) to a given object identified by its type and ID.
Server Implementation
The topaz implementation of our application is slightly different from the previous examples. Instead of defining its own middleware we use the Aserto Go SDK, go-aserto
, which offers middleware implementations for popular Go frameworks including gorilla/mux
. All we need to do is create and configure the middleware.
func AsertoAuthorizer(addr string) (*gorillaz.Middleware, error) {
azClient, err := az.New(aserto.WithAddr(addr))
if err != nil {
return nil, err
}
mw := gorillaz.New(
azClient,
&middleware.Policy{
Decision: "allowed",
Root: "rebac",
},
)
mw.Identity.Mapper(func(r *http.Request, identity middleware.Identity) {
if username, _, ok := r.BasicAuth(); ok {
identity.Subject().ID(username)
}
})
return mw, nil
}
The AsertoAuthorizer function takes the address of a topaz instance (localhost:8282 by default) and uses it to create an authorizer client. It then uses that client to create middleware for gorilla/mux
routers.
We then attach an identity mapper to the middleware. The identity mapper is a function that inspects the incoming HTTP requests and determines the identity of the caller. In our case we use basic-HTTP auth. We read the username and set it as the subject of the authorization call.
The main
function uses the middleware to further annotate our server's routes with instructions for deriving the required poilcy parameters (object_type
, object_id
, and relation
) from incoming requests:
func main() {
authorizerAddr := os.Getenv("AUTHORIZER_ADDRESS")
if authorizerAddr == "" {
authorizerAddr = "localhost:8282" // default topaz authorizer port
}
authorizer, err := AsertoAuthorizer(authorizerAddr)
if err != nil {
log.Fatal("Failed to create authorizer:", err)
}
log.Print(os.Getenv("AUTHORIZER_API_KEY"))
router := mux.NewRouter()
router.Use(authorizer.Check(
gorillaz.WithObjectType("resource"),
gorillaz.WithObjectIDFromVar("resource"),
gorillaz.WithRelationMapper(authz.ActionFromMethod),
).Handler)
router.HandleFunc("/api/{resource}", server.Handler).Methods("GET", "PUT", "DELETE")
server.Start(router)
}
We configure the behavior of the authorization by telling it to:
- Set the
object_type
toresource
. - Set the
object_id
to the value of the{resource}
path parameter in our gorilla/mux route (/api/{resource}
). - Set the
relation
using the sameActionFromMethod()
function we used in the other examples.
Click here to view the full Topaz implementation.
Setting up Topaz
To install topaz, follow the instructions for your OS.
With topaz installed we can now bootstrap our authorizer using the built-in simple-rbac
template:
topaz templates install simple-rbac -f
This command:
- Initializes topaz's domain model.
- Configures topaz to use the simple-rbac authorization policy.
- Starts the topaz authorizer in a docker container.
- Imports sample data (users and resources) into the topaz directory.
- Opens the topaz console in a web browser.
With topaz running you can start the the example server and run the tests.
Modify Authorization Data
As mentioned, one of the advantages of running the authorizer as a service is that authorization poilcies and data can evolve without requiring changes to the application logic. Let's add a new kind of resource to the system.
We have worked with three kinds of resources so far: mega-seeds, portal-guns, and space-cruisers. The simple-rbac template we installed has a fourth resource called time-crystal
with Morty as a reader and Rick as a writer.
Without modifying or even restarting our application we can immediately start authorizing requests to access this resource.
We can see that, as a writer, Rick is allowed to send PUT requests:
❯ curl -X PUT -f -u rick@the-citadel.com:x http://localhost:8000/api/time-crystal
"Access granted"
But he cannot DELETE because he's not an owner:
❯ curl -X DELETE -u rick@the-citadel.com:x http://localhost:8000/api/time-crystal
Forbidden
Summary
In the post, we reviewed multiple ways of adding RBAC to your application. We’ve seen that in most cases, users are not considered a first-class citizen in the authorization offering and that the process of role resolution is left to the developer, and ends up as part of the application itself, which introduces many risks. We’ve also seen that most solutions take the “Policy-as-Data” approach as opposed to the “Policy-as-Code” approach.
While it might seem easier to use a library to implement RBAC in your Golang application, it is important to consider the lifecycle of the application and how it’ll grow. How will new users and roles be added? What would be the implications of changing the authorization policy? How will we reason about the authorization policy when it gets to be more complex?
Using a library means that you assume ownership of the authorization component - which requires time and effort to build and maintain. By using a service such as Aserto you can offload the responsibility of managing the authorization flow - without sacrificing the performance or availability of your application.
This post was written in collaboration with Ronen Hilewicz - Thanks Ronen!
Ronen Hilewicz
Principal Engineer
Roie Schwaber-Cohen
Developer Advocate
Related Content
Adding Authorization to a Go app with Topaz
In this tutorial, we'll learn how to add authorization to a Todo app written in Go, using the Aserto Go SDK.
Aug 20th, 2024
Authorization 101: Multi-tenant RBAC
Every multi-tenant B2B SaaS product needs an authorization model. The trick is to build one that can scale with your app.
Sep 9th, 2024
API Authorization using Aserto and Zuplo
Platform Engineering teams that want to authorize access to their APIs now have a turnkey solution from Aserto and Zuplo.
Sep 25th, 2024