Skip to content

Authorization

FORGE implements a full Role-Based Access Control (RBAC) system that governs what authenticated users can do within the application. Roles group permissions together, and permissions control access to individual actions on resources. The system is flexible enough to support simple admin/user setups and complex multi-role hierarchies.

RBAC Architecture

┌──────────┐       ┌──────────┐       ┌──────────────┐
│  Users   │──M:N──│  Roles   │──M:N──│ Permissions  │
└──────────┘       └──────────┘       └──────────────┘
                   role_user           permission_role

User "Ahmed" ──▶ Role "admin" ──▶ Permission "users.create"
                                 ──▶ Permission "users.edit"
                                 ──▶ Permission "roles.view"
                                 ──▶ ...all permissions

A user can have multiple roles, and each role can have multiple permissions. When checking authorization, the system aggregates all permissions from all of the user's roles.

Database Schema

Four tables power the RBAC system:

sql
-- Roles table
CREATE TABLE roles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL UNIQUE,
    display_name VARCHAR(255),
    description TEXT,
    is_system BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Permissions table
CREATE TABLE permissions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL UNIQUE,
    display_name VARCHAR(255),
    description TEXT,
    group_name VARCHAR(255),
    is_system BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Pivot: which users have which roles
CREATE TABLE role_user (
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    PRIMARY KEY (user_id, role_id)
);

-- Pivot: which roles have which permissions
CREATE TABLE permission_role (
    permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    PRIMARY KEY (permission_id, role_id)
);

WARNING

Rows with is_system = true cannot be deleted or renamed through the API. This protects the built-in admin role and core permissions from accidental removal.

Default Roles

FORGE seeds two roles on first migration:

RolePermissionsSystemDescription
adminAll permissionsYesFull access to every resource
userNo permissionsYesAuthenticated but unprivileged

The admin role automatically receives every permission. When new permissions are added via seeders, the admin role is updated to include them.

Permission Naming Convention

Permissions follow a resource.action format:

users.view        # View user list and details
users.create      # Create new users
users.edit        # Update existing users
users.delete      # Delete users

roles.view        # View roles
roles.create      # Create roles
roles.edit        # Update roles
roles.delete      # Delete roles

contents.view     # View content entries
contents.create   # Create content
contents.edit     # Update content
contents.delete   # Delete content

settings.view     # View application settings
settings.edit     # Update settings

TIP

When adding a new module, follow the convention and create four permissions: module.view, module.create, module.edit, and module.delete. The admin role will automatically pick them up from the seeder.

Middleware Usage

Protecting Routes with Permissions

Apply the require_permission middleware to routes that need authorization:

rust
use crate::middleware::rbac::require_permission;

pub fn routes(pool: PgPool) -> Router {
    Router::new()
        // Only users with "users.view" permission can list users
        .route(
            "/users",
            get(handlers::admin::users::list)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("users.view"),
                )),
        )
        // Only users with "users.create" permission can create users
        .route(
            "/users",
            post(handlers::admin::users::create)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("users.create"),
                )),
        )
}

Checking Permissions in Handlers

For fine-grained control within a handler, check permissions directly on the authenticated user:

rust
pub async fn update_user(
    State(pool): State<PgPool>,
    Extension(current_user): Extension<AuthUser>,
    Path(user_id): Path<Uuid>,
    Json(payload): Json<UpdateUserRequest>,
) -> Result<impl IntoResponse, AppError> {
    // Check if the user can edit other users
    if !current_user.has_permission("users.edit") {
        return Err(AppError::Forbidden(
            "You do not have permission to edit users".into(),
        ));
    }

    // Additional check: prevent non-admins from assigning admin role
    if payload.role_ids.contains(&admin_role_id)
        && !current_user.has_permission("roles.edit")
    {
        return Err(AppError::Forbidden(
            "You do not have permission to assign the admin role".into(),
        ));
    }

    let user = UserService::update(&pool, user_id, payload).await?;
    Ok(Json(ApiResponse::success(user)))
}

The AuthUser Struct

The authentication middleware injects an AuthUser into request extensions. This struct carries the user's identity and their aggregated permissions:

rust
#[derive(Clone, Debug)]
pub struct AuthUser {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub permissions: Vec<String>,
    pub roles: Vec<String>,
}

impl AuthUser {
    /// Check if the user has a specific permission
    pub fn has_permission(&self, permission: &str) -> bool {
        self.permissions.contains(&permission.to_string())
    }

    /// Check if the user has any of the given permissions
    pub fn has_any_permission(&self, permissions: &[&str]) -> bool {
        permissions.iter().any(|p| self.has_permission(p))
    }

    /// Check if the user has all of the given permissions
    pub fn has_all_permissions(&self, permissions: &[&str]) -> bool {
        permissions.iter().all(|p| self.has_permission(p))
    }

    /// Check if the user has a specific role
    pub fn has_role(&self, role: &str) -> bool {
        self.roles.contains(&role.to_string())
    }
}

Managing Roles via API

Assign Roles to a User

bash
POST /api/admin/users/{id}/roles
Authorization: Bearer {token}
Content-Type: application/json

{
  "role_ids": [
    "550e8400-e29b-41d4-a716-446655440000",
    "660e8400-e29b-41d4-a716-446655440001"
  ]
}

List All Roles

bash
GET /api/admin/roles
Authorization: Bearer {token}

Create a Custom Role

bash
POST /api/admin/roles
Authorization: Bearer {token}
Content-Type: application/json

{
  "name": "editor",
  "display_name": "Editor",
  "description": "Can manage content but not users",
  "permission_ids": [
    "uuid-of-contents.view",
    "uuid-of-contents.create",
    "uuid-of-contents.edit"
  ]
}

Update Role Permissions

bash
PUT /api/admin/roles/{id}
Authorization: Bearer {token}
Content-Type: application/json

{
  "display_name": "Senior Editor",
  "permission_ids": [
    "uuid-of-contents.view",
    "uuid-of-contents.create",
    "uuid-of-contents.edit",
    "uuid-of-contents.delete"
  ]
}

WARNING

System roles (is_system = true) cannot be deleted through the API. Attempting to delete the admin or user role returns a 403 Forbidden error. You can still modify the permissions assigned to system roles.

Permission Seeder

Permissions are seeded from a migration file to ensure consistency across environments:

sql
-- database/seeders/02_permissions.sql
INSERT INTO permissions (name, display_name, group_name, is_system)
VALUES
    ('users.view', 'View Users', 'Users', true),
    ('users.create', 'Create Users', 'Users', true),
    ('users.edit', 'Edit Users', 'Users', true),
    ('users.delete', 'Delete Users', 'Users', true),
    ('roles.view', 'View Roles', 'Roles', true),
    ('roles.create', 'Create Roles', 'Roles', true),
    ('roles.edit', 'Edit Roles', 'Roles', true),
    ('roles.delete', 'Delete Roles', 'Roles', true),
    ('contents.view', 'View Contents', 'Contents', true),
    ('contents.create', 'Create Contents', 'Contents', true),
    ('contents.edit', 'Edit Contents', 'Contents', true),
    ('contents.delete', 'Delete Contents', 'Contents', true)
ON CONFLICT (name) DO NOTHING;

-- Assign all permissions to admin role
INSERT INTO permission_role (permission_id, role_id)
SELECT p.id, r.id
FROM permissions p
CROSS JOIN roles r
WHERE r.name = 'admin'
ON CONFLICT DO NOTHING;

See Also

  • Authentication — How users prove their identity
  • Middleware — Implementation details of the RBAC middleware
  • Routes — Applying permission middleware to route groups
  • Handlers — In-handler permission checks

Released under the MIT License.