Building RBAC in Go

Aug 12th, 2024

Ronen Hilewicz avatar

Ronen Hilewicz

Roie Schwaber-Cohen avatar

Roie Schwaber-Cohen

Authorization  |  

Integration  |  

Topaz

Building Role-based Access Control in Golang

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.

multiverse

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 with p) for each role (clone, sidekick, and evilGenius)
  • 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 role r.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:

  1. Set the object_type to resource.
  2. Set the object_id to the value of the {resource} path parameter in our gorilla/mux route (/api/{resource}).
  3. Set the relation using the same ActionFromMethod() 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:

  1. Initializes topaz's domain model.
  2. Configures topaz to use the simple-rbac authorization policy.
  3. Starts the topaz authorizer in a docker container.
  4. Imports sample data (users and resources) into the topaz directory.
  5. 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 avatar

Ronen Hilewicz

Principal Engineer

Roie Schwaber-Cohen avatar

Roie Schwaber-Cohen

Developer Advocate