Build Permission-Aware SMS Notifications with Go, Twilio, and Permit.io

December 09, 2025
Written by
Temitope Taiwo Oyedele
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build Permission-Aware SMS Notifications with Go, Twilio, and Permit.io

In most systems, sending notifications is easy. But sending them to the right people, based on permissions and roles, is where things get real. With Twilio providing the messaging functionality and Permit.io handling the authorization logic, you can build a robust notification system in Go.

In this tutorial, you'll build a backend service in Go that only sends SMS notifications via Twilio to users authorized to receive them, using Permit.io for fine-grained access control.

What you'll build

You’ll create a lightweight service that:

  • Stores users with roles (like manager, chief_programmer, etc.)
  • Let users submit requests, such as maintenance issues, equipment needs, or system access
  • Allows approved users to send SMS notifications
  • Uses Permit.io to control who can notify whom
  • Sends SMS through Twilio

Prerequisites

To follow along, you’ll need:

  • Go 1.20 or above
  • A Twilio account, free or paid. Create an account if you don't have one already
  • A Twilio phone number
  • A Permit.io account
  • Basic experience with Go
  • Basic knowledge of SQLite
  • Docker Desktop
  • A mobile/cell phone that can receive SMS

Set up Permit.io

Before integrating Permit.io into the application, you need to configure the roles and permissions in your Permit.io dashboard. To do this, go to your dashboard workspace and start a new project.

Once created, you will be given access to two environments: Development and Production.

Dashboard showing Production and Development environments with user and resource statistics.

For this tutorial, we’ll use the "Development" environment. So you now proceed to create your resources. Start by clicking " Open dashboard" in the Development environment on the Policy option. Then, select the Resources tab.

Illustration of a file with an icon and text Create your first resource and a button Create a Resource.

To create the notification resource, click Create a Resource in the Resources tab and fill in the necessary details in the New Resource form:

  • Name: notifications
  • Key:notifications(this will be generated automatically)
  • Actions: sendtoall, sendtoprogrammers, sendtotechnicians
Interface for creating a new resource named notifications with options to add actions like sendtoall and sendtoprogrammers.

Next, you need to define roles in your Permit.io project. To do this, switch to the Roles tab, and click Add Role to create a specific role. The following roles needs to be created:

  • chief_programmer: This role will be able to approve or reject a request of only those with the role of programmer, and also send notifications to them
  • chief_technician: This role will be able to approve or reject a request of only those with the role of technicians, and also send notifications to them
  • manager: This role will be able to approve or reject a request, send notifications to all roles
  • programmer: A regular user who can submit requests
  • Technician: The same as programmer, but specific to technical tasks
Interface showing roles, keys, resource types, and derivations with options to add or edit roles.

Next, click on the Policy Editor tab to view your resources. There:

  • Give the manager role unrestricted access by ticking all actions for the notifications resource (sendtoall, sendtoprogrammers, and sendtotechnicians) as in the screenshot above
  • The chief_programmer role only requires permission to sendtoprogrammers for the notifications resource
  • The chief_technician should be given the permission of sendtotechnicians in the notifications resource

To save the changes, click Save Changes.

Set up your PDP (Policy Decision Point) container

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

docker pull permitio/pdp-v2:latest

The PDP container serves as a local policy decision point, receiving access requests from your app and verifying them against the policies you've defined in Permit.io.

After that, you’ll need your Permit.io API key to start the Policy Decision Point (PDP) container.

To get the key: Click Projects in the left-hand side navigation menu, and in your Development environment, click the three dots (•••), then select Copy API Key.

Dashboard showing project environments with options to copy, rotate API key, or delete the development environment.

Now, in the command below, replace <YOUR_API_KEY> with your Permit.io API key, then run the command to start the container:

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

Now, let’s start building.

What are we building?

Before proceeding to start building, it's important to understand what we are building. The project will consist of two kinds of workers:

  • technicians
  • programmers

Both workers can table a request for the manager or their head of department to see and approve. They would also be receiving messages designated to them by their respective heads, i.e., chief programmer, chief technician, or even the manager.

Because of this, there would be three super-admins, namely:

  • manager
  • chief_programmer
  • chief_technician

The manager would be able to approve any request from any of the workers, send notifications to all the workers, or choose who to send notifications to. The chief_programmers and chief_technician can only send notifications to workers having the same role or profession.

Create a new Go project

The first thing to do is to create a new directory for your project and initialize a new Go module. Do that by running these commands:

mkdir permit_twilio_demo
cd permit_twilio_demo
go mod init permit_twilio_demo

Add the required environment variables

Next, you'll need your Permit.io API key, your Twilio credentials, your Twilio phone number, and the phone number to which you want the message to be sent.

In a new terminal tab or session, create a file named .env in your project's top-level folder, then copy the configuration below into the file.

TWILIO_ACCOUNT_SID=<your_account_sid>
TWILIO_AUTH_TOKEN=<your_auth_token>
TWILIO_PHONE_NUMBER=<your_twilio_phone_number>
PERMIT_API_KEY=<your_permit_api_key>
SESSION_SECRET=supersecret
DATABASE_PATH=./data.db
The image shows available phone numbers for purchase on the dashboard.
The image shows available phone numbers for purchase on the dashboard.

First, log in to the Twilio Console. Then, from the Account Info panel of the main dashboard, copy your Account SID, Auth Token, and phone number and set them in .env as the values for TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER, respectively.

Install the required dependencies

Next, you need to install the dependencies needed for this project, namely:

  • github.com/gorilla/sessions : It provides a secure cookie-based session store, which allows you to manage user sessions, such as keeping users logged in across pages and tracking flash messages
  • github.com/joho/godotenv : It reads key-value pairs from a .env file and loads them into your Go app’s environment. This simplifies setting and retrieving configuration data without hardcoding it
  • github.com/mattn/go-sqlite3 : It registers the SQLite driver, which allows you to utilize SQLite as the database backend to store user accounts, roles, and submitted request data in a lightweight and portable format
  • github.com/permitio/permit-golang : This is the official Permit.io SDK for Go, which lets your app interact with Permit.io’s policy engine. It’s used to sync users, assign roles, and check if a user has permission to perform a certain action before sending notifications
  • github.com/twilio/twilio-go: This is the official Twilio Helper Library for Go, which simplifies interacting with Twilio services like SMS, voice, and other communication tools. For this project, it’s used to send SMS notifications to authorized users when a new request is submitted or approved.

To install the dependencies, run the following command:

go get github.com/gorilla/sessions github.com/joho/godotenv github.com/mattn/go-sqlite3 github.com/permitio/permit-golang github.com/twilio/twilio-go golang.org/x/crypto go.uber.org/goleak

Build the database

You next need to set up the SQLite database, creating tables for users and requests, and pre-populating the database with three super admin accounts ( manager, chief programmer, chief technician) to make testing easier.

Create a folder called db. Inside it, create a file called db.go . Then, add the following code to db/db.go:

package db
import (
   "database/sql"
   "fmt"
   "log"
   "os"
   _ "github.com/mattn/go-sqlite3"
   "golang.org/x/crypto/bcrypt"
)
var DB *sql.DB
type AppUser struct {
   ID    int
   Name  string
   Email string
   Phone string
   Role  string
}
func Init() {
   dbPath := os.Getenv("DATABASE_PATH")
   if dbPath == "" {
       log.Fatal("DATABASE_PATH not set in .env")
   }
   var err error
   DB, err = sql.Open("sqlite3", dbPath)
   if err != nil {
       log.Fatalf("Failed to open SQLite DB: %v", err)
   }
   err = DB.Ping()
   if err != nil {
       log.Fatalf("Failed to ping DB: %v", err)
   }
   fmt.Println("Connected to SQLite DB")
   createTables()
   createManagementUsers()
}
func createTables() {
   _, err := DB.Exec(`
   CREATE TABLE IF NOT EXISTS users (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       name TEXT NOT NULL,
       email TEXT NOT NULL UNIQUE,
       phone TEXT,
       role TEXT NOT NULL,
       password TEXT NOT NULL
   );
   CREATE TABLE IF NOT EXISTS requests (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       requester_id INTEGER,
       details TEXT,
       status TEXT DEFAULT 'pending',
       priority TEXT DEFAULT 'medium',
       created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
       approved_by INTEGER
   );
   `)
   if err != nil {
       log.Fatalf("Failed to create base tables: %v", err)
   }
   var hasPriorityColumn bool
   err = DB.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('requests') WHERE name = 'priority'`).Scan(&hasPriorityColumn)
   if err != nil {
       log.Fatalf("Failed to check for priority column: %v", err)
   }
   if !hasPriorityColumn {
       _, err := DB.Exec(`ALTER TABLE requests ADD COLUMN priority TEXT DEFAULT 'medium'`)
       if err != nil {
           log.Fatalf("Failed to add priority column: %v", err)
       }
       log.Println("Added 'priority' column to requests table")
   }
   var hasApprovedByColumn bool
   err = DB.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('requests') WHERE name = 'approved_by'`).Scan(&hasApprovedByColumn)
   if err != nil {
       log.Fatalf("Failed to check for approved_by column: %v", err)
   }
   if !hasApprovedByColumn {
       _, err := DB.Exec(`ALTER TABLE requests ADD COLUMN approved_by INTEGER`)
       if err != nil {
           log.Fatalf("Failed to add approved_by column: %v", err)
       }
       log.Println("Added 'approved_by' column to requests table")
   }
}
func createManagementUsers() {
   managementUsers := []struct {
       ID       int
       Name     string
       Email    string
       Phone    string
       Role     string
       Password string
   }{
       {
           ID:       1,
           Name:     "John Manager",
           Email:    "manager@example.com",
           Phone:    "+2348028191735",
           Role:     "manager",
           Password: "manager123",
       },
       {
           ID:       2,
           Name:     "Alice Chief Programmer",
           Email:    "chief_programmer@example.com",
           Phone:    "+2348028191735",
           Role:     "chief_programmer",
           Password: "chief123",
       },
       {
           ID:       3,
           Name:     "Bob Chief Technician",
           Email:    "chief_technician@example.com",
           Phone:    "+2348028191735",
           Role:     "chief_technician",
           Password: "tech123",
       },
   }
   for _, user := range managementUsers {
       var existingID int
       err := DB.QueryRow("SELECT id FROM users WHERE email = ?", user.Email).Scan(&existingID)
       if err == sql.ErrNoRows {
           hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
           if err != nil {
               log.Printf("Failed to hash password for %s: %v", user.Name, err)
               continue
           }
           _, err = DB.Exec(`
               INSERT INTO users (id, name, email, phone, role, password)
               VALUES (?, ?, ?, ?, ?, ?)`,
               user.ID, user.Name, user.Email, user.Phone, user.Role, string(hashedPassword))
           if err != nil {
               log.Printf("Failed to create management user %s: %v", user.Name, err)
           } else {
               log.Printf("Created management user: %s (%s)", user.Name, user.Role)
               fmt.Printf("Login credentials - Email: %s, Password: %s\n", user.Email, user.Password)
           }
       } else if err != nil {
           log.Printf("Error checking for existing user %s: %v", user.Email, err)
       } else {
           log.Printf("Management user already exists: %s", user.Email)
       }
   }
}
func GetAllUsers() ([]AppUser, error) {
   rows, err := DB.Query("SELECT id, name, email, phone, role FROM users")
   if err != nil {
       return nil, fmt.Errorf("querying users: %w", err)
   }
   defer rows.Close()
   var users []AppUser
   for rows.Next() {
       var u AppUser
       if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Phone, &u.Role); err != nil {
           return nil, fmt.Errorf("scanning user row: %w", err)
       }
       users = append(users, u)
   }
   return users, nil
}
func GetUserByID(id int) (AppUser, error) {
   var u AppUser
   err := DB.QueryRow("SELECT id, name, email, phone, role FROM users WHERE id = ?", id).
       Scan(&u.ID, &u.Name, &u.Email, &u.Phone, &u.Role)
   return u, err
}
func GetUsersByRole(role string) ([]AppUser, error) {
   rows, err := DB.Query("SELECT id, name, email, phone, role FROM users WHERE role = ?", role)
   if err != nil {
       return nil, fmt.Errorf("querying users by role: %w", err)
   }
   defer rows.Close()
   var users []AppUser
   for rows.Next() {
       var u AppUser
       if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Phone, &u.Role); err != nil {
           return nil, fmt.Errorf("scanning user row: %w", err)
       }
       users = append(users, u)
   }
   return users, nil
}

The code above initializes the SQLite database, defines the schema, seeds initial admin users, and exposes functions to fetch users from the DB.

Set up session management

To keep users logged in to know who’s making requests, you’ll need some session middleware. This is also where you’ll set up a secure cookie-based session store, and define helpers to get the currently logged-in user.

Create a subdirectory called middleware inside the root directory. Inside it, create a file called session.go. Then, add the following code to the new file:

package middleware
import (
	"context"
	"log"
	"net/http"
	"permit_twilio_demo/db"
	"github.com/gorilla/sessions"
)
var Store *sessions.CookieStore
type contextKey string
const userKey contextKey = "current_user"
func WithCurrentUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Printf("WithCurrentUser middleware processing: %s %s", r.Method, r.URL.Path)
		if Store == nil {
			log.Printf(" Session store not initialized in middleware")
			http.Error(w, "Server configuration error", http.StatusInternalServerError)
			return
		}
		session, err := Store.Get(r, "session")
		if err != nil {
			log.Printf("Session error: %v - creating new session", err)
			session, _ = Store.New(r, "session")
		}
		log.Printf("Session ID: %s, Values: %v", session.ID, session.Values)
		userID, ok := session.Values["user_id"]
		if !ok || userID == nil {
			log.Printf("No user_id in session, redirecting to login")
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		id, ok := userID.(int)
		if !ok {
			log.Printf(" user_id is not an int: %v (type: %T)", userID, userID)
			session.Values = map[interface{}]interface{}{}
			session.Save(r, w)
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		user, err := db.GetUserByID(id)
		if err != nil {
			log.Printf("Failed to get user with ID %d: %v", id, err)
			session.Values = map[interface{}]interface{}{}
			session.Save(r, w)
			http.Redirect(w, r, "/login", http.StatusSeeOther)
			return
		}
		ctx := context.WithValue(r.Context(), userKey, user)
		r = r.WithContext(ctx)
		log.Printf("User authenticated: %s (ID: %d, Role: %s)", user.Name, user.ID, user.Role)
		next.ServeHTTP(w, r)
	})
}
func GetCurrentUser(r *http.Request) (db.AppUser, bool) {
	u, ok := r.Context().Value(userKey).(db.AppUser)
	return u, ok
}
func LoadSessionUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		session, err := Store.Get(r, "session")
		if err != nil {
			next.ServeHTTP(w, r)
			return
		}
		userID, ok := session.Values["user_id"]
		if ok {
			if id, ok := userID.(int); ok {
				user, err := db.GetUserByID(id)
				if err == nil {
					ctx := context.WithValue(r.Context(), userKey, user)
					r = r.WithContext(ctx)
				}
			}
		}
		next.ServeHTTP(w, r)
	})
}

The code above:

  • Sets up middleware to manage user sessions and authentication
  • Reads the logged-in user from the session cookie
  • Fetches the full user data from the database and attaches it to the request context
  • If the user isn’t logged in or the session is broken, it redirects them to the login page
  • It also includes a helper to retrieve the current user from any handler

Add user management and permissioned notification requests

This is the part that handles user registration, login, and sending notifications. We’ll ensure that only users with the correct roles, such as "manager" or "chief programmer", can send messages to the intended recipients.

First, create a subdirectory called handlers inside the root directory. Inside it, create a file called user.go. Add the following code:

package handlers
import (
	"fmt"
	"html/template"
	"log"
	"net/http"
	"permit_twilio_demo/db"
	"permit_twilio_demo/middleware"
	"permit_twilio_demo/notify"
	"permit_twilio_demo/permit"
	"strings"
	"github.com/gorilla/sessions"
	"golang.org/x/crypto/bcrypt"
)
var Store *sessions.CookieStore
var tmpl *template.Template 
func SetTemplates(t *template.Template) {
	tmpl = t
}
type User struct {
	Name string `json:"name"`
	Email string `json:"email"`
	Phone string `json:"phone"`
	Role string `json:"role"`
	Password string `json:"password"`
}
func RegisterPage(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodGet {
		tmpl.ExecuteTemplate(w, "layout.html", map[string]any{
			"content": "register.html",
			"is_authenticated": false,
		})
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Invalid form", http.StatusBadRequest)
		return
	}
	role := r.FormValue("role")
	if role != "programmer" && role != "technician" {
		http.Error(w, "Registration is only allowed for programmer and technician roles", http.StatusForbidden)
		return
	}
	password := r.FormValue("password")
	if password == "" {
		http.Error(w, "Password is required", http.StatusBadRequest)
		return
	}
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		http.Error(w, "Failed to hash password", http.StatusInternalServerError)
		return
	}
	user := User{
		Name: r.FormValue("name"),
		Email: r.FormValue("email"),
		Phone: r.FormValue("phone"),
		Role: role,
		Password: string(hashedPassword),
	}
	var id int
	err = db.DB.QueryRow(
		`INSERT INTO users (name, email, phone, role, password)
		 VALUES (?, ?, ?, ?, ?) RETURNING id`,
		user.Name, user.Email, user.Phone, user.Role, user.Password,
	).Scan(&id)
	if err != nil {
		log.Printf("DB insert error: %v", err)
		http.Error(w, "Failed to register user", http.StatusInternalServerError)
		return
	}
	fmt.Printf("📋 Syncing to Permit.io: Name=%q, Email=%q, Role=%q\n", user.Name, user.Email, user.Role)
	permit.RegisterUserInPermit(id, user.Email, user.Name, user.Role)
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func NotifyPage(w http.ResponseWriter, r *http.Request) {
	user, ok := middleware.GetCurrentUser(r)
	if !ok {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}
	if r.Method == http.MethodGet {
		var allowedTargets []string
		var canNotifyAll bool
		switch user.Role {
		case "manager":
			canNotifyAll = true
			allowedTargets = []string{"programmer", "technician", "chief_programmer", "chief_technician", "manager", "employee", "hr", "finance", "security"}
		case "chief_programmer":
			canNotifyAll = false
			allowedTargets = []string{"programmer"}
		case "chief_technician":
			canNotifyAll = false
			allowedTargets = []string{"technician"}
		default:
			allowedTargets = []string{}
			canNotifyAll = false
		}
		tmpl.ExecuteTemplate(w, "layout.html", map[string]any{
			"content": "notify.html",
			"is_authenticated": true,
			"user_role": user.Role,
			"user_name": user.Name,
			"allowed_targets": allowedTargets,
			"can_notify_all": canNotifyAll,
		})
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Invalid form", http.StatusBadRequest)
		return
	}
	subject := r.FormValue("subject")
	message := r.FormValue("message")
	selectedRoles := r.Form["target_roles"]
	if subject == "" || message == "" {
		http.Error(w, "Subject and message are required", http.StatusBadRequest)
		return
	}
	var hasPermission bool
	var permissionError string
	var allowedRoles []string
	switch user.Role {
	case "manager":
		hasPermission = true
		allowedRoles = []string{"programmer", "technician", "chief_programmer", "chief_technician"}
	case "chief_programmer":
		allowedRoles = []string{"programmer"}
		hasPermission = true
	case "chief_technician":
		allowedRoles = []string{"technician"}
		hasPermission = true
	default:
		hasPermission = false
		permissionError = "Access denied: Insufficient privileges to send notifications"
	}
	if hasPermission && len(selectedRoles) > 0 {
		for _, selectedRole := range selectedRoles {
			roleAllowed := false
			for _, allowedRole := range allowedRoles {
				if selectedRole == allowedRole {
					roleAllowed = true
					break
				}
			}
			if !roleAllowed {
				hasPermission = false
				permissionError = fmt.Sprintf("You don't have permission to send notifications to role: %s", selectedRole)
				break
			}
		}
	}
	if !hasPermission {
		http.Error(w, permissionError, http.StatusForbidden)
		return
	}
	fullMessage := subject + ": " + message
	var userIDs []int
	var err error
	var recipientDescription string
	if len(selectedRoles) > 0 {
		userIDs, err = getUserIDsByMultipleRoles(selectedRoles)
		if len(selectedRoles) == 1 {
			recipientDescription = fmt.Sprintf("users with role '%s'", selectedRoles[0])
		} else {
			recipientDescription = fmt.Sprintf("users with roles: %s", strings.Join(selectedRoles, ", "))
		}
	} else {
		http.Error(w, "Invalid recipient selection - please select at least one role", http.StatusBadRequest)
		return
	}
	if err != nil {
		http.Error(w, "Failed to fetch recipients: "+err.Error(), http.StatusInternalServerError)
		return
	}
	if len(userIDs) == 0 {
		fmt.Fprintf(w, `<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded">
			<strong>Warning:</strong> No users found for the selected criteria.
		</div>`)
		return
	}
	successCount := 0
	failCount := 0
	for _, id := range userIDs {
		var phone string
		err := db.DB.QueryRow(`SELECT phone FROM users WHERE id = ?`, id).Scan(&phone)
		if err != nil {
			fmt.Printf("Could not find phone for user %d: %v\n", id, err)
			failCount++
			continue
		}
		if phone == "" {
			fmt.Printf("No phone number for user %d\n", id)
			failCount++
			continue
		}
		err = notify.SendSMS(phone, fullMessage)
		if err != nil {
			fmt.Printf("Failed to send SMS to user %d: %v\n", id, err)
			failCount++
		} else {
			successCount++
		}
	}
	resultHTML := fmt.Sprintf(`
		<div class="max-w-2xl mx-auto mt-8">
			<div class="bg-white rounded-lg shadow-md p-6">
				<h3 class="text-xl font-semibold text-gray-800 mb-4">📤 Notification Results</h3>
				<div class="space-y-3">
					<div class="flex items-center p-3 bg-green-50 border border-green-200 rounded-lg">
						<i class="fas fa-check-circle text-green-600 mr-3"></i>
						<span class="text-green-800">
							<strong>Successfully sent to %d users</strong> (%s)
						</span>
					</div>
					%s
					<div class="border-t pt-4 mt-4">
						<p class="text-gray-600"><strong>Subject:</strong> %s</p>
						<p class="text-gray-600"><strong>Message:</strong> %s</p>
					</div>
					<div class="flex justify-center mt-6">
						<a href="/notify" class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition">
							<i class="fas fa-arrow-left mr-2"></i>Send Another Notification
						</a>
					</div>
				</div>
			</div>
		</div>
	`, successCount, recipientDescription, getFailureHTML(failCount), subject, message)
	fmt.Fprint(w, resultHTML)
}
func getUserIDsByMultipleRoles(roles []string) ([]int, error) {
	if len(roles) == 0 {
		return []int{}, nil
	}
	placeholders := make([]string, len(roles))
	args := make([]interface{}, len(roles))
	for i, role := range roles {
		placeholders[i] = "?"
		args[i] = role
	}
	query := fmt.Sprintf(`SELECT id FROM users WHERE role IN (%s)`, strings.Join(placeholders, ","))
	rows, err := db.DB.Query(query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var userIDs []int
	for rows.Next() {
		var id int
		if err := rows.Scan(&id); err != nil {
			continue
		}
		userIDs = append(userIDs, id)
	}
	return userIDs, nil
}
func getAllUserIDs() ([]int, error) {
	rows, err := db.DB.Query(`SELECT id FROM users`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var userIDs []int
	for rows.Next() {
		var id int
		if err := rows.Scan(&id); err != nil {
			continue
		}
		userIDs = append(userIDs, id)
	}
	return userIDs, nil
}
func getUserIDsByRole(role string) ([]int, error) {
	rows, err := db.DB.Query(`SELECT id FROM users WHERE role = ?`, role)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var userIDs []int
	for rows.Next() {
		var id int
		if err := rows.Scan(&id); err != nil {
			continue
		}
		userIDs = append(userIDs, id)
	}
	return userIDs, nil
}
func getFailureHTML(failCount int) string {
	if failCount > 0 {
		return fmt.Sprintf(`
			<div class="flex items-center p-3 bg-red-50 border border-red-200 rounded-lg">
				<i class="fas fa-exclamation-triangle text-red-600 mr-3"></i>
				<span class="text-red-800">
					<strong>Failed to send to %d users</strong> (missing phone numbers or SMS errors)
				</span>
			</div>
		`, failCount)
	}
	return ""
}
func LoginPage(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodGet {
		tmpl.ExecuteTemplate(w, "layout.html", map[string]any{
			"content": "login.html",
			"is_authenticated": false,
		})
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Invalid form", 400)
		return
	}
	email := r.FormValue("email")
	password := r.FormValue("password")
	log.Printf("Login attempt for email: %s", email)
	var user db.AppUser
	var hashedPassword string
	err := db.DB.QueryRow(`
		SELECT id, name, email, phone, role, password
		FROM users
		WHERE email = ?`, email).
		Scan(&user.ID, &user.Name, &user.Email, &user.Phone, &user.Role, &hashedPassword)
	if err != nil {
		log.Printf(" Login failed - User not found: %s", email)
		http.Error(w, "User not found", 401)
		return
	}
	if bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) != nil {
		log.Printf("Login failed - Invalid password for: %s", email)
		http.Error(w, "Invalid password", 401)
		return
	}
	permit.RegisterUserInPermit(user.ID, user.Email, user.Name, user.Role)
	session, err := middleware.Store.Get(r, "session")
	if err != nil {
		log.Printf(" Failed to get session during login: %v - creating new session", err)
		session, _ = middleware.Store.New(r, "session")
	}
	session.Values["user_id"] = user.ID
	session.Values["user_role"] = user.Role
	session.Values["user_name"] = user.Name
	if err := session.Save(r, w); err != nil {
		log.Printf("Failed to save session: %v", err)
		http.Error(w, "Session error", http.StatusInternalServerError)
		return
	}
	log.Printf("Login successful: %s (ID: %d, Role: %s)", user.Name, user.ID, user.Role)
	log.Printf("Session after login - ID: %s, Values: %v", session.ID, session.Values)
	http.Redirect(w, r, "/", http.StatusSeeOther)
}
func HomePage(w http.ResponseWriter, r *http.Request) {
	log.Printf("HomePage handler called for path: %s", r.URL.Path)
	user, ok := middleware.GetCurrentUser(r)
	if !ok {
		log.Println("No user in context, redirecting to login")
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}
	data := map[string]any{
		"content": "home.html",
		"user_name": user.Name,
		"user_role": user.Role,
		"user_id": user.ID,
		"is_authenticated": true, // Flag to indicate user is logged in
	}
	log.Println("Rendering home template for user:", user.Name)
	err := tmpl.ExecuteTemplate(w, "layout.html", data)
	if err != nil {
		log.Printf(" Failed to render home page: %v", err)
		http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	log.Println("HomePage handler completed successfully")
}
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
	session, _ := Store.Get(r, "session")
	session.Values = map[interface{}]interface{}{}
	session.Options.MaxAge = -1 // This will delete the cookie
	session.Save(r, w)
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

The handlers package defines core HTTP handlers for user registration, login, home display, logout, and role-based SMS notifications in a web app integrated with Twilio and Permit.io. It includes form validation, session management using Gorilla sessions, secure password hashing with bcrypt, role-based access control for sending notifications, and dynamic HTML rendering using Go’s html/template.

The RegisterPage handles user sign-up and stores users in both the app database and Permit.io. LoginPage authenticates users and starts a session. NotifyPage allows authorized roles to send SMS messages to other roles using Twilio, while strictly enforcing permission checks.

HomePage renders a user-specific dashboard, and LogoutHandler clears the session. Overall, this code ties together user auth, role permissions, templating, and SMS functionality into a cohesive, permission-aware notification system.

Add the request submission and approval logic

Here, the users will be able to submit requests, and the managers and role-based heads will be able to approve or reject them. They also get notifications when their requests are approved or rejected.

Create a file called request.go inside the handlers subdirectory with the following code:

package handlers
import (
	"fmt"
	"net/http"
	"permit_twilio_demo/db"
	"permit_twilio_demo/middleware"
	"permit_twilio_demo/notify"
)
type Request struct {
	ID int
	RequesterID int
	Details string
	Status string
	Priority string
	ApprovedBy int
}
func SubmitRequestPage(w http.ResponseWriter, r *http.Request) {
	if Store == nil {
		http.Error(w, "Session store not initialized", http.StatusInternalServerError)
		return
	}
	session, err := Store.Get(r, "session")
	if err != nil {
		session, _ = Store.New(r, "session")
	}
	user, ok := middleware.GetCurrentUser(r)
	if !ok {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}
	if user.Role != "programmer" && user.Role != "technician" {
		http.Error(w, "Access denied: Only programmers and technicians can submit requests", http.StatusForbidden)
		return
	}
	if r.Method == http.MethodGet {
		var flashes []map[string]string
		if raw, ok := session.Values["flash"]; ok {
			if flashMap, ok := raw.(map[string]string); ok {
				flashes = append(flashes, flashMap)
			} else if flashMap, ok := raw.(map[interface{}]interface{}); ok {
				convertedFlash := make(map[string]string)
				for k, v := range flashMap {
					if ks, ok := k.(string); ok {
						if vs, ok := v.(string); ok {
							convertedFlash[ks] = vs
						}
					}
				}
				if len(convertedFlash) > 0 {
					flashes = append(flashes, convertedFlash)
				}
			}
			delete(session.Values, "flash")
			session.Save(r, w)
		}
		data := map[string]any{
			"content": "submit_request.html",
			"is_authenticated": true,
			"user_role": user.Role,
			"user_name": user.Name,
			"flash_messages": flashes,
		}
		err = tmpl.ExecuteTemplate(w, "layout.html", data)
		if err != nil {
			http.Error(w, "Template error", http.StatusInternalServerError)
		}
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Invalid form", http.StatusBadRequest)
		return
	}
	details := r.FormValue("details")
	priority := r.FormValue("priority")
	if details == "" {
		session.Values["flash"] = map[string]string{
			"Type": "error",
			"Message": "Request details are required",
		}
		session.Save(r, w)
		http.Redirect(w, r, "/submit-request", http.StatusSeeOther)
		return
	}
	_, err = db.DB.Exec(
		`INSERT INTO requests (requester_id, details, status, priority) VALUES (?, ?, 'pending', ?)`,
		user.ID, details, priority,
	)
	if err != nil {
		session.Values["flash"] = map[string]string{
			"Type": "error",
			"Message": "Failed to submit request. Please try again.",
		}
		session.Save(r, w)
		http.Redirect(w, r, "/submit-request", http.StatusSeeOther)
		return
	}
	session.Values["flash"] = map[string]string{
		"Type": "success",
		"Message": "Your request has been submitted successfully!",
	}
	session.Save(r, w)
	http.Redirect(w, r, "/submit-request", http.StatusSeeOther)
}
func RequestListPage(w http.ResponseWriter, r *http.Request) {
	user, ok := middleware.GetCurrentUser(r)
	if !ok {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}
	if user.Role != "manager" && user.Role != "chief_programmer" && user.Role != "chief_technician" {
		http.Error(w, "Access denied: Insufficient privileges to view requests", http.StatusForbidden)
		return
	}
	session, _ := Store.Get(r, "session")
	var flashes []map[string]string
	if raw, ok := session.Values["flash"]; ok {
		if flashMap, ok := raw.(map[string]string); ok {
			flashes = append(flashes, flashMap)
		} else if flashMap, ok := raw.(map[interface{}]interface{}); ok {
			convertedFlash := make(map[string]string)
			for k, v := range flashMap {
				if ks, ok := k.(string); ok {
					if vs, ok := v.(string); ok {
						convertedFlash[ks] = vs
					}
				}
			}
			if len(convertedFlash) > 0 {
				flashes = append(flashes, convertedFlash)
			}
		}
		delete(session.Values, "flash")
		session.Save(r, w)
	}
	var query string
	var args []interface{}
	switch user.Role {
	case "manager":
		query = `SELECT r.id, r.requester_id, r.details, r.status, r.priority,
		 u.name as requester_name, u.role as requester_role
		 FROM requests r
		 JOIN users u ON r.requester_id = u.id
		 ORDER BY r.id DESC`
	case "chief_programmer":
		query = `SELECT r.id, r.requester_id, r.details, r.status, r.priority,
		 u.name as requester_name, u.role as requester_role
		 FROM requests r
		 JOIN users u ON r.requester_id = u.id
		 WHERE u.role = 'programmer'
		 ORDER BY r.id DESC`
	case "chief_technician":
		query = `SELECT r.id, r.requester_id, r.details, r.status, r.priority,
		 u.name as requester_name, u.role as requester_role
		 FROM requests r
		 JOIN users u ON r.requester_id = u.id
		 WHERE u.role = 'technician'
		 ORDER BY r.id DESC`
	}
	rows, err := db.DB.Query(query, args...)
	if err != nil {
		http.Error(w, "Failed to load requests", http.StatusInternalServerError)
		return
	}
	defer rows.Close()
	var requests []map[string]any
	for rows.Next() {
		var id, requesterID int
		var details, status, priority, requesterName, requesterRole string
		err := rows.Scan(&id, &requesterID, &details, &status, &priority, &requesterName, &requesterRole)
		if err != nil {
			continue
		}
		requests = append(requests, map[string]any{
			"id": id,
			"requester_id": requesterID,
			"requester_name": requesterName,
			"requester_role": requesterRole,
			"details": details,
			"status": status,
			"priority": priority,
		})
	}
	var pageTitle string
	switch user.Role {
	case "manager":
		pageTitle = "All Requests"
	case "chief_programmer":
		pageTitle = "Programmer Requests"
	case "chief_technician":
		pageTitle = "Technician Requests"
	}
	tmpl.ExecuteTemplate(w, "layout.html", map[string]any{
		"content": "requests.html",
		"requests": requests,
		"page_title": pageTitle,
		"is_authenticated": true,
		"user_role": user.Role,
		"user_name": user.Name,
		"flash_messages": flashes,
	})
}
func ApproveRequest(w http.ResponseWriter, r *http.Request) {
	user, ok := middleware.GetCurrentUser(r)
	if !ok {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}
	if user.Role != "manager" && user.Role != "chief_programmer" && user.Role != "chief_technician" {
		http.Error(w, "Access denied: Insufficient privileges to approve requests", http.StatusForbidden)
		return
	}
	requestID := r.URL.Query().Get("id")
	action := r.URL.Query().Get("action")
	if requestID == "" || (action != "approve" && action != "reject") {
		http.Error(w, "Invalid request parameters", http.StatusBadRequest)
		return
	}
	var requestIDInt int
	fmt.Sscanf(requestID, "%d", &requestIDInt)
	var requesterRole string
	err := db.DB.QueryRow(`
		SELECT u.role
		FROM requests r
		JOIN users u ON r.requester_id = u.id
		WHERE r.id = ?`, requestIDInt).Scan(&requesterRole)
	if err != nil {
		session, _ := Store.Get(r, "session")
		session.Values["flash"] = map[string]string{
			"Type": "error",
			"Message": "Request not found or access denied.",
		}
		session.Save(r, w)
		http.Redirect(w, r, "/requests", http.StatusSeeOther)
		return
	}
	canApprove := false
	switch user.Role {
	case "manager":
		canApprove = true // Manager can approve all
	case "chief_programmer":
		canApprove = (requesterRole == "programmer")
	case "chief_technician":
		canApprove = (requesterRole == "technician")
	}
	if !canApprove {
		session, _ := Store.Get(r, "session")
		session.Values["flash"] = map[string]string{
			"Type": "error",
			"Message": fmt.Sprintf("Access denied: You can only manage requests from %s", getRoleManagementScope(user.Role)),
		}
		session.Save(r, w)
		http.Redirect(w, r, "/requests", http.StatusSeeOther)
		return
	}
	newStatus := "approved"
	if action == "reject" {
		newStatus = "rejected"
	}
	_, err = db.DB.Exec(`UPDATE requests SET status = ?, approved_by = ? WHERE id = ?`, newStatus, user.ID, requestIDInt)
	if err != nil {
		session, _ := Store.Get(r, "session")
		session.Values["flash"] = map[string]string{
			"Type": "error",
			"Message": "Failed to update request. Please try again.",
		}
		session.Save(r, w)
		http.Redirect(w, r, "/requests", http.StatusSeeOther)
		return
	}
	var requesterID int
	var details, priority string
	err = db.DB.QueryRow(`SELECT requester_id, details, COALESCE(priority, 'medium') FROM requests WHERE id = ?`, requestIDInt).
		Scan(&requesterID, &details, &priority)
	if err == nil {
		var phone, name string
		err = db.DB.QueryRow(`SELECT phone, name FROM users WHERE id = ?`, requesterID).Scan(&phone, &name)
		if err == nil && phone != "" {
			statusMessage := "Your request has been APPROVED."
			if newStatus == "rejected" {
				statusMessage = "Your request has been REJECTED."
			}
			if len(details) > 30 {
				details = details[:30] + "..."
			}
			message := fmt.Sprintf("Hello %s, %s\nRequest: '%s'\nPriority: %s\nDecision by: %s (%s)",
				name, statusMessage, details, priority, user.Name, user.Role)
			notify.SendSMS(phone, message)
		}
	}
	session, _ := Store.Get(r, "session")
	session.Values["flash"] = map[string]string{
		"Type": "success",
		"Message": fmt.Sprintf("Request has been %sd successfully. SMS notification sent to requester.", action),
	}
	session.Save(r, w)
	http.Redirect(w, r, "/requests", http.StatusSeeOther)
}
func getRoleManagementScope(role string) string {
	switch role {
	case "chief_programmer":
		return "programmers"
	case "chief_technician":
		return "technicians"
	case "manager":
		return "all employees"
	default:
		return "your assigned role"
	}
}
func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

In the code above, we handle all core user features, including registration, login, logout, dashboard access, and role-based SMS notifications. Only users with valid roles, such as "programmer" or "technician", can register, and only authorized roles, like "manager" or "chief programmer", can send messages to others.

The file also syncs users with Permit.io, manages sessions, renders templates, and enforces permission checks before sending notifications via Twilio — all in one place.

Add the Permit.io authorization logic

You need to integrate Permit.io to manage who’s allowed to do what. This includes syncing users, checking access before sending notifications, and filtering who receives messages based on roles and policies.

Create a subdirectory called permit in the root directory and inside it, create a file called permit.go. Add the following code:

package permit
import (
	"context"
	"fmt"
	"os"
	"permit_twilio_demo/db"
	"strconv"
	"github.com/permitio/permit-golang/pkg/config"
	"github.com/permitio/permit-golang/pkg/enforcement"
	"github.com/permitio/permit-golang/pkg/models"
	"github.com/permitio/permit-golang/pkg/permit"
)
var PermitClient *permit.Client
func ptr(s string) *string {
	return &s
}
func InitPermit() {
	cfg := config.NewConfigBuilder(os.Getenv("PERMIT_API_KEY")).Build()
	PermitClient = permit.New(cfg)
	fmt.Println("Permit.io client initialized")
}
func RegisterUserInPermit(id int, email, name, role string) {
	if PermitClient == nil {
		fmt.Println(" Permit.io client not initialized")
		return
	}
	userID := strconv.Itoa(id)
	// Delete existing user to clear stale data
	err := PermitClient.Api.Users.Delete(context.Background(), userID)
	if err != nil {
		fmt.Printf("Could not delete user (may not exist yet): %v\n", err)
	}
	fmt.Printf("📋 Syncing to Permit.io: Name=%q, Email=%q, Role=%q\n", name, email, role)
	user := models.NewUserCreate(userID)
	user.Email = ptr(email)
	user.FirstName = ptr(name)
	user.Attributes = map[string]interface{}{
		"role": role,
	}
	_, err = PermitClient.SyncUser(context.Background(), *user)
	if err != nil {
		fmt.Printf(" Error syncing user: %v\n", err)
		return
	}
	_, err = PermitClient.Api.Users.AssignRole(context.Background(), userID, role, "default")
	if err != nil {
		fmt.Printf("Error assigning role: %v\n", err)
		return
	}
	fmt.Printf("Synced and assigned role '%s' to user %s (%s)\n", role, name, email)
}
func CheckPermission(userID int, resourceID int, action string) bool {
	if PermitClient == nil {
		fmt.Println(" Permit.io client is nil")
		return false
	}
	user := enforcement.UserBuilder(strconv.Itoa(userID)).Build()
	resource := enforcement.ResourceBuilder(strconv.Itoa(resourceID)).Build()
	actionObj := enforcement.Action(action)
	allowed, err := PermitClient.Check(user, actionObj, resource)
	if err != nil {
		fmt.Printf(" Permit.io check error: %v\n", err)
		return false
	}
	return allowed
}
func GetAuthorizedUsers(action, resource string) ([]int, error) {
	res := enforcement.ResourceBuilder(resource).Build()
		users, err := db.GetAllUsers()
	if err != nil {
		return nil, err
	}
	var authorizedUserIDs []int
	for _, user := range users {
		usr := enforcement.UserBuilder(strconv.Itoa(user.ID)).Build()
		permitted, err := PermitClient.Check(usr, enforcement.Action(action), res)
		if err != nil {
			continue
		}
		if permitted {
			authorizedUserIDs = append(authorizedUserIDs, user.ID)
		}
	}
	return authorizedUserIDs, nil
}

In the code above, we connect the app to Permit.io, which handles fine-grained access control. It initializes the Permit.io client, synchronizes users (with assigned roles) during registration and login, and assigns those roles within the Permit.io system.

It also includes logic to check if a user has permission to perform a specific action on a resource, like sending a notification. The GetAuthorizedUsers() function helps determine which users are authorized to perform an action based on their roles and policies.

Next, create a file called notification.go inside the permit subdirectory. Then, add the following code to the file:

package permit
import (
	"fmt"
	"permit_twilio_demo/db"
	"permit_twilio_demo/notify"
	"strconv"
	"github.com/permitio/permit-golang/pkg/enforcement"
)
func NotifyAuthorizedUsers(action, resource, message string) {
	if PermitClient == nil {
		fmt.Println(" Permit.io client not initialized")
		return
	}
	res := enforcement.ResourceBuilder(resource).Build()
	users, err := db.GetAllUsers()
	if err != nil {
		fmt.Println("Failed to fetch users:", err)
		return
	}
	for _, user := range users {
		userRef := enforcement.UserBuilder(strconv.Itoa(user.ID)).Build()
		allowed, err := PermitClient.Check(userRef, enforcement.Action(action), res)
		if err != nil {
			fmt.Printf("Permit.io error for user %d: %v\n", user.ID, err)
			continue
		}
		if allowed {
			fmt.Printf("Notifying %s (%s)\n", user.Name, user.Phone)
			notify.SendSMS(user.Phone, message)
		}
	}
}

Integrate Twilio for SMS

You now need to add the Twilio logic in order to send SMS messages. Create a subdirectory called notify inside the project root directory. Inside it, create a file called twilio.go. Add the following code to the new file:

package notify
import (
	"fmt"
	"log"
	"os"
	"strings"
	twilio "github.com/twilio/twilio-go"
	openapi "github.com/twilio/twilio-go/rest/api/v2010"
)
func SendSMS(to string, message string) error {
	accountSid := os.Getenv("TWILIO_ACCOUNT_SID")
	authToken := os.Getenv("TWILIO_AUTH_TOKEN")
	fromNumber := os.Getenv("TWILIO_PHONE_NUMBER")
	if accountSid == "" || authToken == "" || fromNumber == "" {
		log.Println(" Error: Missing Twilio credentials in environment variables")
		return fmt.Errorf("missing Twilio credentials")
	}
	to = strings.TrimSpace(to)
	if to == "" {
		return fmt.Errorf("empty phone number provided")
	}
	if !strings.HasPrefix(to, "+") {
		// Default to US country code if none provided
		to = "+1" + strings.TrimPrefix(to, "1")
	}
	client := twilio.NewRestClientWithParams(twilio.ClientParams{
		Username: accountSid,
		Password: authToken,
	})
	params := &openapi.CreateMessageParams{}
	params.SetTo(to)
	params.SetFrom(fromNumber)
	params.SetBody(message)
	previewLength := 50
	if len(message) > previewLength {
		log.Printf("Sending SMS to %s: %s...", to, message[:previewLength])
	} else {
		log.Printf(" Sending SMS to %s: %s", to, message)
	}
	resp, err := client.Api.CreateMessage(params)
	if err != nil {
		log.Printf("Failed to send SMS: %v", err.Error())
		return err
	}
	sid := ""
	if resp.Sid != nil {
		sid = *resp.Sid
	}
	log.Printf("SMS sent successfully! SID: %s", sid)
	return nil
}

In the code above, the SendSMS() function is used to send text messages using Twilio's Go SDK. It loads the necessary credentials such as the Account SID, Auth Token, and Twilio phone number from environment variables. Once the message parameters are prepared, the function uses Twilio's REST client to send the SMS.

Create the view templates

These templates will render login, registration, request pages, and the dashboard. It’s not the main focus, but it helps us visualize and test the flow.

Create a subdirectory called templates in the root directory. Then, in the new directory, create the following files:

  • home.html
  • layout.html
  • login.html
  • notify.html
  • register.html
  • requests.html
  • submit_request.html

You can get the code for each of these HTML templates from this GitHub repo.

Add the main function

Time to glue everything together. This is the main function that initializes the DB, sets up sessions, loads templates, wires up the routes, and starts the HTTP server.

Create a file called main.go in the root directory of your project. Then, add the following code to the new file:

package main
import (
    "encoding/gob"
    "html/template"
    "log"
    "net/http"
    "os"
    "permit_twilio_demo/db"
    "permit_twilio_demo/handlers"
    "permit_twilio_demo/middleware"
    "permit_twilio_demo/permit"
    "github.com/gorilla/sessions"
    "github.com/joho/godotenv"
)
func init() {
    gob.Register(map[string] string {})
}
func main() {
    log.SetFlags(log.LstdFlags | log.Lshortfile)
    log.Println("Starting server initialization...")
    err := godotenv.Load()
    if err != nil {
        log.Printf("Warning: Error loading .env file: %v", err)
    }
    log.Println("Initializing database connection...")
    db.Init()
    log.Println("Initializing Permit.io...")
    permit.InitPermit()
    log.Println("Setting up templates...")
    tmpl := template.Must(template.ParseGlob("templates/*.html"))
    handlers.SetTemplates(tmpl)
    log.Println("Setting up session store...")
    sessionSecret := os.Getenv("SESSION_SECRET")
    if sessionSecret == "" {
        log.Println("Warning: SESSION_SECRET not set in environment, using default value")
        sessionSecret = "default-session-secret-for-development"
    }
    store := sessions.NewCookieStore([] byte(sessionSecret))
       store.Options = & sessions.Options {
        Path: "/", 
        MaxAge: 3600 * 24, 
        HttpOnly: false, 
        Secure: false, 
        SameSite: http.SameSiteDefaultMode, // Most permissive for debugging
    }
    handlers.Store = store // Set in handlers package
    middleware.Store = store // Set in middleware package
    log.Println("Setting up routes...")
    http.HandleFunc("/register", logRequest(handlers.RegisterPage))
    http.HandleFunc("/login", logRequest(handlers.LoginPage))
    http.HandleFunc("/logout", logRequest(handlers.LogoutHandler))
    http.HandleFunc("/v1/health", func(w http.ResponseWriter, r * http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([] byte("OK"))
    })
    http.HandleFunc("/submit-request", logRequest(withAuth(handlers.SubmitRequestPage)))
    http.HandleFunc("/requests", logRequest(withAuth(handlers.RequestListPage)))
    http.HandleFunc("/approve", logRequest(withAuth(handlers.ApproveRequest)))
    http.HandleFunc("/notify", logRequest(withAuth(handlers.NotifyPage)))
    http.HandleFunc("/", logRequest(withAuth(handlers.HomePage)))
    log.Println("Server initialization complete. Running at http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
func logRequest(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r * http.Request) {
        log.Printf("Request: %s %s", r.Method, r.URL.Path)
        next(w, r)
    }
}
func withAuth(handler http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r * http.Request) {
        middleware.WithCurrentUser(http.HandlerFunc(handler)).ServeHTTP(w, r)
    }
}
func withOptionalUser(handler http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r * http.Request) {
        middleware.LoadSessionUser(http.HandlerFunc(handler)).ServeHTTP(w, r)
    }
}

In the code above, we bootstrap the entire application. It loads environment variables, initializes the database and Permit.io client, sets up HTML templates, and configures secure session storage.

The main() function also registers all HTTP routes, wiring them to their corresponding handlers; some public, others protected by middleware. It wraps routes with basic logging and authentication checks.

Test the application

To test the application, run the following command:

go run main.go

To register a programmer or technician, navigate to http://localhost:8080/register. This will take you to the register page, where you can register as a programmer or a technician. To register, please fill in your name, email, password, phone number, and then select a role(programmers or technicians).

Registration form interface for programmers and technicians with fields for name, email, password, and phone.

After successfully registering, you'll then be redirected to the login route. Fill in your credentials and click Login.

User login form with fields for email and password and a blue login button.

Once logged in, you’ll be directed to the dashboard:

Welcome screen showing user’s dashboard with options to submit requests and view system information.

In the dashboard, click on Submit New Request to submit a new request:

Online form with fields for request details, priority selection, and a submit button

Fill in your request and click Submit Request.

Now let's move on to the admin part. Log out, then log as any of the super admins (which you can find in db/db.go). For this demo, let’s go with the chief_programmer. The dashboard will look like this:

Dashboard with sections for managing requests, sending notifications, accessing system information, and permissions.

So, chief_programmer can view and approve requests of the programmer, by clicking View Pending Requests:

Dashboard showing a programmer request with priority medium and status approved.

Click Approve next to the request that you created earlier. Once approved (or rejected), a notification is sent.

Now, here comes the fun part, which is to showcase the permission notification. The chief programmer can send notifications, and every worker with the title "programmer" gets them. To demonstrate it. Click Home to go back to the dashboard, then click Send Notifications.

Screenshot of a web page for sending notifications, featuring fields for subject, message, and recipient selection.

Then, fill in the message and click Send Notification. This will send a notification to everyone with the title "programmer".

Text message about a team meeting from a Twilio trial account with various emoji reactions.

The same happens for the "technicians", too. The "manager" role is a bit different as it is granted permission to send to both, or any of the fields.

You can find the complete code for the application on GitHub.

That's how to send permission-aware SMS notifications with Go, Twilio, and Permit.io

In this tutorial, you learnt how to build a role-based SMS notification system in Go using Twilio and Permit.io. You handled user registration, request approval, and permissioned messaging, making sure only users with the correct roles can send notifications to the correct people. With this setup, you get a secure, structured way to manage internal communications without hardcoding access rules.

Ready to extend it? Try adding email or push notifications next, or hook it into a real-time dashboard. And if you haven’t already, explore more features on Permit.io and Twilio’s messaging APIs to see what else you can build.

Temitope Taiwo Oyedele is a software engineer and technical writer. He likes to write about things he’s learned and experienced.