How to implement RBAC in Go
Project Description
For the purpose of this article, we will assume we have a bunch of blog posts and we want to define permissions for certain operations based on the following user roles:
- viewer – can view all blog posts
- editor – can view, create, edit and delete his/her own blog post
- admin – gets all privileges
Before we jump in, I'll like to note that this is an article about authorization not authentication and so the parts of the code we will write that should contain the logic for authentication will be omitted. You should fill them out yourself while you follow along.
By the end of this lesson – as a bonus, we'll add an in-memory cache to avoid performing database calls every time we need to access restricted resources.
Database Schemas
As indicated in the previous section, we'll need a table for posts. Posts need authors therefore we'll add a table for users as well. In your migration file add the following:
CREATE TABLE IF NOT EXISTS users (
id INT generated always as identity ,
name TEXT NOT NULL ,
email TEXT NOT NULL UNIQUE ,
password BYTEA NOT NULL ,
created_at TIMESTAMPTZ DEFAULT current_timestamp ,
PRIMARY KEY ( id )
);
CREATE TABLE IF NOT EXISTS posts (
id INT generated always as identity ,
title TEXT NOT NULL ,
content TEXT NOT NULL ,
slug TEXT NOT NULL ,
author_id INT ,
created_at TIMESTAMPTZ DEFAULT current_timestamp ,
PRIMARY KEY ( id ),
FOREIGN KEY ( author_id ) REFERENCES users ( id ) ON DELETE SET NULL
);
We've created users and posts tables. The posts table has a foreign key constraint on its author_id property that references the users table.
Now that we have that out of the way, we need to create schemas for roles and permissions. In your migration file add the following:
CREATE TABLE IF NOT EXISTS roles (
id INT generated always as identity ,
role TEXT ,
description TEXT ,
PRIMARY KEY ( id )
);
CREATE TABLE IF NOT EXISTS user_roles (
role_id INT ,
user_id INT ,
PRIMARY KEY ( role_id , user_id ),
FOREIGN KEY ( role_id ) REFERENCES roles ( id ) ON DELETE CASCADE ,
FOREIGN KEY ( user_id ) REFERENCES users ( id ) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS permissions (
id INT generated always as identity ,
name TEXT ,
PRIMARY KEY ( id )
);
CREATE TABLE IF NOT EXISTS role_permission (
permission_id INT ,
role_id INT ,
PRIMARY KEY ( permission_id , role_id ),
FOREIGN KEY ( role_id ) REFERENCES roles ( id ) ON DELETE CASCADE ,
FOREIGN KEY ( permission_id ) REFERENCES permissions ( id ) ON DELETE CASCADE
);
Here we added a couple of tables – roles, user_roles, permissions, role_permission. user_roles and role_permission are linking tables that connect users to their roles in the case of user_roles and a role to its permissions in the case of role_permission.
Seeding the Database
We need to seed our roles, permissions and role_permission tables with the necessary data. In your migration file add:
INSERT INTO roles ( role , description ) VALUES
( 'viewer' , 'can view all blog posts' ),
( 'editor' , 'can view, create, edit and delete a blog post' ),
( 'admin' , 'all privileges' );
INSERT INTO permissions ( name ) VALUES
( 'users:read' ),
( 'users:update' ),
( 'posts:read' ),
( 'posts:create' ),
( 'posts:update' ),
( 'posts:delete' );
INSERT INTO role_permission ( permission_id , role_id ) VALUES
(( SELECT id FROM permissions WHERE name = 'posts:read' ), ( SELECT id FROM roles WHERE role = 'viewer' )),
(( SELECT id FROM permissions WHERE name = 'posts:read' ), ( SELECT id FROM roles WHERE role = 'editor' )),
(( SELECT id FROM permissions WHERE name = 'posts:create' ), ( SELECT id FROM roles WHERE role = 'editor' )),
(( SELECT id FROM permissions WHERE name = 'posts:update' ), ( SELECT id FROM roles WHERE role = 'editor' )),
(( SELECT id FROM permissions WHERE name = 'posts:delete' ), ( SELECT id FROM roles WHERE role = 'editor' )),
(( SELECT id FROM permissions WHERE name = 'posts:read' ), ( SELECT id FROM roles WHERE role = 'admin' )),
(( SELECT id FROM permissions WHERE name = 'posts:create' ), ( SELECT id FROM roles WHERE role = 'admin' )),
(( SELECT id FROM permissions WHERE name = 'posts:update' ), ( SELECT id FROM roles WHERE role = 'admin' )),
(( SELECT id FROM permissions WHERE name = 'posts:delete' ), ( SELECT id FROM roles WHERE role = 'admin' )),
(( SELECT id FROM permissions WHERE name = 'users:read' ), ( SELECT id FROM roles WHERE role = 'admin' )),
(( SELECT id FROM permissions WHERE name = 'users:update' ), ( SELECT id FROM roles WHERE role = 'admin' ));
Initializing the Project
Initialize your go project with the command go mod init demoblog and create an entry file main.go. In your entry file add the following:
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
type config struct {
port int
}
type application struct {
cfg config
handler func () http . Handler
}
func ( app * application ) mount () {
app . handler = func () http . Handler {
mux := http . NewServeMux ()
return mux
}
}
func ( app * application ) run ( ctx context . Context ) error {
app . mount ()
serv := & http . Server {
Addr : fmt . Sprintf ( ":%d" , app . cfg . port ),
Handler : app . handler (),
}
select {
case err := <- app . serve ( serv ) :
return err
case <- ctx . Done () :
return app . shutdown ( serv )
}
}
func ( app * application ) serve ( serv * http . Server ) <- chan error {
ch := make ( chan error , 1 )
go func () {
ch <- serv . ListenAndServe ()
}()
return ch
}
func ( app * application ) shutdown ( serv * http . Server ) error {
ctx := context . Background ()
ctx , cancel := context . WithTimeout ( ctx , 5 * time . Second )
defer cancel ()
return serv . Shutdown ( ctx )
}
func main () {
var cfg config
flag . IntVar ( & cfg . port , "port" , 3000 , "application port" )
flag . Parse ()
ctx := context . Background ()
ctx , cancel := signal . NotifyContext ( ctx , syscall . SIGTERM , syscall . SIGINT )
defer cancel ()
app := & application {
cfg : cfg ,
}
if err := app . run ( ctx ); err != nil {
log . Fatal ( err )
}
}
Here we have created an http server that runs on port 3000. To start your server, in your terminal run the command go run main.go. If for some reason you've got another application running on port 3000, you can add a flag to run your server on another port like so – go run main.go --port=3001.
Adding Entities
Before we get to writing repository and handler methods, let's create a couple of structs that map directly to properties of the tables in our database.
We have a users table so we need a User struct. In your working directory add an internal folder. We'll be adding packages to this folder. In internal create a package called entities and add a user.go file. In user.go add the following:
package entities
import "time"
type User struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Email string `db:"email" json:"email"`
Password []byte `db:"password" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
We have a posts table so let's create a post.go file in the same package and add the following:
package entities
import "time"
type Post struct {
ID int `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
Slug string `db:"slug" json:"slug"`
AuthorID int `db:"author_id" json:"author_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
Let's do the same for permissions. Create a permission.go file in the same package and add the following:
package entities
import "slices"
const (
PostsRead = "posts:read"
PostsWrite = "posts:create"
PostsUpdate = "posts:update"
PostsDelete = "posts:delete"
UsersRead = "users:read"
UsersUpdate = "users:update"
)
type Permissions []string
func ( p Permissions ) Can ( permission string ) bool {
return slices . Contains ( p , permission )
}
Permissions is a named type that has a method – Can. Can checks if the permission passed to the method as an argument is contained in the slice of permissions it holds.
Implementing Users
Before we write any code, let's go over what we need to do. We need to create a user and assign said user its default role. In our example viewer is the default role for new users.
If you recall what we did in our sql schemas, we have separate tables to store user information – users and their role – user_roles. To successfully add a user we'll need to perform two operations – add a user and add the user's role. We also don't want to do both database writes in two separate actions. This is the exact problem transactions solve. By definition a database transaction is a single logical unit of work made up of multiple operations.
To execute database queries, we need to add a postgres driver to our project. pgx is the recommended one by consensus so we'll use it. To add it as a dependency to your project run the command go get github.com/jackc/pgx/v5 in your terminal.
When the installation is done, create a users package in internal then add repository and handler files. In your internal/users/repository.go file add the following:
package users
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"demoblog/internal/entities"
)
type dbtx interface {
Query ( ctx context . Context , query string , args ... any ) ( pgx . Rows , error )
QueryRow ( ctx context . Context , query string , args ... any ) pgx . Row
Exec ( ctx context . Context , query string , args ... any ) ( pgconn . CommandTag , error )
}
type Repository interface {
CreateUser ( ctx context . Context , param CreateUserParam ) ( * entities . User , error )
ListUsers ( ctx context . Context ) ([] entities . User , error )
UpdateUserRole ( ctx context . Context , id int ) error
}
type repository struct {
db dbtx
dB * pgx . Conn
}
func NewRepository ( db * pgx . Conn ) * repository {
return & repository {
db : db ,
dB : db ,
}
}
func ( r * repository ) withTx ( db dbtx ) * repository {
return & repository {
db : db ,
dB : r . dB ,
}
}
func execTx ( ctx context . Context , db * pgx . Conn , fn func ( pgx . Tx ) error ) error {
tx , err := db . BeginTx ( ctx , pgx . TxOptions {})
if err != nil {
return err
}
defer tx . Rollback ( ctx )
if err := fn ( tx ); err != nil {
return err
}
return tx . Commit ( ctx )
}
func ( r * repository ) CreateUser ( ctx context . Context , param CreateUserParam ) ( * entities . User , error ) {
var user entities . User
err := execTx ( ctx , r . dB , func ( tx pgx . Tx ) error {
var err error
user , err = r . withTx ( tx ) . createUser ( ctx , param )
if err != nil {
return err
}
return r . withTx ( tx ) . assignRole ( ctx , user . ID )
})
if err != nil {
return nil , err
}
return & user , nil
}
func ( r * repository ) createUser ( ctx context . Context , param CreateUserParam ) ( entities . User , error ) {
query := `
INSERT INTO users (name, email, password)
VALUES (@name, @email, @password)
RETURNING id, name, email, password, created_at;
`
row := r . db . QueryRow ( ctx , query , pgx . NamedArgs {
"name" : param . Name ,
"email" : param . Email ,
"password" : hashPassword ( param . Password ),
})
var user entities . User
err := row . Scan ( & user . ID , & user . Name , & user . Email , & user . Password , & user . CreatedAt )
if err != nil {
return user , err
}
return user , nil
}
func ( r * repository ) assignRole ( ctx context . Context , id int ) error {
query := `
INSERT INTO user_roles (user_id, role_id)
VALUES (@user_id, (SELECT id FROM roles WHERE role = 'viewer'));
`
_ , err := r . db . Exec ( ctx , query , pgx . NamedArgs {
"user_id" : id ,
})
return err
}
func ( r * repository ) ListUsers ( ctx context . Context ) ([] entities . User , error ) {
query := `SELECT id, name, email, password, created_at FROM users;`
rows , err := r . db . Query ( ctx , query )
if err != nil {
return nil , err
}
return pgx . CollectRows ( rows , pgx . RowToStructByName [ entities . User ])
}
func ( r * repository ) UpdateUserRole ( ctx context . Context , id int ) error {
query := `
UPDATE user_roles
SET role_id = (SELECT id FROM roles WHERE role = 'editor')
WHERE user_id = @id;
`
_ , err := r . db . Exec ( ctx , query , pgx . NamedArgs {
"id" : id ,
})
return err
}
func hashPassword ( _ string ) [] byte {
// TODO: add implementation
return nil
}
This code snippet has quite a number of moving parts so let's talk about it. There's a repository struct, that implements the Repository interface but that isn't really all it does. It has two extra methods – createUser and assignRole. Both methods perform the operation their names describe – but they are two separate queries and we need them to happen in one call.
execTx is a helper function that encapsulates the logic for performing database transactions. We also have a withTx method on the repository struct that in fact returns a reference to the repository itself. This looks kinda odd but it's a nifty trick to make the methods on the struct reusable. All of this allows us perform both operations in one CreateUser call.
ListUsers fetches all users from the database and UpdateUserRole updates a user's role – it basically promotes a viewer to editor.
Now let's write our handler. In internal/users/handler.go add the following:
package users
import (
"encoding/json"
"io"
"net/http"
"strconv"
)
type CreateUserParam struct {
// TODO: add fields
}
Comments
No comments yet. Start the discussion.