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 permissionsA 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:
-- 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:
| Role | Permissions | System | Description |
|---|---|---|---|
admin | All permissions | Yes | Full access to every resource |
user | No permissions | Yes | Authenticated 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 settingsTIP
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:
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:
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:
#[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
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
GET /api/admin/roles
Authorization: Bearer {token}Create a Custom Role
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
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:
-- 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