DEV Community

Oyedele Temitope
Oyedele Temitope

Posted on

How to Set Up Authorization in a Bookstore Management System with Go, HTMX, and Permit.io

Authorization is important when building applications, as it determines what actions and resources a user is allowed to access after they authenticate.

In this article, we'll take a look at how to implement authorization using permit.io. To demonstrate it, we'll be building a simple bookstore app using Golang and HTMX (I'm a huge fan).

Table of Content

Prerequisites

To follow through this tutorial, the following prerequisites should be met:

  • Golang installed along with a basic understanding of it.
  • A Permit.io account.
  • Docker installed.
  • Basic understanding of HTML, HTTP, and REST APIs.
  • PostgreSQL (database).
  • Familiarity with SQL.

Project Scope

  • For this demonstration, we'll keep things simple. We'll have two user types, an admin and a standard user. Both will be registered on Permit.io. Upon login, the database will consult Permit.io to determine the user's role and authorize their actions.

All users (admins included) can read books. Admins can also add, delete, and update books. Standard users are limited to reading books.

This tutorial will guide you through setting up a bookstore application with basic authorization. We’ll implement:

  • Authorization Logic: Define roles (Admin and Standard User) using Permit.io to restrict or grant access to different resources.

  • Database: Set up a PostgreSQL database to store book and user data.

  • Handlers: Implement routes for viewing, adding, updating, and deleting books with access control checks.

  • Frontend: Use HTMX to load book data dynamically.

Project Setup

In setting up the project, we’ll start by setting up permit.io. Navigate to your dashboard workspace and create a new project. I’ll give mine the name of bookstore.

creating the project

This will create two environments: a development environment and a production environment.

environment created by Permit

Since we’re working locally, we’ll use the development environment. Click on Open dashboard in the Development environment and then on Create Policy. You’ll be asked to create a new resource first. Click create resource. Give it a name and state the actions. For this project, I’ll name mine books, and the action will be create, update, delete, and view.

create a new resource

Next, navigate to the policy editor section. By default, you should see an admin role already created. You just need to tick the view action we added, as it is not recognized by default. You need another role. This will be for users with only permission to read.

Click Create then Role and give it the name of user. Once created, you should see it in the policy editor and tick view in the user role you just created like so:

create policy editor

The next thing is to register the users who would be authorized by permit.io. Navigate back to your home menu through the sidebar menu you should still have something like this:

Home

Click on Add users and then add, then add user. Fill in the details that will correspond to your users in the database.

Once that is done, navigate back to your project. In the Development For the environment for the bookstore project, click on the 3 dotted icon. You’ll see an option to copy the API key. Copy and save it somewhere, as you’ll be needing it for the project.

Setup the Database

Create a PostgreSQL database called bookstore. You’ll need to set up two tables:

  • users table: Stores user credentials and roles:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(50) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Go ahead and populate this, but make each user have a role of admin and user, respectively, and make sure they match the users added on Permit.io.

  • books table: Stores book details:

CREATE TABLE books (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title VARCHAR(255) NOT NULL,
  author VARCHAR(255) NOT NULL,
  published_at DATE,
  created_at TIMESTAMPTZ DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

You don’t need to populate this we’ll be doing that in the code.

Install Dependencies

You’ll need to install the following dependencies:

  • github.com/permitio/permit-golang: Provides tools for handling role-based access control (RBAC) and permission management with Permit.io in Go applications.

  • github.com/google/uuid: This provides functions to generate and work with universally unique identifiers (UUIDs).

  • github.com/gorilla/mux: Helps to Implement an HTTP request router and dispatcher for handling routes in a web application.

  • github.com/joho/godotenv: This loads environment variables from a .env. file into the application, making it easier to manage configuration settings.

  • github.com/lib/pq: This is Go’s Postgres driver for communicating with PostgreSQL databases.

  • golang.org/x/crypto: Implements supplementary cryptographic algorithms and libraries that are not included in Go's standard library.

To install these dependencies, you need to initialize initializes a new Go module. This is the starting point for dependency management in Go.

Run this command:

go mod init bookstore
Enter fullscreen mode Exit fullscreen mode

Next, run this command:


go get github.com/google/uuid \
       github.com/gorilla/mux \
       github.com/joho/godotenv \
       github.com/lib/pq \
       github.com/permitio/permit-golang \
       golang.org/x/crypto
Enter fullscreen mode Exit fullscreen mode

This will install all the dependencies listed above.

Setup your PDP (Policy Decision Point) Container

To set up the PDP, you’ll need to start up docker. Once you do, open up your terminal and run this command:

docker pull permitio/pdp-v2:latest
Enter fullscreen mode Exit fullscreen mode

After which you need to run the container with this command:

 docker run -it -p 7766:7000 --env PDP_DEBUG=True --env PDP_API_KEY=<YOUR_API_KEY> permitio/pdp-v2:latest
Enter fullscreen mode Exit fullscreen mode

Replace the part that says <YOUR_API_KEY> with your actual API key. Now, let’s start building.

Build the Application

To build the application, this is how our project structure will be:


Bookstore                
├── config               
│   └── config.go        
│
├── handlers             
│   └── handlers.go      
│
├── middleware           
│   └── middleware.go    
│
├── models               
│   └── models.go        
│
├── templates            
│   ├── add.html         
│   ├── books.html       
│   ├── index.html       
│   ├── layout.html      
│   ├── login.html       
│   └── update.html      
│
├── main.go              
└── .env
Enter fullscreen mode Exit fullscreen mode

Let's first add our API Key inside a .env file. Create one, and then your permit API key like so:

export PERMIT_API_KEY=”your_api_key”
Enter fullscreen mode Exit fullscreen mode

Configure Database Connection

Create a folder called config. Inside it, create a file called config.go. Add the following code:


package config

import (
  "database/sql"
  "fmt"

  _ "github.com/lib/pq"
)

type Config struct {
  DB       *sql.DB
  Port     string
  DBConfig PostgresConfig
}

type PostgresConfig struct {
  Host     string
  Port     string
  User     string
  Password string
  DBName   string
}

func NewConfig() *Config {
  return &Config{
    Port: "8080",
    DBConfig: PostgresConfig{
      Host:     "localhost",
      Port:     "5432",
      User:     "bookstore_user",
      Password: "your_password",
      DBName:   "bookstore_db",
    },
  }
}

func (c *Config) ConnectDB() error {
  connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
    c.DBConfig.Host,
    c.DBConfig.Port,
    c.DBConfig.User,
    c.DBConfig.Password,
    c.DBConfig.DBName,
  )

  db, err := sql.Open("postgres", connStr)
  if err != nil {
    return fmt.Errorf("error opening database: %v", err)
  }

  if err := db.Ping(); err != nil {
    return fmt.Errorf("error connecting to database: %v", err)
  }

  c.DB = db
  return nil
}
Enter fullscreen mode Exit fullscreen mode

This is just us setting up a configuration to connect to a PostgreSQL database.

Create the Handlers

Next, create a folder called handlers, and inside it, create a file called handlers.go. Inside it, add the following code:


package handlers

import (
  "bookstore/middleware"
  "bookstore/models" 
  "context"
  "database/sql"
  "fmt"
  "html/template"
  "net/http"
  "strings"
  "time"

  "github.com/google/uuid"
  "github.com/permitio/permit-golang/pkg/config"
  "github.com/permitio/permit-golang/pkg/enforcement"
  permitModels "github.com/permitio/permit-golang/pkg/models"
  "github.com/permitio/permit-golang/pkg/permit"
)

var tmpl = template.Must(template.ParseGlob("templates/*.html"))

func StringPtr(s string) *string {
  return &s
}

type Handlers struct {
  db           *sql.DB
  permitClient *permit.Client
}

func NewHandlers(db *sql.DB, apiKey string) *Handlers {
  permitConfig := config.NewConfigBuilder(apiKey).
    WithPdpUrl("http://localhost:7766").
    Build()
  permitClient := permit.NewPermit(permitConfig)
  if permitClient == nil {
    panic("Failed to initialize Permit.io client")
  }

  return &Handlers{
    db:           db,
    permitClient: permitClient,
  }
}
Enter fullscreen mode Exit fullscreen mode

Aside from importing the packages, what we’re trying to do here is create a structure that holds the database connection and permit.io. We are also providing an initialization function that sets up Permit.io with local PDP.

Right after the NewHandlers add this in:


func (h *Handlers) LoginHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                if r.Method == http.MethodGet {
                        if err := tmpl.ExecuteTemplate(w, "login.html", nil); err != nil {
                                http.Error(w, "Error rendering template", http.StatusInternalServerError)
                                return
                        }
                        return
                }

                username := r.FormValue("username")
                password := r.FormValue("password")

                user, err := middleware.LoginUser(h.db, username, password)
                if err != nil {
                        http.Error(w, "Invalid login credentials", http.StatusUnauthorized)
                        return
                }
                role := user.Role

                http.SetCookie(w, &http.Cookie{
                        Name:     "username",
                        Value:    username,
                        Path:     "/",
                        Expires:  time.Now().Add(24 * time.Hour),
                        HttpOnly: true,
                        Secure:   r.TLS != nil,
                })

                ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
                defer cancel()

                permitUser := permitModels.NewUserCreate(username)
                permitUser.SetAttributes(map[string]interface{}{
                        "role": role,
                })

                _, _ = h.permitClient.SyncUser(ctx, *permitUser)

                data := struct {
                        Username string
                        Role     string
                }{
                        Username: username,
                        Role:     role,
                }

                if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
                        http.Error(w, "Error displaying page", http.StatusInternalServerError)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

The LoginHandler does the following:

  • Handles both GET (show login form) and POST (process login).
  • Authenticates users against the database.
  • Sets session cookies for authenticated users.
  • Syncs user data with Permit.io for authorization.
  • Renders appropriate templates based on login success/failure.

The next step is to add a book handler to access the books. It will also utilize permit.io to verify the user's role. Add the following code right after the LoginHandler:


func (h *Handlers) BooksHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "view", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        http.Error(w, "Access denied", http.StatusForbidden)
                        return
                }

                rows, err := h.db.Query("SELECT id, title, author, published_at, created_at FROM books")
                if err != nil {
                        http.Error(w, "Error fetching books", http.StatusInternalServerError)
                        return
                }
                defer rows.Close()

                var books []models.Book
                for rows.Next() {
                        var book models.Book
                        var publishedAt sql.NullTime

                        err := rows.Scan(&book.ID, &book.Title, &book.Author, &publishedAt, &book.CreatedAt)
                        if err != nil {
                                http.Error(w, "Error reading book data", http.StatusInternalServerError)
                                return
                        }

                        if publishedAt.Valid {
                                book.PublishedAt = &publishedAt.Time
                        }

                        books = append(books, book)
                }

                if err = rows.Err(); err != nil {
                        http.Error(w, "Error reading book data", http.StatusInternalServerError)
                        return
                }

                if err := tmpl.ExecuteTemplate(w, "books.html", books); err != nil {
                        http.Error(w, "Error displaying books", http.StatusInternalServerError)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

The BookHandler does the following:

  • Checks user authentication via cookies.
  • Verifies user role and permissions using Permit.io.
  • Fetches books from the database if authorized.
  • Renders books template with fetched data.
  • Handles authorization failures appropriately.

Next, you need a handler to add books. It will also verify the user's role through Permit.io to ensure only authorized users can add books:


func (h *Handlers) AddBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
        func (h *Handlers) AddBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "create", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        w.Header().Set("Content-Type", "text/html")
                        fmt.Fprint(w, `
                                <!DOCTYPE html>
                                <html lang="en">
                                <head>
                                        <meta charset="UTF-8">
                                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                                        <title>Access Denied</title>
                                        <script>alert('You cannot add a book as you are an admin.')</script>
                                </head>
                                <body>
                                        <p>You do not have permission to add books.</p>
                                        <a href="/books">Back to Books</a>
                                </body>
                                </html>
                        `)
                        return
                }

                if r.Method == http.MethodGet {
                        if err := tmpl.ExecuteTemplate(w, "add.html", nil); err != nil {
                                http.Error(w, "Error displaying page", http.StatusInternalServerError)
                        }
                        return
                }

                if r.Method == http.MethodPost {
                        title := strings.TrimSpace(r.FormValue("title"))
                        author := strings.TrimSpace(r.FormValue("author"))
                        publishedAt := strings.TrimSpace(r.FormValue("published_at"))

                        var pubDate sql.NullTime
                        if publishedAt != "" {
                                parsedDate, err := time.Parse("2006-01-02", publishedAt)
                                if err == nil {
                                        pubDate = sql.NullTime{Time: parsedDate, Valid: true}
                                }
                        }

                        _, err := h.db.Exec("INSERT INTO books (id, title, author, published_at, created_at) VALUES ($1, $2, $3, $4, NOW())",
                                uuid.New(), title, author, pubDate)
                        if err != nil {
                                http.Error(w, "Error adding book", http.StatusInternalServerError)
                                return
                        }

                        http.Redirect(w, r, "/books", http.StatusSeeOther)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

The AddBookHandler does the following:

  • Checks user permissions for book creation.
  • Handles both GET (show form) and POST (add book).
  • Validates input data.
  • Generates UUID for new books.
  • Handles date parsing for publication dates.
  • Redirects to books list after successful addition.

You need two more handlers, one for deleting and the other for updating. Add this code right after the AddBookHandler function:


func (h *Handlers) DeleteBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "delete", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        http.Error(w, "Access denied", http.StatusForbidden)
                        return
                }

                bookIDStr := r.FormValue("id")
                bookID, err := uuid.Parse(bookIDStr)
                if err != nil {
                        http.Error(w, "Invalid book ID", http.StatusBadRequest)
                        return
                }

                _, err = h.db.Exec("DELETE FROM books WHERE id = $1", bookID)
                if err != nil {
                        http.Error(w, "Error deleting book", http.StatusInternalServerError)
                        return
                }

                http.Redirect(w, r, "/books", http.StatusSeeOther)
        }
}
Enter fullscreen mode Exit fullscreen mode

The DeleteBookHandler does the following:

  • Verifies user permissions for deletion.
  • Validates book ID.
  • Performs database deletion.
  • Handles errors and redirects appropriately.

Right after the DeleteBookHandler function, add the following:


func (h *Handlers) UpdateBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "update", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        w.Header().Set("Content-Type", "text/html")
                        fmt.Fprint(w, `
                                <!DOCTYPE html>
                                <html lang="en">
                                <head>
                                        <meta charset="UTF-8">
                                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                                        <title>Access Denied</title>
                                        <script>alert('You are not authorized to update books.')</script>
                                </head>
                                <body>
                                        <p>Access Denied. You do not have permission to update books.</p>
                                        <a href="/books">Back to Books</a>
                                </body>
                                </html>
                        `)
                        return
                }

                if r.Method == http.MethodGet {
                        bookID := r.FormValue("id")
                        var book models.Book
                        err := h.db.QueryRow("SELECT id, title, author, published_at FROM books WHERE id = $1", bookID).
                                Scan(&book.ID, &book.Title, &book.Author, &book.PublishedAt)
                        if err != nil {
                                http.Error(w, "Error fetching book", http.StatusInternalServerError)
                                return
                        }

                        if err := tmpl.ExecuteTemplate(w, "update.html", book); err != nil {
                                http.Error(w, "Error displaying update page", http.StatusInternalServerError)
                        }
                        return
                }

                if r.Method == http.MethodPost {
                        bookID := r.FormValue("id")
                        title := r.FormValue("title")
                        author := r.FormValue("author")
                        publishedAt := r.FormValue("published_at")

                        var pubDate sql.NullTime
                        if publishedAt != "" {
                                parsedDate, err := time.Parse("2006-01-02", publishedAt)
                                if err == nil {
                                        pubDate = sql.NullTime{Time: parsedDate, Valid: true}
                                }
                        }

                        _, err := h.db.Exec("UPDATE books SET title = $1, author = $2, published_at = $3 WHERE id = $4",
                                title, author, pubDate, bookID)
                        if err != nil {
                                http.Error(w, "Error updating book", http.StatusInternalServerError)
                                return
                        }

                        http.Redirect(w, r, "/books", http.StatusSeeOther)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

The UpdateHandler does the following:

  • Checks update permissions.
  • Handles both GET (show edit form) and POST (update book).
  • Fetches existing book data for editing.
  • Validates and processes updates.
  • Handles date formatting and database updates.

Throughout the code, you’ll notice that the authorization system is built around Permit.io's role-based access control framework, which provides sophisticated permission management.

This system also enables fine-grained control over user actions and allows different levels of access for viewing, creating, updating, and deleting resources. Each operation in the application undergoes detailed permission checking and ensures that users can only perform actions for which they're authorized.

Create Authorization Middleware

Now we’re done with the handlers. Create a folder called middleware, and inside it, create a file called middleware.go. Add the following code:


package middleware

import (
        "bookstore/models"
        "database/sql"
        "fmt"
        "github.com/google/uuid"
        "golang.org/x/crypto/bcrypt"
        "time"
)

func HashPassword(password string) (string, error) {
        bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
        return string(bytes), err
}

func CheckPasswordHash(password, hash string) bool {
        err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
        return err == nil
}

func LoginUser(db *sql.DB, username, password string) (*models.User, error) {
        fmt.Printf("Attempting login for username: %s\n", username)

        var user models.User
        var passwordHash string

        err := db.QueryRow(`
       SELECT id, username, password_hash, role, email, first_name, last_name, created_at
       FROM users
       WHERE username = $1
   `, username).Scan(
                &user.ID,
                &user.Username,
                &passwordHash,
                &user.Role,
                &user.Email,
                &user.FirstName,
                &user.LastName,
                &user.CreatedAt,
        )

        if err != nil {
                if err == sql.ErrNoRows {
                        fmt.Printf("No user found with username: %s\n", username)
                        return nil, fmt.Errorf("invalid credentials")
                }
                fmt.Printf("Database error: %v\n", err)
                return nil, err
        }

        if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
                fmt.Printf("Password comparison failed: %v\n", err)
                return nil, fmt.Errorf("invalid credentials")
        }

        user.PasswordHash = ""
        return &user, nil
}

func GetBooks(db *sql.DB) ([]models.Book, error) {
        rows, err := db.Query(`
       SELECT id, title, author, published_at, created_by, created_at
       FROM books
       ORDER BY created_at DESC
   `)
        if err != nil {
                return nil, err
        }
        defer rows.Close()

        var books []models.Book
        for rows.Next() {
                var book models.Book
                err := rows.Scan(
                        &book.ID,
                        &book.Title,
                        &book.Author,
                        &book.PublishedAt,
                        &book.CreatedAt,
                )
                if err != nil {
                        return nil, err
                }
                books = append(books, book)
        }

        return books, nil
}

func CreateBook(db *sql.DB, book *models.Book) error {
        book.ID = uuid.New()
        book.CreatedAt = time.Now()

        _, err := db.Exec(`
       INSERT INTO books (id, title, author, published_at, created_by, created_at)
       VALUES ($1, $2, $3, $4, $5, $6)
   `,
                book.ID,
                book.Title,
                book.Author,
                book.PublishedAt,
                book.CreatedAt,
        )

        return err
}

func UpdateBook(db *sql.DB, book *models.Book) error {
        result, err := db.Exec(`
       UPDATE books
       SET title = $1, author = $2, published_at = $3
       WHERE id = $4 AND created_by = $5
   `,
                book.Title,
                book.Author,
                book.PublishedAt,
                book.ID,
        )
        if err != nil {
                return err
        }

        rowsAffected, err := result.RowsAffected()
        if err != nil {
                return err
        }

        if rowsAffected == 0 {
                return fmt.Errorf("book not found or user not authorized")
        }

        return nil
}

func DeleteBook(db *sql.DB, bookID uuid.UUID, userID uuid.UUID) error {
        result, err := db.Exec(`
       DELETE FROM books
       WHERE id = $1 AND created_by = $2
   `, bookID, userID)
        if err != nil {
                return err
        }

        rowsAffected, err := result.RowsAffected()
        if err != nil {
                return err
        }

        if rowsAffected == 0 {
                return fmt.Errorf("book not found or user not authorized")
        }

        return nil
}

func GetUserRole(db *sql.DB, username string) (string, error) {
        var role string
        err := db.QueryRow("SELECT role FROM users WHERE username = $1", username).Scan(&role)
        if err != nil {
                if err == sql.ErrNoRows {
                        return "", fmt.Errorf("no user found with username %s", username)
                }
                return "", err
        }
        return role, nil
}
Enter fullscreen mode Exit fullscreen mode

This middleware package helps provide secure password hashing and authentication, along with CRUD operations for managing books in a bookstore application. It uses bcrypt to hash passwords for secure storage and verifies password hashes during login. It also prevents the exposure of sensitive data.

The LoginUser function authenticates users by comparing their input with stored password hashes and retrieves the full user profile on successful login, excluding the password hash for added security.

Also, CRUD operations allow you to create, update, retrieve, and delete book records in the database, with access control to ensure only authorized users can modify or delete entries they created. The package also includes a GetUserRole function to retrieve user roles, facilitating role-based access control.

Create the Models

Create another folder called models, and inside it, create a file called models.go. And add the following:


package models

import (
        "time"

        "database/sql/driver"

        "github.com/google/uuid"
)

type NullUUID struct {
        UUID  uuid.UUID
        Valid bool
}

func (n *NullUUID) Scan(value interface{}) error {
        if value == nil {
                n.UUID, n.Valid = uuid.UUID{}, false
                return nil
        }
        n.Valid = true
        return n.UUID.Scan(value)
}

func (n NullUUID) Value() (driver.Value, error) {
        if !n.Valid {
                return nil, nil
        }
        return n.UUID[:], nil
}

type User struct {
        ID           uuid.UUID `json:"id"`
        Username     string    `json:"username"`
        PasswordHash string    `json:"-"`
        Role         string    `json:"role"`
        Email        string    `json:"email"`
        FirstName    string    `json:"first_name"`
        LastName     string    `json:"last_name"`
        CreatedAt    time.Time `json:"created_at"`
}

type Book struct {
        ID          uuid.UUID  `json:"id"`
        Title       string     `json:"title"`
        Author      string     `json:"author"`
        PublishedAt *time.Time `json:"published_at,omitempty"`

        CreatedAt time.Time `json:"created_at"`
}

type LoginRequest struct {
        Username string `json:"username"`
        Password string `json:"password"`
}
Enter fullscreen mode Exit fullscreen mode

This package defines several data models for a bookstore application, including User, Book, and LoginRequest structures, along with a custom NullUUID type for handling nullable UUIDs in the database.

Almost done. The next thing you need to do is create the templates for your project. You’ll need to create templates for login and index, to add books, view books, delete books, and update books.

Create the HTML Templates

Create a folder called templates. This is where your html templates will be.
For login, create a file called login.html, and inside it, paste this:


    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Login</title>
      </head>
      <body>
        <h2>Login</h2>
        <form method="POST" action="/login">
          <label for="username">Username:</label>
          <input type="text" id="username" name="username" required /><br />
          <label for="password">Password:</label>
          <input type="password" id="password" name="password" required /><br />
          <button type="submit">Login</button>
        </form>
      </body>
    </html>

For index, create a file called `index.html`. Add this:


    <!doctype html>
    <html>
      <head>
        <title>Welcome</title>
      </head>
      <body>
        <h1>Welcome {{.Username}}!</h1>
        <p>You are logged in successfully.</p>
        <p>Your role is: {{.Role}}</p>
        <a href="/books">Go to Books</a>
        <br />
        <a href="/add">Add Book</a>
      </body>
    </html>
Enter fullscreen mode Exit fullscreen mode

For viewing books, create a file called books.html. Add this:


<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Books</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
    />
  </head>
  <body class="bg-gray-100">
    <div class="container mx-auto px-4">
      <h1 class="text-3xl font-bold text-center my-8">Books</h1>
      <div class="text-center mb-4">
        <a
          href="/add"
          class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
          >Add Book</a
        >
      </div>

      {{if eq (len .) 0}}
      <p class="text-center text-gray-600">No books to fetch</p>
      {{else}}
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {{range .}}
        <div class="bg-white shadow-md rounded-lg p-6 relative">
          <h2 class="text-xl font-bold mb-2">{{.Title}}</h2>
          <p><strong>Author:</strong> {{.Author}}</p>
          {{if .PublishedAt}}
          <p>
            <strong>Published Date:</strong> {{.PublishedAt.Format
            "2006-01-02"}}
          </p>
          {{else}}
          <p><strong>Published Date:</strong> Unknown</p>
          {{end}}
          <p><strong>Created At:</strong> {{.CreatedAt.Format "2006-01-02"}}</p>

          <!-- Update and Delete Buttons -->
          <div class="mt-4 flex space-x-2">
            <form action="/update" method="GET">
              <input type="hidden" name="id" value="{{.ID}}" />
              <button
                type="submit"
                class="bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600 focus:outline-none"
              >
                Update
              </button>
            </form>
            <form action="/delete" method="POST">
              <input type="hidden" name="id" value="{{.ID}}" />
              <button
                type="submit"
                class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 focus:outline-none"
              >
                Delete
              </button>
            </form>
          </div>
        </div>
        {{end}}
      </div>
      {{end}}
    </div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

To add books, create a file called add.html. Add this:


<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Add a New Book</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
    />
  </head>
  <body class="bg-gray-100">
    <div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6 mt-10">
      <h2 class="text-2xl font-bold mb-6">Add a New Book</h2>

      <!-- Form to add a new book -->
      <form action="/add" method="POST">
        <div class="mb-4">
          <label class="block text-gray-700 text-sm font-bold mb-2" for="title"
            >Title</label
          >
          <input
            type="text"
            id="title"
            name="title"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            required
          />
        </div>

        <div class="mb-4">
          <label class="block text-gray-700 text-sm font-bold mb-2" for="author"
            >Author</label
          >
          <input
            type="text"
            id="author"
            name="author"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            required
          />
        </div>

        <div class="mb-6">
          <label
            class="block text-gray-700 text-sm font-bold mb-2"
            for="published_at"
            >Published Date</label
          >
          <input
            type="date"
            id="published_at"
            name="published_at"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
          />
        </div>

        <div class="flex items-center justify-between">
          <button
            type="submit"
            class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:shadow-outline"
          >
            Add Book
          </button>
        </div>
      </form>

      <!-- Link back to books page -->
      <div class="mt-4">
        <a href="/books" class="text-indigo-600 hover:underline"
          >Back to Books</a
        >
      </div>
    </div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Lastly, you need to create the main file, which is the main.go. This will be in the root folder. Create it and add the following code:


package main

import (
        "bookstore/handlers"
        "database/sql"
        "fmt"
        "log"
        "net/http"
        "os"

        "github.com/gorilla/mux"
        "github.com/joho/godotenv"
        _ "github.com/lib/pq"
)

func connectDB() *sql.DB {

        connStr := "user=bookstore_user password=1111 dbname=bookstore_db sslmode=disable"
        db, err := sql.Open("postgres", connStr)
        if err != nil {
                log.Fatal("Error connecting to the database:", err)
        }
        if err := db.Ping(); err != nil {
                log.Fatal("Cannot ping the database:", err)
        }
        fmt.Println("Successfully connected to the database!")
        return db
}

func main() {

        err := godotenv.Load()
        if err != nil {
                log.Print("Error loading .env file")
        }

        permitApiKey := os.Getenv("PERMIT_API_KEY")
        if permitApiKey == "" {
                log.Fatal("PERMIT_API_KEY environment variable is not set")
        }

        db := connectDB()
        defer db.Close()

        r := mux.NewRouter()

        h := handlers.NewHandlers(db, permitApiKey)

        r.HandleFunc("/login", h.LoginHandler()).Methods("GET", "POST")
        r.HandleFunc("/books", h.BooksHandler()).Methods("GET")
        r.HandleFunc("/add", h.AddBookHandler()).Methods("GET", "POST")
        r.HandleFunc("/delete", h.DeleteBookHandler()).Methods("POST")
        r.HandleFunc("/update", h.UpdateBookHandler()).Methods("GET", "POST")

        fmt.Println("Server starting on :8080")
        if err := http.ListenAndServe(":8080", r); err != nil {
                log. Fatal(err)
        }
}
Enter fullscreen mode Exit fullscreen mode

This main package serves as the entry point for a bookstore application. It sets up database connectivity, environment configuration, and HTTP routes for handling user login and book management.

In the main function, routes are registered using the Gorilla Mux router. The handlers.NewHandlers function initializes handlers with the database and Permit.io API key. It enables functionality such as user authentication (/login) and book management (/books, /add, /delete, /update). Each route is mapped to specific HTTP methods, organizing the endpoints for different actions.

Finally, the server starts on port 8080, listening for incoming requests and logging any errors that occur. This setup ensures a structured API endpoint configuration and secure handling of environment variables.

Test the Application

Now that's about everything! Let's start up our app to see. the result. To start the server, run this command:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:8080/login in your browser.

Let's start by testing just the permissions of the standard_user:

testing standard_user's permissions

You'll see that the standard_user is restricted to viewing books only and cannot add, delete, or update a book.

Let's now log in using the admin_user to see what happens:

admin_user

You'll see that the admin has permission to do just about anything! That's how solid and easy to use Permit is!

You can check out these resources to learn more about Permit’s authorization:

Conclusion

In this tutorial, we built a simple bookstore management app to implement role-based access control using Go, HTMX, and Permit.io. Authorization is fundamental aspect of application security, as it ensures that users can only access what they’re allowed to.

Implementing an effective access control model like RBAC or ABAC into your application would not only secure your application but also enhance its scalability and compliance.

Top comments (0)