Establishing a permissions system is essential for protecting application resources. Generally, these permissions are composed of actions that describe what can be done and are associated with a thin layer of logic, often related with CRUD words (create, read, update and delete).
However, these checks are not only performed through actions, we also need the subject of the resource that we want to test our permissions on. This subject is usually linked to the identity of the domain, such as users, comments, subscriptions, among others.
Following this line of reasoning, by defining a set of rules we can limit the scope of resources that can be consumed or actions that can be performed.
That said, there are several role-based access control strategies, the most common being flat hierarchy, where users are created and assigned roles based on their responsibilities. However, roles with greater relevance do not inherit permissions from lower roles, which makes each role independent. On the one hand, this structure allows for greater granularity, but on the other hand, it makes permission management more complex.
Putting It into Action
Taking into account the points mentioned above, the first step is to define the structure. Each role has a set of responsibilities, which are determined by the operations and identities we want to read or modify. We can have a structure similar to this:
package rbac
type Role string
type PermissionKind string
type Ability struct {
Action string
Resource string
}
type AbilityMap map[Role]map[string]bool
// ...
The next step depends on where the permissions logic will be implemented. Assuming it will be used to verify authority in an API, we can define a simple DSL to create an abstraction for defining and building rules based on the user's role.
// ...
type DefineRuleFunc func(role Role, action PermissionKind, resource string)
type DefineCallback func(can, cannot DefineRuleFunc)
func defineAbilities(defineFunc DefineCallback) {
abilities := make(AbilityMap)
createKey := func(action PermissionKind, resource string) string {
return string(action) + "::" + resource
}
// ...
}
The defineAbilities function initializes an empty AbilityMap
to store role permissions and creates unique keys by concatenating actions and resources with "::", allowing for a wide range of permissions to be defined for projects with many domain identities.
// ...
type DefineRuleFunc func(role Role, action PermissionKind, resource string)
type DefineCallback func(can, cannot DefineRuleFunc)
func defineAbilities(defineFunc DefineCallback) {
// ...
can := func(role Role, action PermissionKind, resource string) {
if _, exists := abilities[role]; !exists {
abilities[role] = make(map[string]bool)
}
abilities[role][createKey(action, resource)] = true
}
cannot := func(role Role, action PermissionKind, resource string) {
if _, exists := abilities[role]; !exists {
abilities[role] = make(map[string]bool)
}
abilities[role][createKey(action, resource)] = false
}
defineFunc(can, cannot)
// ...
}
The can
and cannot
functions are essential utilities for managing access control in a system. The can
function grants permission to perform an action on a resource in the ability map. On the other hand, the cannot
function explicitly denies the possibility of performing an action. The defineFunc
function makes both of these functions available and ensures that access rules are clearly defined and consistently applied.
type DefineRuleFunc func(role Role, action PermissionKind, resource string)
type DefineCallback func(can, cannot DefineRuleFunc)
type GuardFunc func(action PermissionKind, resource string) bool
func defineAbilities(defineFunc DefineCallback) func(role Role) GuardFunc {
// ...
return func(role Role) GuardFunc {
return func(action PermissionKind, resource string) bool {
if allowed, exists := abilities[role][createKey(action, resource)]; exists {
return allowed
}
return false
}
}
}
What the defineAbilities function returns is a closure where we pass the user's role as the only argument. We can then call the authorization validation function throughout the API. It checks if we have set any rules based on the role, action, and resource. If no rule is defined, the default is to deny authorization.
Rule definition
Now that I have shared a possible solution, we can create an example of how to define each of the application's roles and the types of permissions we can grant. We will use these definitions to set the rules for accessing API resources.
package rbac
const (
MANAGER Role = "manager"
DEVELOPER Role = "developer"
VIEWER Role = "viewer"
)
const (
PermissionKindRead PermissionKind = "read"
PermissionKindWrite PermissionKind = "write"
PermissionKindDelete PermissionKind = "delete"
PermissionKindAssign PermissionKind = "assign"
)
var buildGuardByRole = defineAbilities(func(can, cannot DefineRuleFunc) {
// scope: Manager
can(MANAGER, PermissionKindWrite, "Project")
can(MANAGER, PermissionKindDelete, "Project")
can(MANAGER, PermissionKindAssign, "Task")
can(MANAGER, PermissionKindRead, "Project")
// scope: Developer
can(DEVELOPER, PermissionKindWrite, "CodeRepository")
can(DEVELOPER, PermissionKindAssign, "Task")
can(DEVELOPER, PermissionKindRead, "Project")
cannot(DEVELOPER, PermissionKindDelete, "Project")
// scope: Viewer
can(VIEWER, PermissionKindRead, "Project")
can(VIEWER, PermissionKindRead, "Task")
cannot(VIEWER, PermissionKindWrite, "CodeRepository")
cannot(VIEWER, PermissionKindAssign, "Task")
})
In the code above, roles like "manager," "developer," and "viewer" have their actions limited by specific permissions, such as "read," "write," "delete," and "assign." The defineAbilities
function creates these rules, using the can
and cannot
functions to specify what each role can or cannot do with certain resources, like "Project," "Task," and "CodeRepository." The result is the buildGuardByRole
function, which is created at runtime. In this function, we need to pass the role as an argument so that the function responsible for validating the user's authority can be used.
One advantage of creating this simple DSL is that it makes the rules easy to read and understand, even though the rules are static and do not include dynamic checks like whether a user can edit a resource they did not create, but the defineAbilities
functionality is easily extensible to handle such cases.
Middleware Setup for RBAC
Creating the middleware is straightforward. In this article, I will get the user's role from the headers, but depending on the use case, it could come from a Cookie, JSON Web Token, database, or other storage methods. By default, the user's role will be set to "viewer" if it is not provided. With this role, we pass it as an argument to buildGuardByRole
to get the guard
function. Then, we inject the guard into the request context so it can be used.
package rbac
import (
"context"
"net/http"
)
const guardKey = "guard"
func RBACMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := Role(r.Header.Get("x-user-role"))
if role == "" {
role = VIEWER
}
guard := buildGuardByRole(role)
r = r.WithContext(context.WithValue(r.Context(), guardKey, guard))
next.ServeHTTP(w, r)
})
}
// ...
With the code above, we can make the validator available in the API middleware stack; however, we still need to create a utility function to add an authorization layer at the handler level. Some endpoints in the application can be accessed by everyone, but some resources are restricted to certain roles. The goal is to check the user's authority over the resource they want to access, allowing them to continue if they have permission, or receiving a forbidden error if they do not.
package rbac
import (
"context"
"net/http"
)
const guardKey = "guard"
// ...
func getGuard(ctx context.Context) GuardFunc {
if guard, ok := ctx.Value(guardKey).(GuardFunc); ok {
return guard
}
return func(action PermissionKind, resource string) bool {
return false
}
}
func Authorize(action PermissionKind, resource string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
guard := getGuard(r.Context())
if guard(action, resource) {
handler(w, r)
} else {
http.Error(w, "Forbidden", http.StatusForbidden)
}
}
}
As you may have noticed, the getGuard
function looks for the validator in the context using the guardKey
. If it is not found, we create a function that always returns false, ensuring the resource is protected by default. The Authorize
function takes three arguments: the type of permission, the resource (which should match what is defined in the rules), and the handler.
Lastly, to apply this in an example with three API endpoints, we register the middleware before the routes and use Authorize
to validate the user and allow access to the route handler for processing the business logic.
package main
import (
"net/http"
"rbacoon/rbac"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Use(rbac.RBACMiddleware)
r.Post("/projects",
rbac.Authorize(
rbac.PermissionKindWrite,
"Project",
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Project created successfully"))
},
),
)
r.Get("/projects/{projectID}",
rbac.Authorize(
rbac.PermissionKindRead,
"Project",
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Project details displayed"))
},
),
)
http.ListenAndServe(":3333", r)
}
End Note
I hope you found this article useful, whether for an existing project or just for fun, and please let me know if you find any errors by leaving a comment; the source code is available in the GitHub repository linked here.