gogate

package module
v1.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 8 Imported by: 0

README

wpd-gogate

A clean, idiomatic, and high-performance Go Role-Based Access Control (RBAC) library.

wpd-gogate is fully framework-agnostic, database-agnostic, supports multi-tenant team/workspace scoping, polymorphic model checking, and leverages a high-speed in-memory cache to achieve sub-microsecond authorization decisions.


Features

  • Sub-microsecond Evaluations: Role-permission matrices are cached in a thread-safe synchronized map on startup. Check times are measured in nanoseconds.
  • Relational Schema: Utilizes a highly flexible, polymorphic 5-table relational database schema.
  • Polymorphic Targets: Query and assign permissions on any target entity (e.g. users, api_keys, services) using model_type and model_id.
  • Scoped Scenarios (Teams/Workspaces): Assign roles and permissions scoped to specific workspaces, groups, or teams using an optional team_id.
  • Fluent Chaining API: Ergonomic Go API:
    user := gate.Model("users", userID, teamID)
    user.AssignRole(ctx, "writer")
    user.Can(ctx, "edit articles")
    
  • Zero-DB Checked Fast Path: Perform pure in-memory checks (gate.HasRolePermission("admin", "edit articles")) if the user's role has already been resolved in context or JWT.
  • Instant Cache Updates: Modifying a role's permissions or deleting a role instantly updates the in-memory cache, keeping clustered nodes consistent without complex event listeners.
  • Echo Framework Integration: Built-in routing middleware for fast integration into your Echo handler stack.

Installation

go get github.com/weprodev/wpd-gogate

Database Schema

wpd-gogate manages roles and permissions using a standard relational structure. Instead of storing access checks in code or custom config files, everything is managed securely in your PostgreSQL database.

The package utilizes 5 tables:

  1. permissions: Stores permission names (e.g., users.list, articles.create).
  2. roles: Stores role names (e.g., admin, editor).
  3. role_has_permissions: Links permissions to roles (which roles can perform which actions).
  4. model_has_roles: Assigns roles to models (e.g., assigning the editor role to a specific user within a specific workspace/team using team_id).
  5. model_has_permissions: Assigns direct permission overrides to models (e.g., giving a specific user articles.delete even if their role doesn't allow it, also scoped by team_id).
The team_id Architecture

Both model_has_roles and model_has_permissions contain an optional team_id UUID column. This is the foundation for multi-tenant and workspace-scoped authorization.

  • If a user is assigned a role with team_id = NULL, it is a global role.
  • If assigned with a specific team_id, the user only possesses that role/permission when operating within the context of that specific team. The team_id column is built into the Primary Key of these tables to allow a user to hold different roles across different teams.
Understanding guard_name

The guard_name column defines the authentication boundary or context under which a role or permission is valid.

1. Scoping Multiple Authentication Systems

In complex systems, you often have different ways of authenticating users, each having its own context. For example:

  • web: Standard human portal users authenticated via JWT or session cookies.
  • api: External API clients or services authenticated via API keys or client credentials.
  • admin: Internal super-admins logging into an employee-only panel.

By defining guard_name, you prevent permissions from leaking across different interfaces. A user might have the settings.write permission under the web guard, but an API key might require the settings.write permission under the api guard.

2. Preventing Name Collisions

The database enforces a unique constraint on permission/role names per guard:

CONSTRAINT permissions_name_guard_unique UNIQUE (name, guard_name)

This allows you to define identical permission names (e.g., logs.read) under different guards without collision, keeping their mappings fully isolated.

3. Default Value

By default, the library sets guard_name to 'web' if it is not explicitly specified.

Running Migrations

We provide standard Postgres migration files under the migrations/ folder. Simply run the UP migration file on your database client or migration manager to create the tables:

  • UP Migration (Creates Tables): migrations/create_permission_tables.up.sql
  • DOWN Migration (Removes Tables): migrations/create_permission_tables.down.sql

Database Seeding

To quickly set up standard roles and associate them with permissions, you can use our pre-configured SQL seeder. This script inserts standard roles (admin, editor, writer, viewer), registers basic permissions, and maps them together using dynamic SQL queries.

  • SQL Seeder Script: seeds/seed_roles_permissions.sql

To seed your database, run the script against your PostgreSQL instance using psql or your preferred SQL tool:

psql -U your_user -d your_database -f seeds/seed_roles_permissions.sql

Quickstart Guide

1. Initialize the Gate

Instantiate the Gate with your database handle and call LoadPolicy on boot to populate the cache:

package main

import (
	"context"
	"database/sql"
	"log"

	"github.com/weprodev/wpd-gogate"
	_ "github.com/lib/pq"
)

func main() {
	db, err := sql.Open("postgres", "host=localhost user=postgres dbname=app sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}

	// Initialize gate with defaults
	gate := gogate.NewGate(db, nil)

	// Load role-permission relations into memory cache
	ctx := context.Background()
	if err := gate.LoadPolicy(ctx); err != nil {
		log.Fatalf("failed to load policies: %v", err)
	}
}
2. Configure Roles & Permissions (Admin API)

Programmatically configure your roles, permissions, and mappings:

// Create roles & permissions
_ = gate.CreateRole(ctx, "writer", "web")
_ = gate.CreatePermission(ctx, "edit articles", "web")

// Give permission to a role
_ = gate.Role("writer").GivePermissionTo(ctx, "edit articles")
3. Assign & Verify Access (Fluent Model API)

Perform authorization checks on polymorphic models:

userID := "00000000-0000-0000-0000-000000000010"
workspaceID := "00000000-0000-0000-0000-000000000001"

// Scoped model reference
user := gate.Model("users", userID, workspaceID)

// Assign role
_ = user.AssignRole(ctx, "writer")

// Check if user has permission (inherits "edit articles" from "writer" role)
hasAccess, err := user.Can(ctx, "edit articles")
if hasAccess {
    // Authorized!
}

// Give a direct permission override (ignores roles)
_ = user.GivePermissionTo(ctx, "publish posts")
4. Zero-DB Fast Path (In-Memory Check)

If your user's role has already been resolved (e.g. injected into context or parsed from a JWT claim), you can perform a pure memory lookup with no database queries:

if gate.HasRolePermission("admin", "delete posts") {
    // Authorized in nanoseconds!
}

Integration Guide (Routes, Middleware, Handlers)

wpd-gogate is designed to be highly versatile. Here are the three primary patterns for integrating authorization checks into your Echo application.

1. Route-Level Authorization (Declarative Routes)

You can protect individual routes or entire route groups declaratively during route registration. This keeps your handlers focused entirely on business logic.

package main

import (
	"context"
	"database/sql"
	"github.com/labstack/echo/v4"
	"github.com/weprodev/wpd-gogate"
)

func main() {
	db, _ := sql.Open("postgres", "...")
	gate := gogate.NewGate(db, nil)
	_ = gate.LoadPolicy(context.Background())

	e := echo.New()

	// 1. Protecting individual endpoints
	e.GET("/templates", listTemplates, gogate.RequirePermission(gate, "templates.list", nil))

	// 2. Protecting route groups
	adminGroup := e.Group("/admin")
	adminGroup.Use(gogate.RequirePermission(gate, "admin.access", nil))
	adminGroup.POST("/settings", updateSettings)
}
2. Custom Middleware Configuration (Dynamic Context Scoping)

If your application stores user contexts or workspace scopes differently (e.g., in request headers, custom session variables, or specific path variables), you can configure MiddlewareOptions to resolve identifiers dynamically:

// Protect workspace settings with custom ID extraction
opts := gogate.MiddlewareOptions{
    ModelType: "users",
    ExtractModelID: func(c echo.Context) (any, error) {
        // Extract the user UUID from a custom JWT context attribute
        userID, ok := c.Get("authenticated_user_id").(string)
        if !ok || userID == "" {
            return nil, echo.NewHTTPError(http.StatusUnauthorized, "User context missing")
        }
        return userID, nil
    },
    ExtractTeamID: func(c echo.Context) (any, error) {
        // Extract workspace/team UUID from a custom header instead of path parameters
        workspaceID := c.Request().Header.Get("X-Workspace-ID")
        if workspaceID == "" {
            return nil, echo.NewHTTPError(http.StatusBadRequest, "X-Workspace-ID header required")
        }
        return workspaceID, nil
    },
    OnDenied: func(c echo.Context, permissionName string) error {
        return c.JSON(http.StatusForbidden, map[string]string{
            "error":      "Access Denied",
            "permission": permissionName,
        })
    },
}

// Attach the configured middleware
e.GET("/settings", getSettings, gogate.RequirePermission(gate, "settings.read", &opts))
3. Imperative Authorization in Handlers (Controller Logic)

For complex scenarios where authorization depends on entity ownership or path attributes loaded dynamically inside the controller, you can use the fluent API directly inside your handlers:

func DeleteArticle(c echo.Context) error {
	ctx := c.Request().Context()
	userID := c.Get("userID").(string)
	workspaceID := c.Param("wid")
	articleID := c.Param("articleId")

	// 1. Resolve fluent ModelRef for the active user & workspace
	user := gate.Model("users", userID, workspaceID)

	// 2. Perform permission check
	hasAdminAccess, err := user.Can(ctx, "articles.delete")
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, "Auth service error")
	}

	// 3. Fallback check: check if the user is the owner of the article
	isOwner := checkIfUserOwnsArticle(articleID, userID)

	if !hasAdminAccess && !isOwner {
		return echo.NewHTTPError(http.StatusForbidden, "You do not have permission to delete this article")
	}

	// Proceed with deletion logic...
	return c.NoContent(http.StatusNoContent)
}

Local Development & Testing

We provide a self-contained local development script and a Docker compose file to run integration tests against a real Postgres database.

Running Audit (Fmt + Lint + Race tests)
make audit
Database Integration Testing
# Starts Postgres container, runs tests with Postgres connection, stops container
make test-integration

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrRoleAlreadyExists       = errors.New("wpd-gogate: role already exists")
	ErrPermissionAlreadyExists = errors.New("wpd-gogate: permission already exists")
)

Functions

func IsNilOrEmpty added in v1.1.0

func IsNilOrEmpty(val any) bool

IsNilOrEmpty checks if a value is nil, an empty string, a nil pointer, or a zero UUID.

func RequirePermission

func RequirePermission(gate *Gate, permissionName string, opts *MiddlewareOptions) echo.MiddlewareFunc

RequirePermission returns an Echo middleware enforcing that the authenticated model has the required permission.

Types

type Config

type Config struct {
	RolesTable               string
	PermissionsTable         string
	RoleHasPermissionsTable  string
	ModelHasRolesTable       string
	ModelHasPermissionsTable string
	DefaultGuardName         string
}

Config defines the table names and defaults for the RBAC gate, mirroring standard relational database RBAC conventions.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns the standard config mapping to defaults.

type DBTX

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

DBTX is the minimal database interface required by wpd-gogate. It is satisfied by *sql.DB and *sql.Tx.

type Gate

type Gate struct {
	// contains filtered or unexported fields
}

Gate is the core engine for role-based and permission-based authorization.

func NewGate

func NewGate(db DBTX, cfg *Config) *Gate

NewGate instantiates a new Gate with a DB client and optional configuration.

func (*Gate) Check

func (g *Gate) Check(ctx context.Context, modelType string, modelID any, permissionName string, guardName string, teamID any) (bool, error)

Check verifies if the model (e.g. user) has the required permission. It queries both direct permissions and roles in a single database round-trip (UNION ALL), then maps them against the in-memory cache to determine access. teamID is optional and can be nil to check global assignments.

func (*Gate) CreatePermission

func (g *Gate) CreatePermission(ctx context.Context, name string, guardName string) error

CreatePermission inserts a new permission into the database.

func (*Gate) CreateRole

func (g *Gate) CreateRole(ctx context.Context, name string, guardName string) error

CreateRole inserts a new role into the database.

func (*Gate) DeletePermission

func (g *Gate) DeletePermission(ctx context.Context, name string) error

DeletePermission deletes a permission from the database and removes it from all roles in the cache.

func (*Gate) DeleteRole

func (g *Gate) DeleteRole(ctx context.Context, name string) error

DeleteRole deletes a role from the database and removes it from the cache.

func (*Gate) GetAllPermissionsMap added in v1.2.0

func (g *Gate) GetAllPermissionsMap(ctx context.Context) (map[string][]string, error)

GetAllPermissionsMap returns all permissions in the database, grouped by guard_name.

func (*Gate) GetAllRolesMap added in v1.2.0

func (g *Gate) GetAllRolesMap(ctx context.Context) (map[string][]string, error)

GetAllRolesMap returns all roles in the database, grouped by guard_name.

func (*Gate) HasRolePermission

func (g *Gate) HasRolePermission(guardName, roleName, permissionName string) bool

HasRolePermission performs an in-memory O(1) check of whether a role is assigned a specific permission.

func (*Gate) LoadPolicy

func (g *Gate) LoadPolicy(ctx context.Context) error

LoadPolicy fetches all role-permission associations from the database and caches them in memory. This is thread-safe and should be run on boot or when permissions are updated.

func (*Gate) Model

func (g *Gate) Model(modelType string, modelID any, teamID any) *ModelRef

Model constructs a ModelRef for checking permissions and managing roles/permissions.

func (*Gate) Role

func (g *Gate) Role(name string, guardName string) *RoleRef

Role constructs a RoleRef for role-scoped operations.

type MiddlewareOptions

type MiddlewareOptions struct {
	// ModelType specifies the type of model being checked (default: "users").
	ModelType string
	// ExtractModelID extracts the model identifier (e.g., user UUID) from the context.
	ExtractModelID func(c echo.Context) (any, error)
	// ExtractTeamID extracts the team or workspace identifier (optional, e.g., workspace UUID) from the context.
	ExtractTeamID func(c echo.Context) (any, error)
	// OnDenied defines the response when permission is denied.
	OnDenied func(c echo.Context, permissionName string) error
	// OnError defines the response when an internal database error occurs.
	OnError func(c echo.Context, err error) error
	// GuardName specifies the guard name for this check. If empty, uses default.
	GuardName string
}

MiddlewareOptions configures how the RBAC middleware behaves.

func DefaultMiddlewareOptions

func DefaultMiddlewareOptions() MiddlewareOptions

DefaultMiddlewareOptions provides sensible defaults for Echo web applications.

type ModelRef

type ModelRef struct {
	// contains filtered or unexported fields
}

ModelRef provides a fluent, scoped API for a specific model (e.g., User, APIKey) in the context of an optional team or workspace.

func (*ModelRef) AssignRole

func (m *ModelRef) AssignRole(ctx context.Context, roleName string, guardName string) error

AssignRole assigns the given role to the model.

func (*ModelRef) Can

func (m *ModelRef) Can(ctx context.Context, permissionName string, guardName string) (bool, error)

Can checks if the model has the specified permission.

func (*ModelRef) GetAllPermissions

func (m *ModelRef) GetAllPermissions(ctx context.Context) ([]string, error)

GetAllPermissions returns both direct and inherited permissions.

func (*ModelRef) GetDirectPermissions

func (m *ModelRef) GetDirectPermissions(ctx context.Context) ([]string, error)

GetDirectPermissions returns the names of all direct permissions assigned to the model.

func (*ModelRef) GetPermissionsViaRoles

func (m *ModelRef) GetPermissionsViaRoles(ctx context.Context) ([]string, error)

GetPermissionsViaRoles returns all permissions inherited by the model's roles.

func (*ModelRef) GetRoleNames

func (m *ModelRef) GetRoleNames(ctx context.Context) ([]string, error)

GetRoleNames returns the names of all roles assigned to the model.

func (*ModelRef) GetRolesMap added in v1.1.0

func (m *ModelRef) GetRolesMap(ctx context.Context) (map[string][]string, error)

GetRolesMap returns all roles assigned to the model, grouped by guard_name.

func (*ModelRef) GivePermissionTo

func (m *ModelRef) GivePermissionTo(ctx context.Context, permissionName string, guardName string) error

GivePermissionTo assigns a direct permission override to the model.

func (*ModelRef) HasAllPermissions added in v1.1.0

func (m *ModelRef) HasAllPermissions(ctx context.Context, permissionNames ...string) (bool, error)

HasAllPermissions checks if the model has all of the specified permissions.

func (*ModelRef) HasAllRoles added in v1.1.0

func (m *ModelRef) HasAllRoles(ctx context.Context, guardName string, roleNames ...string) (bool, error)

HasAllRoles checks if the model has all of the specified roles.

func (*ModelRef) HasAnyPermission added in v1.1.0

func (m *ModelRef) HasAnyPermission(ctx context.Context, permissionNames ...string) (bool, error)

HasAnyPermission checks if the model has any of the specified permissions.

func (*ModelRef) HasAnyRole added in v1.1.0

func (m *ModelRef) HasAnyRole(ctx context.Context, guardName string, roleNames ...string) (bool, error)

HasAnyRole checks if the model has at least one of the specified roles.

func (*ModelRef) HasRole added in v1.1.0

func (m *ModelRef) HasRole(ctx context.Context, roleName string, guardName string) (bool, error)

HasRole checks if the model has the specified role directly in the database.

func (*ModelRef) RemoveRole

func (m *ModelRef) RemoveRole(ctx context.Context, roleName string, guardName string) error

RemoveRole removes the given role from the model.

func (*ModelRef) RevokePermissionTo

func (m *ModelRef) RevokePermissionTo(ctx context.Context, permissionName string, guardName string) error

RevokePermissionTo removes a direct permission override from the model.

type RoleRef

type RoleRef struct {
	// contains filtered or unexported fields
}

RoleRef provides a fluent API for managing a specific Role's permissions.

func (*RoleRef) GetPermissionNames

func (r *RoleRef) GetPermissionNames(ctx context.Context) ([]string, error)

GetPermissionNames returns names of all permissions assigned to the role.

func (*RoleRef) GivePermissionTo

func (r *RoleRef) GivePermissionTo(ctx context.Context, permissionName string) error

GivePermissionTo assigns the specified permission to the role in the database and immediately updates the in-memory cache.

func (*RoleRef) RevokePermissionTo

func (r *RoleRef) RevokePermissionTo(ctx context.Context, permissionName string) error

RevokePermissionTo revokes the specified permission from the role and immediately removes it from the in-memory cache.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL