Mathieu Larose

How Authorization is Implemented in Libredesk

March 2025

Introduction

Libredesk was recently featured on Hacker News, and as someone who has worked a lot on authorization in various organizations, I was curious to explore how it is implemented.

A quick disclaimer: this is my interpretation of the current state of Libredesk. The project is still in its alpha stage, and its implementation may evolve. Additionally, I may have overlooked some details.

Summary

At a high level, Libredesk relies primarily on role-based access control (RBAC), with elements of relationship-based access control (ReBAC) for managing access to key resources like conversations and their associated content (e.g., messages within a conversation).

The system includes two default roles: admin and agent. Admins have full access, while agents, by default, can view or manage all conversations.

By default, authorization in Libredesk follows an all-or-nothing approach: a signed-in user with one of these roles can access all conversations, while others have no access.

However, Libredesk also supports more granular access control, allowing restrictions such as limiting agents to only their assigned conversations or their team's conversations, aligning with ReBAC principles.

For example, a new role could be created where an agent only sees their team's conversations, whereas the current agent role functions more like a supervisor role.

That said, granular access is not consistently enforced across all areas of the system (see lack of permissions-aware data filtering below).

Implementation

Where authorization is stored

Authorization data is stored in a SQL database. The database schema has the following tables:

A roles table with a list of permissions for each role:

CREATE TABLE roles (
    id SERIAL PRIMARY KEY,
    ...
    permissions TEXT[] DEFAULT '{}'::TEXT[] NOT NULL,
    "name" TEXT UNIQUE NOT NULL,
    description TEXT NULL,
  CONSTRAINT constraint_roles_on_name CHECK (length("name") <= 50),
  CONSTRAINT constraint_roles_on_description CHECK (length(description) <= 300)
);

A users table:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    ...
    email TEXT NULL,
    first_name TEXT NOT NULL,
    last_name TEXT NULL,
    ...
);

And a user_roles table to assign roles to users:

CREATE TABLE user_roles (
  id SERIAL PRIMARY KEY,
  ...
  user_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
  role_id INT REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,

  CONSTRAINT constraint_user_roles_on_user_id_and_role_id_unique UNIQUE (user_id, role_id)
);

And then two default roles - Agent and Admin:

INSERT INTO
  roles ("name", description, permissions)
VALUES
  (
    'Agent',
    'Role for all agents with limited access to conversations.',
    '{conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage}'
  );

INSERT INTO
  roles ("name", description, permissions)
VALUES
  (
    'Admin',
    'Role for users who have complete access to everything.',
    '{ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
  );

Where authorization is enforced

Libredesk is an single-page app with an API backend. Authorization is enforced on each API endpoint using a perm middleware in handlers.go:

  g.GET("/api/v1/conversations/all", perm(handleGetAllConversations, "conversations:read_all"))
  g.GET("/api/v1/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations:read_unassigned"))
  g.GET("/api/v1/conversations/assigned", perm(handleGetAssignedConversations, "conversations:read_assigned"))
  g.GET("/api/v1/teams/{id}/conversations/unassigned", perm(handleGetTeamUnassignedConversations, "conversations:read_team_inbox"))
  g.GET("/api/v1/views/{id}/conversations", perm(handleGetViewConversations, "conversations:read"))
...

The perms function retrieves the user's session and calls Enforce which wraps Casbin, an open-source authorization library.

While Casbin is capable of ReBAC, Libredesk primarily uses it for RBAC, with custom ReBAC logic implemented on top of it.

Libredesk's Casbin model:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act

This model grants users (sub) permission to perform an action (act) on a resource type (obj) rather than on specific objects, effectively making it an RBAC implementation (and not ReBAC).

However, EnforceConversationAccess constains a custom ReBAC logic:

	if conversation.AssignedUserID.Int == user.ID {
		if allowed, err := checkPermission("read_assigned"); err != nil || allowed {
			return allowed, err
		}
	}

Similarly, for team access:

// Check `read_team_inbox` permission for team-assigned conversations
	if conversation.AssignedTeamID.Int > 0 && slices.Contains(user.Teams.IDs(), conversation.AssignedTeamID.Int) && conversation.AssignedUserID.Int == 0 {
		if allowed, err := checkPermission("read_team_inbox"); err != nil || allowed {
			return allowed, err
		}
	}

Considerations

Lack of permissions-aware data filtering

When filtering resources like conversations or messages, there is no per-resource permission filtering, beyond checking if a user has conversations:read or messages:read permissions. This results in an all-or-nothing access model.

EnforceMediaAccess

In EnforceMediaAccess, access is granted by default when the model is unknown:

func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
	switch model {
	case "messages":
		allowed, err := e.Enforce(user, model, "read")
		if err != nil {
			return false, envelope.NewError(envelope.GeneralError, "Error checking permissions", nil)
		}
		if !allowed {
			return false, envelope.NewError(envelope.UnauthorizedError, "Permission denied", nil)
		}
	default:
		return true, nil
	}
	return true, nil
}

Conclusion

Libredesk's authorization implementation is good for a small team, where users have broad access. With the perm middleware, every endpoint is protected from anonymous access. However, achieving more granular access control will require additional work, which is expected for an alpha-stage project.

Like this article? Get notified of new ones: