RBAC Authorization Patterns for laneweaverTMS
Expert guidance for implementing Role-Based Access Control (RBAC) and multi-tenant authorization in a Go/Echo backend with Supabase/PostgreSQL.
When to Use This Skill
Use when:
- Defining user roles and permissions for freight brokerage operations
- Implementing Echo middleware for role/permission checks
- Setting up multi-tenant isolation with account-based access
- Designing JWT claims structure for authorization
- Writing RLS policies for tenant-isolated data access
- Choosing appropriate HTTP status codes for authorization failures
Freight Brokerage Role Definitions
Standard Roles
| Role | Description | Typical Access |
|---|
admin | Full system access | All resources, user management, system config |
dispatcher | Load management, carrier selection | Loads, carriers, tracking, dispatch operations |
sales | Account management, quotes | Customers, quotes, lanes, tenders |
finance | Invoicing, payments, reports | Invoices, carrier bills, payments, financial reports |
driver | Limited mobile access | Assigned loads only, status updates, document upload |
readonly | View-only access | Read all operational data, no modifications |
Permission Model
Permissions follow a resource:action pattern:
loads:read, loads:create, loads:update, loads:delete
carriers:read, carriers:create, carriers:update
customers:read, customers:create, customers:update
invoices:read, invoices:create, invoices:approve
reports:financial, reports:operational
users:manage
Database Schema Patterns
Core RBAC Tables
sql
1-- Roles table
2CREATE TABLE public.roles (
3 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
4 name TEXT NOT NULL UNIQUE,
5 description TEXT,
6 is_system_role BOOLEAN DEFAULT false,
7 created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
8 updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
9);
10
11-- Permissions table
12CREATE TABLE public.permissions (
13 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
14 resource TEXT NOT NULL,
15 action TEXT NOT NULL,
16 description TEXT,
17 created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
18 UNIQUE(resource, action)
19);
20
21-- Role-Permission junction
22CREATE TABLE public.role_permissions (
23 role_id UUID NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
24 permission_id UUID NOT NULL REFERENCES public.permissions(id) ON DELETE CASCADE,
25 created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
26 PRIMARY KEY (role_id, permission_id)
27);
28
29-- User-Role junction (within account/tenant context)
30CREATE TABLE public.user_roles (
31 user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
32 role_id UUID NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
33 account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
34 created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
35 created_by UUID REFERENCES auth.users(id),
36 PRIMARY KEY (user_id, role_id, account_id)
37);
38
39-- Account-User junction for multi-tenant
40CREATE TABLE public.account_users (
41 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
42 account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
43 user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
44 is_primary BOOLEAN DEFAULT false,
45 invited_at TIMESTAMPTZ DEFAULT now() NOT NULL,
46 accepted_at TIMESTAMPTZ,
47 created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
48 UNIQUE(account_id, user_id)
49);
50
51-- Indexes for RLS policy performance
52CREATE INDEX idx_user_roles_user_id ON public.user_roles(user_id);
53CREATE INDEX idx_user_roles_account_id ON public.user_roles(account_id);
54CREATE INDEX idx_account_users_user_id ON public.account_users(user_id);
55CREATE INDEX idx_account_users_account_id ON public.account_users(account_id);
Seed Default Roles
sql
1INSERT INTO public.roles (name, description, is_system_role) VALUES
2 ('admin', 'Full system access', true),
3 ('dispatcher', 'Load management and carrier selection', true),
4 ('sales', 'Account management and quotes', true),
5 ('finance', 'Invoicing, payments, and reports', true),
6 ('driver', 'Limited mobile access for assigned loads', true),
7 ('readonly', 'View-only access to operational data', true);
Echo Authorization Middleware
Context Keys
go
1package middleware
2
3type contextKey string
4
5const (
6 ContextKeyUserID contextKey = "user_id"
7 ContextKeyAccountID contextKey = "account_id"
8 ContextKeyRoles contextKey = "roles"
9 ContextKeyUser contextKey = "user"
10)
JWT Claims Structure
go
1package auth
2
3import "github.com/golang-jwt/jwt/v5"
4
5type Claims struct {
6 jwt.RegisteredClaims
7 UserID string `json:"user_id"`
8 Email string `json:"email"`
9 AccountID string `json:"account_id"`
10 Roles []string `json:"roles"`
11 Permissions []string `json:"permissions,omitempty"` // Optional: can derive from roles
12}
Authentication Middleware
Validates JWT and extracts claims into context:
go
1package middleware
2
3import (
4 "net/http"
5 "strings"
6
7 "github.com/labstack/echo/v4"
8)
9
10func JWTAuth(jwtSecret []byte) echo.MiddlewareFunc {
11 return func(next echo.HandlerFunc) echo.HandlerFunc {
12 return func(c echo.Context) error {
13 authHeader := c.Request().Header.Get("Authorization")
14 if authHeader == "" {
15 return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
16 }
17
18 tokenString := strings.TrimPrefix(authHeader, "Bearer ")
19 if tokenString == authHeader {
20 return echo.NewHTTPError(http.StatusUnauthorized, "invalid authorization format")
21 }
22
23 claims, err := ValidateToken(tokenString, jwtSecret)
24 if err != nil {
25 return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired token")
26 }
27
28 // Store in context for downstream handlers
29 c.Set(string(ContextKeyUserID), claims.UserID)
30 c.Set(string(ContextKeyAccountID), claims.AccountID)
31 c.Set(string(ContextKeyRoles), claims.Roles)
32 c.Set(string(ContextKeyUser), claims)
33
34 return next(c)
35 }
36 }
37}
Tenant Context Middleware
Ensures valid tenant context after authentication:
go
1func TenantContext() echo.MiddlewareFunc {
2 return func(next echo.HandlerFunc) echo.HandlerFunc {
3 return func(c echo.Context) error {
4 accountID := c.Get(string(ContextKeyAccountID))
5 if accountID == nil || accountID.(string) == "" {
6 return echo.NewHTTPError(http.StatusForbidden, "no tenant context")
7 }
8
9 // Optionally validate account exists and is active
10 // This can be cached for performance
11
12 return next(c)
13 }
14 }
15}
Role-Based Authorization Middleware
go
1// RequireRole checks if user has any of the specified roles
2func RequireRole(roles ...string) echo.MiddlewareFunc {
3 return func(next echo.HandlerFunc) echo.HandlerFunc {
4 return func(c echo.Context) error {
5 userRoles, ok := c.Get(string(ContextKeyRoles)).([]string)
6 if !ok || len(userRoles) == 0 {
7 return echo.NewHTTPError(http.StatusForbidden, "no roles assigned")
8 }
9
10 for _, required := range roles {
11 for _, userRole := range userRoles {
12 if userRole == required {
13 return next(c)
14 }
15 }
16 }
17
18 return echo.NewHTTPError(http.StatusForbidden, "insufficient role permissions")
19 }
20 }
21}
22
23// RequireAnyRole is an alias for RequireRole (OR logic)
24var RequireAnyRole = RequireRole
25
26// RequireAllRoles checks if user has ALL specified roles
27func RequireAllRoles(roles ...string) echo.MiddlewareFunc {
28 return func(next echo.HandlerFunc) echo.HandlerFunc {
29 return func(c echo.Context) error {
30 userRoles, ok := c.Get(string(ContextKeyRoles)).([]string)
31 if !ok {
32 return echo.NewHTTPError(http.StatusForbidden, "no roles assigned")
33 }
34
35 userRoleSet := make(map[string]bool)
36 for _, r := range userRoles {
37 userRoleSet[r] = true
38 }
39
40 for _, required := range roles {
41 if !userRoleSet[required] {
42 return echo.NewHTTPError(http.StatusForbidden, "missing required role")
43 }
44 }
45
46 return next(c)
47 }
48 }
49}
Permission-Based Authorization Middleware
go
1// RequirePermission checks for specific resource:action permission
2func RequirePermission(resource, action string) echo.MiddlewareFunc {
3 return func(next echo.HandlerFunc) echo.HandlerFunc {
4 return func(c echo.Context) error {
5 claims, ok := c.Get(string(ContextKeyUser)).(*Claims)
6 if !ok {
7 return echo.NewHTTPError(http.StatusForbidden, "invalid user context")
8 }
9
10 // Check explicit permissions if available
11 requiredPerm := resource + ":" + action
12 for _, perm := range claims.Permissions {
13 if perm == requiredPerm || perm == resource+":*" || perm == "*:*" {
14 return next(c)
15 }
16 }
17
18 // Fallback: derive from roles (requires DB lookup or cached mapping)
19 if hasPermissionViaRole(claims.Roles, resource, action) {
20 return next(c)
21 }
22
23 return echo.NewHTTPError(http.StatusForbidden, "permission denied")
24 }
25 }
26}
27
28// hasPermissionViaRole checks role-permission mapping
29// In production, use cached lookup or include permissions in JWT
30func hasPermissionViaRole(roles []string, resource, action string) bool {
31 // Admin role has all permissions
32 for _, role := range roles {
33 if role == "admin" {
34 return true
35 }
36 }
37
38 // Role-permission mapping (simplified; use DB in production)
39 rolePerms := map[string][]string{
40 "dispatcher": {"loads:*", "carriers:read", "tracking:*"},
41 "sales": {"customers:*", "quotes:*", "lanes:*", "tenders:*"},
42 "finance": {"invoices:*", "payments:*", "reports:financial"},
43 "driver": {"loads:read", "loads:update_status", "documents:upload"},
44 "readonly": {"loads:read", "carriers:read", "customers:read"},
45 }
46
47 requiredPerm := resource + ":" + action
48 for _, role := range roles {
49 for _, perm := range rolePerms[role] {
50 if matchPermission(perm, requiredPerm) {
51 return true
52 }
53 }
54 }
55 return false
56}
57
58func matchPermission(pattern, required string) bool {
59 if pattern == required {
60 return true
61 }
62 // Handle wildcard: "loads:*" matches "loads:read"
63 if strings.HasSuffix(pattern, ":*") {
64 prefix := strings.TrimSuffix(pattern, "*")
65 return strings.HasPrefix(required, prefix)
66 }
67 return false
68}
Middleware Chain Example
Apply middleware in order: Auth -> Tenant -> Role -> Permission:
go
1func SetupRoutes(e *echo.Echo, cfg *config.Config) {
2 // Public routes (no auth required)
3 e.GET("/health", handlers.HealthCheck)
4
5 // API routes with auth
6 api := e.Group("/api/v1")
7 api.Use(middleware.JWTAuth(cfg.JWTSecret))
8 api.Use(middleware.TenantContext())
9
10 // Load routes - dispatchers and admins
11 loads := api.Group("/loads")
12 loads.Use(middleware.RequireRole("admin", "dispatcher", "sales", "readonly"))
13 loads.GET("", handlers.ListLoads)
14 loads.GET("/:id", handlers.GetLoad)
15
16 // Modify operations require specific roles
17 loads.POST("", handlers.CreateLoad, middleware.RequireRole("admin", "dispatcher", "sales"))
18 loads.PUT("/:id", handlers.UpdateLoad, middleware.RequireRole("admin", "dispatcher"))
19
20 // Finance routes
21 finance := api.Group("/finance")
22 finance.Use(middleware.RequireRole("admin", "finance"))
23 finance.GET("/invoices", handlers.ListInvoices)
24 finance.POST("/invoices", handlers.CreateInvoice)
25
26 // Admin-only routes
27 admin := api.Group("/admin")
28 admin.Use(middleware.RequireRole("admin"))
29 admin.GET("/users", handlers.ListUsers)
30 admin.POST("/users", handlers.CreateUser)
31}
Multi-Tenant RLS Policies
Enable RLS on Tables
sql
1ALTER TABLE public.loads ENABLE ROW LEVEL SECURITY;
2ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
3ALTER TABLE public.carriers ENABLE ROW LEVEL SECURITY;
4ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY;
Account-Based Tenant Isolation
sql
1-- Users see only their account's loads
2CREATE POLICY "Users see only their account loads"
3 ON public.loads
4 FOR SELECT
5 TO authenticated
6 USING (
7 account_id IN (
8 SELECT account_id
9 FROM public.account_users
10 WHERE user_id = (SELECT auth.uid())
11 )
12 );
13
14-- Users can create loads for their account
15CREATE POLICY "Users create loads for their account"
16 ON public.loads
17 FOR INSERT
18 TO authenticated
19 WITH CHECK (
20 account_id IN (
21 SELECT account_id
22 FROM public.account_users
23 WHERE user_id = (SELECT auth.uid())
24 )
25 );
26
27-- Users can update their account's loads
28CREATE POLICY "Users update their account loads"
29 ON public.loads
30 FOR UPDATE
31 TO authenticated
32 USING (
33 account_id IN (
34 SELECT account_id
35 FROM public.account_users
36 WHERE user_id = (SELECT auth.uid())
37 )
38 )
39 WITH CHECK (
40 account_id IN (
41 SELECT account_id
42 FROM public.account_users
43 WHERE user_id = (SELECT auth.uid())
44 )
45 );
Role-Based RLS Policies
Combine tenant isolation with role restrictions:
sql
1-- Helper function to check user roles within account
2CREATE OR REPLACE FUNCTION public.user_has_role(required_roles TEXT[])
3RETURNS BOOLEAN
4LANGUAGE sql
5SECURITY DEFINER
6STABLE
7AS $$
8 SELECT EXISTS (
9 SELECT 1
10 FROM public.user_roles ur
11 JOIN public.roles r ON ur.role_id = r.id
12 WHERE ur.user_id = (SELECT auth.uid())
13 AND r.name = ANY(required_roles)
14 );
15$$;
16
17-- Only finance and admin can view invoices
18CREATE POLICY "Finance users view invoices"
19 ON public.customer_invoices
20 FOR SELECT
21 TO authenticated
22 USING (
23 account_id IN (
24 SELECT account_id
25 FROM public.account_users
26 WHERE user_id = (SELECT auth.uid())
27 )
28 AND public.user_has_role(ARRAY['admin', 'finance', 'readonly'])
29 );
30
31-- Only finance and admin can create invoices
32CREATE POLICY "Finance users create invoices"
33 ON public.customer_invoices
34 FOR INSERT
35 TO authenticated
36 WITH CHECK (
37 account_id IN (
38 SELECT account_id
39 FROM public.account_users
40 WHERE user_id = (SELECT auth.uid())
41 )
42 AND public.user_has_role(ARRAY['admin', 'finance'])
43 );
Driver-Specific Policies
Drivers see only their assigned loads:
sql
1-- Drivers see only loads assigned to them
2CREATE POLICY "Drivers see assigned loads"
3 ON public.loads
4 FOR SELECT
5 TO authenticated
6 USING (
7 -- Driver is assigned to this load
8 driver_user_id = (SELECT auth.uid())
9 OR
10 -- Or user has broader access via role
11 (
12 account_id IN (
13 SELECT account_id
14 FROM public.account_users
15 WHERE user_id = (SELECT auth.uid())
16 )
17 AND public.user_has_role(ARRAY['admin', 'dispatcher', 'sales', 'readonly'])
18 )
19 );
Authorization Decision Patterns
HTTP Status Code Guidelines
| Scenario | Status Code | When to Use |
|---|
| Missing or invalid token | 401 Unauthorized | Token absent, expired, or malformed |
| Valid token, insufficient permissions | 403 Forbidden | User authenticated but lacks required role/permission |
| Resource not found (or hidden) | 404 Not Found | Resource doesn't exist OR hiding existence is security concern |
Security-Aware 404 Pattern
Use 404 instead of 403 when revealing resource existence is a security concern:
go
1func GetLoad(c echo.Context) error {
2 loadID := c.Param("id")
3 accountID := c.Get(string(middleware.ContextKeyAccountID)).(string)
4
5 load, err := repo.GetLoad(c.Request().Context(), loadID)
6 if err != nil {
7 if errors.Is(err, sql.ErrNoRows) {
8 // Resource doesn't exist
9 return echo.NewHTTPError(http.StatusNotFound, "load not found")
10 }
11 return echo.NewHTTPError(http.StatusInternalServerError, "failed to fetch load")
12 }
13
14 // Check tenant ownership - return 404 to hide existence
15 if load.AccountID != accountID {
16 return echo.NewHTTPError(http.StatusNotFound, "load not found")
17 }
18
19 return c.JSON(http.StatusOK, load)
20}
Error Response Structure
go
1type ErrorResponse struct {
2 Error string `json:"error"`
3 Code string `json:"code,omitempty"`
4 Details string `json:"details,omitempty"`
5}
6
7// Authorization error examples
8// 401: {"error": "missing authorization header", "code": "AUTH_REQUIRED"}
9// 401: {"error": "invalid or expired token", "code": "TOKEN_INVALID"}
10// 403: {"error": "insufficient role permissions", "code": "ROLE_REQUIRED"}
11// 403: {"error": "permission denied", "code": "PERMISSION_DENIED"}
JWT Claims Best Practices
Include only essential claims; derive others from database:
go
1type MinimalClaims struct {
2 jwt.RegisteredClaims
3 UserID string `json:"sub"` // Use standard 'sub' claim
4 AccountID string `json:"account_id"`
5 Roles []string `json:"roles"` // Include for middleware checks
6}
Full Claims with Permissions
For reduced database lookups, include permissions:
go
1type FullClaims struct {
2 jwt.RegisteredClaims
3 UserID string `json:"sub"`
4 Email string `json:"email"`
5 AccountID string `json:"account_id"`
6 AccountName string `json:"account_name"`
7 Roles []string `json:"roles"`
8 Permissions []string `json:"permissions"` // Flattened from roles
9}
Token Generation
go
1func GenerateToken(user *User, account *Account, roles []string, permissions []string) (string, error) {
2 now := time.Now()
3 claims := &FullClaims{
4 RegisteredClaims: jwt.RegisteredClaims{
5 Subject: user.ID,
6 IssuedAt: jwt.NewNumericDate(now),
7 ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
8 Issuer: "laneweavertms",
9 },
10 UserID: user.ID,
11 Email: user.Email,
12 AccountID: account.ID,
13 AccountName: account.Name,
14 Roles: roles,
15 Permissions: permissions,
16 }
17
18 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
19 return token.SignedString(jwtSecret)
20}
Authorization Checklist
RBAC Schema:
[ ] roles, permissions, role_permissions tables created
[ ] user_roles table includes account_id for multi-tenant
[ ] account_users table for tenant membership
[ ] Indexes on user_id, account_id columns for RLS performance
Middleware Chain:
[ ] JWT validation middleware extracts claims to context
[ ] Tenant context middleware validates account_id
[ ] Role middleware checks user roles array
[ ] Permission middleware checks specific resource:action
RLS Policies:
[ ] RLS enabled on all tenant-owned tables
[ ] SELECT policies use account_id IN (SELECT from account_users)
[ ] INSERT policies use WITH CHECK for account_id
[ ] UPDATE policies use both USING and WITH CHECK
[ ] auth.uid() wrapped in SELECT for query plan caching
[ ] Indexes exist on columns used in RLS conditions
JWT Claims:
[ ] Token includes user_id, account_id, roles
[ ] Token expiration set appropriately (e.g., 24 hours)
[ ] Refresh token mechanism for long-lived sessions
Error Handling:
[ ] 401 for missing/invalid authentication
[ ] 403 for valid auth but insufficient permissions
[ ] 404 when hiding resource existence is security concern
[ ] Error responses don't leak sensitive information
- goth-oauth - OAuth2 authentication foundation
- laneweaver-database-design - Database schema conventions
Reference