Custom role-based authorization in Golang

mikebolshakov
9 min readApr 27, 2023

--

https://www.insurancehotline.com/resources/how-to-protect-your-valuables/

In most of my projects I have to implement some sort of authorization logic that would manage access to different business modules, functions or objects.
Of course, there are popular enterprise solutions such as Keycloak, but more often in my practice it looks like an overkill in terms of maintenance and functionality.
Here I would like to show some ideas as well as code samples on how to implement an easy and flexible authorization mechanism in golang without using additional dependencies.
So, let’s start with the basic concept.

Concept

authorization model

In the picture you can see a more or less typical RBAC(Role Based Access Control) authorization model. Let’s go through main entities…

Users

An user is a system account that has successfully passed the identification and authentication procedures and who is going to work with system resources. To be able to do anything in the system user must have access rights for the necessary resources.

Groups

Groups are used to combine multiple roles which are usually given to users together based on their business responsibilities. Groups are designed to simplify permissions management. In contrast to roles groups make business sense and often correlate with company’s organizational structure.

Roles

Roles define a stable set of permissions and usually correspond to some business functions. Typically user obtains permissions through roles.
For example, we may have “salesperson” and “accountant” groups that contain roles such as “contract reader”, “contract administrator”, “ledger administrator”, “price manager” etc..
It’s worth noting that usually it’s up to business and system analysts to describe groups and roles. Right description of groups and roles isn’t a simple task and requires both deep knowledge of the business area and good understanding of the system.

Along with group-role relations there are also explicit roles that were introduced to handle some exceptions that may occur in permissions management. They allow avoiding creating too many groups to handle some non-standard situations (for example explicit roles can be used for temporary permissions without changing the business groups of users).

Resources

The concept of resources is very important to understand because the authorization module does not know what business sense a particular resource has. It is something that has meaning to the outside world and something that must be protected. But it doesn’t matter what it is in particular. It can be an entire business module or a single entity attribute. It depends on what level of granularity is appropriate for your particular business case.

Permissions

Permissions are the lowest level of configuration. They describe the rules for accessing resources. There are “allow” and “deny” permissions which explicitly specify what operation is allowed or denied by the particular role.

A “deny” permissions always overwrite an “allow” permissions. For example, if an user has multiple roles and one of them allows the use of some resource and at the same time another role does not, user will not have access to the resource. Roles with “deny” permissions can be used to deny access to the resource regardless of other roles.

Permissions have types of operations: R (read), W (write), X (execute), D (delete). Thus, a configuration may allow reading some resource not allowing its deletion or modification.

It is not always convenient to list all the resources in configuration. For such cases there is the concept of wildcard permissions which apply not only to a single resource but to all resources matching the pattern. For example “*” applies to all possible resources. “documents.*” applies to all the resources starting with the “document” string. The obvious scenario is to give “*” permissions to the administration account.
Note, choice of resource types and requested permissions depends on the business functionality. In this sense, the authorization part is quite simple. It just answers the question if an account with the given roles can access a given resource.

Typical flow

Groups as well as roles and permissions are usually managed by some kind of superuser (administrator) using a special user interface (or API). In general, the process is as follows:

  • the administrator configures the authorization rules (groups, roles, permissions on resources)
  • the administrator includes/excludes users into/out of groups (or groups are granted or revoked automatically in response to system events)
  • user sessions are assigned roles and this happens when the user sings up/in to the system
  • the user requests operations for system resources (e.g. create a new document, read an invoice, activate an order, etc.)
  • the system checks whether the user session has sufficient rights. If there are not enough permissions the request is denied. Otherwise it is executed

Storage

The approach to storing authorization data can vary and mainly depends on who is responsible for the configuration and if there is any administrative API (or UI). When choosing to store the configuration in a database, caching should not be neglected because checking permissions is one of the busiest operations in the system.

On the other hand, for small and medium-sized projects the authorization rules are often stable and there is no need to expose the configuration API. For such scenarios I have found to be very handful to store all the authorization configurations in a set of simple json files (see example below).

A bit of source code

Here is a simplified contract which I used in one of my project:

type Group struct {
Code string // Code is a unique group code
Name string // Name to be presented to end users
Description string // Description is an overview of the group
Internal bool // Internal group is a predefined and cannot be changed
}

type Role struct {
Code string // Code is a unique role code
Name string // Name to be presented to end users
Description string // Description is an overview of the role
Internal bool // Internal role is a predefined and cannot be changed
}

type Resource struct {
Code string // Code is a unique resource code
Name string // Name to be presented to end users
Description string // Description is an overview of the resource
Internal bool // Internal resource is a predefined and cannot be changed
}

// RWXD specifies a set of permissions
type RWXD struct {
R bool // R read
W bool // W write
X bool // X execute
D bool // D delete
}

// Permissions specify allow/deny permissions on resource
type Permissions struct {
Allow RWXD
Deny RWXD
}

// RoleResourcePermission permissions for resource/role
type RoleResourcePermission struct {
RoleCode string // RoleCode
ResourceCode string // ResourceCode
Permissions Permissions // Permissions
}

// RoleWildCardPermission permissions for resource/role
type RoleWildCardPermission struct {
RoleCode string // RoleCode
ResourcePattern string // ResourcePattern allows define resource mask using "*" ("resource.*")
Permissions Permissions // Permissions
}

type SecurityService interface {
// GetGroup retrieves a group by code
GetGroup(ctx context.Context, code string) (*Group, error)
// GetAllGroups retrieves all not deleted groups
GetAllGroups(ctx context.Context) ([]*Group, error)
// GetRole retrieves a role by code
GetRole(ctx context.Context, code string) (*Role, error)
// GetAllRoles retrieves all not deleted roles
GetAllRoles(ctx context.Context) ([]*Role, error)
// GetRolesForGroups retrieves roles assigned on groups
GetRolesForGroups(ctx context.Context, groups []string) ([]string, error)
// GetResource retrieves a resource by code
GetResource(ctx context.Context, code string) (*Resource, error)
// GetAllResources retrieves all not deleted resources
GetAllResources(ctx context.Context) ([]*Resource, error)
// GetGrantedPermissions calculates permissions on the resource for the roles and applies allow/deny logic
GetGrantedPermissions(ctx context.Context, resource string, roles []string) (*RWXD, error)
// CheckPermissions checks if the roles have the requested perms on the given resource
CheckPermissions(ctx context.Context, resource string, roles []string, requestedPermissions []string) (bool, error)
// GetExplicitPermissions returns permissions on resource / roles setup explicitly
GetExplicitPermissions(ctx context.Context, resources []string, roles []string) ([]*RoleResourcePermission, error)
// GetWildCardPermissions returns wildcard permissions on roles
GetWildCardPermissions(ctx context.Context, roles []string) ([]*RoleWildCardPermission, error)
}


type SecurityStorage interface {
// GetGroup retrieves a group by code
GetGroup(ctx context.Context, code string) (*Group, error)
// GetGroups retrieves all not deleted groups
GetGroups(ctx context.Context) ([]*Group, error)
// GetRole retrieves a role by code
GetRole(ctx context.Context, code string) (*Role, error)
// GetAllRoles retrieves all not deleted roles
GetAllRoles(ctx context.Context) ([]*Role, error)
// GetAllRoleCodes retrieves all role codes
GetAllRoleCodes(ctx context.Context) ([]string, error)
// GetResource retrieves a resource by code
GetResource(ctx context.Context, code string) (*Resource, error)
// GetAllResources retrieves all not deleted resources
GetAllResources(ctx context.Context) ([]*Resource, error)
// ResourceExplicitPermissionsExists checks if there are explicit (no wildcard) permissions on the resource
ResourceExplicitPermissionsExists(ctx context.Context, code string) (bool, error)
// GetRoleCodesForGroups retrieves role codes for groups
GetRoleCodesForGroups(ctx context.Context, groups []string) ([]string, error)
// GroupsWithRoleExists checks if there are groups with assigned role
GroupsWithRoleExists(ctx context.Context, role string) (bool, error)
// GetPermissions retrieves permissions granted to roles on resource
GetPermissions(ctx context.Context, resource string, roles []string) ([]*Permissions, error)
// GetWildcardPermissions retrieves wildcard permissions granted to roles on resource
GetWildcardPermissions(ctx context.Context, resource string, roles []string) ([]*Permissions, error)
}

Once we have configured and stored authorization permissions we must provide a way for business services to check resource permissions.

Here is a quite simple implementation of how it might look. Hope this code snippet is self explanatory

// GetGrantedPermissions retrieves and merges permissions
func (s *securityService) GetGrantedPermissions(ctx context.Context, resource string, roles []string) (RWXD, error) {

// get explicit permissions
explicitPermissions, err := s.storage.GetPermissions(ctx, resource, roles)
if err != nil {
return nil, err
}

// get wildcard permissions
wildCardPermissions, err := s.storage.GetWildcardPermissions(ctx, resource, roles)
if err != nil {
return nil, err
}

permissions := append(explicitPermissions, wildCardPermissions...)

// merge all roles' permissions (explicit and wildcard)
resPermissions := &RWXD{}
for _, p := range permissions {
resPermissions.R = (resPermissions.R || p.Allow.R) && !p.Deny.R
resPermissions.W = (resPermissions.W || p.Allow.W) && !p.Deny.W
resPermissions.X = (resPermissions.X || p.Allow.X) && !p.Deny.X
resPermissions.D = (resPermissions.D || p.Allow.D) && !p.Deny.D
}

return resPermissions, nil
}



// CheckPermissions checks if the given roles allows access to the requested resources
func (s *securityService) CheckPermissions(ctx context.Context, resource string, roles []string, requestedPermissions []string) (bool, error) {

// empty request means no access
if len(requestedPermissions) == 0 {
return false, nil
}

// get granted permissions
grantedPerms, err := s.GetGrantedPermissions(ctx, resource, roles)
if err != nil {
return false, err
}

var allow = true
// check all requested permissions are granted
for _, p := range requestedPermissions {
switch p {
case auth.R:
allow = allow && grantedPerms.R
case auth.W:
allow = allow && grantedPerms.W
case auth.X:
allow = allow && grantedPerms.X
case auth.D:
allow = allow && grantedPerms.D
}
// if any of requested permissions aren't granted, return error
if !allow {
return false, nil
}
}
return true, nil
}

Example of usage

Okay, let’s look at a very trivial scenario of how this approach can be used.

Imagine the following configuration:

// groups & roles
{
"sysadmin": {
"roles": ["documents.admin"]
},
"manager": {
"roles": ["documents.reader", "documents.writer"]
}
}

// permissions on resources
[
{
"resourceCode": "documents.*",
"roleCode": "documents.admin",
"allowR": true,
"allowW": true,
"allowX": true,
"allowD": true
},
{
"resourceCode": "documents.all",
"roleCode": "documents.reader",
"allowR": true,
"allowW": false,
"allowX": false,
"allowD": false
},
{
"resourceCode": "documents.my",
"roleCode": "documents.writer",
"allowR": true,
"allowW": true,
"allowX": false,
"allowD": true
},
]
  • There are two groups of users: “sysadmin” and “manager”
  • Users in the “sysadmin” group are given the “documents.admin” role, which implies full access to all documents. Note the use of wildcard permissions for the “documents.admin” role
  • The user belonging to the “manager” group is given the roles “documents.reader” and “documents.writer”
  • The “documents.reader” role allows reading all documents
  • The “documents.writer” role allows creating documents and modifying only those created by the same user

Now lets imagine that the manager wants to modify one of his (or her) own documents

  1. The user begins the interaction by signing in
  2. A dedicated auth service checks the credentials provided and responds by generating a new JWT access token
  3. The user’s roles are retrieved from the user’s groups (assuming that the user has been previously registered and assigned groups)
  4. A session object is created. It contains a list of roles. Note that this behavior means that the role list is retained until a new login. Thus, if the user’s roles are changed it will only affect the new session, which is fine in most cases. If it is not ok, we must reject the token because the user’s configuration has changed
  5. The user then requests the document modification by calling the document service API. The access token is provided as part of the request header
  6. We assume that it is the business service’s responsibility to verify access rights for the desired resource. There are two possible scenarios for this case: the manager modifies his own document and the administrator modifies any document. Here is what a possible implementation might look like to identify both cases
// retrieve the document
doc, err := getDocument(ctx, docId)
if err != nil {
return err
}
// check if found
if doc == nil {
return NotFoundError()
}

// identify resource to be requested
var requestedResource string
if doc.userId == currentUserId {
requestedResource = "documents.my"
} else {
requestedResource = "documents.all"
}

// check permissions
allow, err := auth.CheckPermissions(ctx, currentUserId, requestedResource, ["R", "W"])
if err != nil {
return err
}
if !allow {
return AccessDeniedError()
}

Conclusion

In this post, I presented a simple approach to implement role-based authorization in golang. Of course, it doesn’t solve every possible scenario and has room for optimization, but in my personal experience it works for most projects.

I would be happy to get any opinions and thoughts.

Enjoy reading ))

--

--

mikebolshakov
mikebolshakov

Written by mikebolshakov

Golang developer & system architect

Responses (1)