role-based vs. attribute-based authorization? (in Golang)

role-based vs. attribute-based authorization? (in Golang)

Role-based authorization

Role-based authorization (RBAC) is a model where for defining a set of roles, such as admin, manager, or customer, and assign them to users. Each role has a predefined set of permissions, such as create, read, update, or delete, for different resources, such as products, orders, or reports. For example, an admin role may have all permissions for all resources, while a customer role may only have read permission for products and orders. To check if a user can access a resource or perform an action, you simply compare their role with the required permission.

The main advantages of RBAC are simplicity, scalability, easily to create and manage roles and permissions applying them to large numbers of users. (Reuse) the same roles and permissions across different applications or domains, as they are not tied to specific attributes or contexts. However, RBAC also has some drawbacks, such as rigidity and complexity. create many roles and permissions to cover all the possible scenarios and variations, which can lead to duplication and inconsistency. it might difficulty handling exceptions or dynamic situations, where the access rules depend on factors other than roles, such as time, location, or data.

Attribute-based authorization

Attribute-based authorization (ABAC) is a model where you define a set of policies, rules, or conditions that determine the access rights of users based on their attributes or characteristics. Attributes can include anything that describes a user or a resource, such as name, age, department, location, status, or type. For example, a policy may state that only users who are over 18 and belong to the marketing department can update the product catalog, or that only customers who have paid their orders can view their invoices. To check if a user can access a resource or perform an action, you evaluate their attributes against the relevant policy or rule.

The main advantages of ABAC are flexibility and granularity. You can create and customize policies and rules to fit any scenario or requirement, and adjust them as needed. You can also specify the access rights with more precision and detail, and account for dynamic or contextual factors, such as time, location, or data. However, ABAC also has some drawbacks, such as complexity and performance. You may need to define and manage many policies and rules, which can be hard to understand and maintain. You may also have to deal with conflicts or inconsistencies among different policies or rules, or between attributes and roles. Additionally, you may face performance issues or security risks, as evaluating attributes and policies can be costly or vulnerable.

Here it 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 will explain 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, try 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