Skip to main content

RBAC System Technical Specification

Overview

This document specifies the complete Role-Based Access Control (RBAC) system with granular permissions for the ODAPI platform. The system uses JWT authentication with refresh tokens, token blacklisting, and Pundit policies for authorization.

Architecture Principles

  1. Rails API as Authority: All authorization decisions happen server-side
  2. JWT for Authentication: Stateless authentication with refresh token strategy
  3. Granular Permissions: Permission-based authorization (not just role-based)
  4. Defense in Depth: Backend policies + frontend route guards
  5. Zero Trust: Every API request validates permissions

Database Schema

Users Table

create_table :users do |t|
# Authentication
t.string :email, null: false, index: { unique: true }
t.string :password_digest, null: false

# Profile
t.string :first_name
t.string :last_name
t.string :phone

# Status
t.boolean :active, default: true, null: false
t.datetime :last_login_at
t.string :last_login_ip

# Security
t.integer :failed_login_attempts, default: 0
t.datetime :locked_at

t.timestamps
end

Roles Table

create_table :roles do |t|
t.string :name, null: false, index: { unique: true }
t.string :description
t.integer :level, null: false, default: 0 # Hierarchy: public=0, admin=50, superuser=100
t.timestamps
end

# Default roles
Role.create!([
{ name: "public_user", description: "Public user with read-only access", level: 0 },
{ name: "admin", description: "Administrator with CRUD access", level: 50 },
{ name: "superuser", description: "Superuser with full system access", level: 100 }
])

Permissions Table

create_table :permissions do |t|
t.string :resource, null: false # e.g., "companies", "users", "cache"
t.string :action, null: false # e.g., "read", "create", "update", "delete"
t.string :description
t.timestamps

t.index [ :resource, :action ], unique: true
end

# Example permissions
Permission.create!([
# Company permissions
{ resource: "companies", action: "read", description: "View company data" },
{ resource: "companies", action: "read_full", description: "View full company details" },
{ resource: "companies", action: "create", description: "Create companies" },
{ resource: "companies", action: "update", description: "Update companies" },
{ resource: "companies", action: "delete", description: "Delete companies" },

# User management
{ resource: "users", action: "read", description: "View users" },
{ resource: "users", action: "create", description: "Create users" },
{ resource: "users", action: "update", description: "Update users" },
{ resource: "users", action: "delete", description: "Delete users" },

# System administration
{ resource: "cache", action: "manage", description: "Manage application cache" },
{ resource: "jobs", action: "manage", description: "Manage background jobs" },
{ resource: "settings", action: "read", description: "View settings" },
{ resource: "settings", action: "update", description: "Update settings" }
])

Role-Permission Join Table

create_table :role_permissions do |t|
t.references :role, null: false, foreign_key: true
t.references :permission, null: false, foreign_key: true
t.timestamps

t.index [ :role_id, :permission_id ], unique: true
end

# Default role-permission assignments
public_role = Role.find_by(name: "public_user")
admin_role = Role.find_by(name: "admin")
super_role = Role.find_by(name: "superuser")

# Public user: read-only company data
public_role.permissions << Permission.where(resource: "companies", action: "read")

# Admin: CRUD companies, users, settings, cache, jobs
admin_role.permissions << Permission.where(resource: [ "companies", "users" ])
admin_role.permissions << Permission.where(resource: "settings")
admin_role.permissions << Permission.where(resource: "cache", action: "manage")
admin_role.permissions << Permission.where(resource: "jobs", action: "manage")

# Superuser: all permissions
super_role.permissions << Permission.all

User-Role Join Table

create_table :user_roles do |t|
t.references :user, null: false, foreign_key: true
t.references :role, null: false, foreign_key: true
t.timestamps

t.index [ :user_id, :role_id ], unique: true
end

JWT Blacklist Table

create_table :jwt_blacklists do |t|
t.string :jti, null: false, index: { unique: true } # JWT ID
t.datetime :exp, null: false # Expiration time
t.timestamps
end

# Auto-cleanup expired tokens (rake task)
# JwtBlacklist.where("exp < ?", Time.current).delete_all

Models

User Model

class User < ApplicationRecord
has_secure_password

# Associations
has_many :user_roles, dependent: :destroy
has_many :roles, through: :user_roles
has_many :permissions, through: :roles

# Validations
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }, if: :password_digest_changed?

# Scopes
scope :active, -> { where(active: true) }
scope :locked, -> { where.not(locked_at: nil) }

# Authentication methods
def self.authenticate(email, password)
user = find_by(email: email.downcase)
return nil unless user&.active?
return nil if user.locked?

if user.authenticate(password)
user.update(failed_login_attempts: 0, last_login_at: Time.current)
user
else
user.increment_failed_attempts!
nil
end
end

def increment_failed_attempts!
increment!(:failed_login_attempts)
lock_account! if failed_login_attempts >= 5
end

def lock_account!
update(locked_at: Time.current)
end

def unlock_account!
update(locked_at: nil, failed_login_attempts: 0)
end

def locked?
locked_at.present?
end

# Permission methods
def has_permission?(resource, action)
permissions.exists?(resource: resource, action: action)
end

def has_any_permission?(*permission_pairs)
permission_pairs.any? do |resource, action|
has_permission?(resource, action)
end
end

def has_role?(role_name)
roles.exists?(name: role_name)
end

def superuser?
has_role?("superuser")
end

def admin?
has_role?("admin") || superuser?
end

def public_user?
has_role?("public_user") && !admin?
end

# For JWT payload
def jwt_payload
{
user_id: id,
email: email,
roles: roles.pluck(:name),
permissions: permissions.pluck(:resource, :action).map { |r, a| "#{r}:#{a}" }
}
end
end

Role Model

class Role < ApplicationRecord
has_many :user_roles, dependent: :destroy
has_many :users, through: :user_roles
has_many :role_permissions, dependent: :destroy
has_many :permissions, through: :role_permissions

validates :name, presence: true, uniqueness: true
validates :level, presence: true, numericality: { only_integer: true }

scope :ordered, -> { order(level: :desc) }

def higher_than?(other_role)
level > other_role.level
end
end

Permission Model

class Permission < ApplicationRecord
has_many :role_permissions, dependent: :destroy
has_many :roles, through: :role_permissions

validates :resource, presence: true
validates :action, presence: true
validates :resource, uniqueness: { scope: :action }

scope :for_resource, ->(resource) { where(resource: resource) }

def to_s
"#{resource}:#{action}"
end
end

JwtBlacklist Model

class JwtBlacklist < ApplicationRecord
validates :jti, presence: true, uniqueness: true
validates :exp, presence: true

def self.revoke_token(jti, exp)
create!(jti: jti, exp: Time.at(exp))
end

def self.token_revoked?(jti)
exists?(jti: jti)
end

def self.cleanup_expired
where("exp < ?", Time.current).delete_all
end
end

JWT Authentication Service

JsonWebToken Service

# app/services/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.dig(:jwt, :secret_key) || Rails.application.secret_key_base
ALGORITHM = "HS256"

# Access token: 1 day
ACCESS_TOKEN_EXPIRATION = 24.hours

# Refresh token: 30 days
REFRESH_TOKEN_EXPIRATION = 30.days

def self.encode(payload, exp = ACCESS_TOKEN_EXPIRATION.from_now)
payload[:exp] = exp.to_i
payload[:jti] = SecureRandom.uuid # JWT ID for revocation
JWT.encode(payload, SECRET_KEY, ALGORITHM)
end

def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: ALGORITHM }).first
HashWithIndifferentAccess.new(decoded)
rescue JWT::DecodeError, JWT::ExpiredSignature
nil
end

def self.generate_tokens(user)
access_payload = user.jwt_payload.merge(type: "access")
refresh_payload = { user_id: user.id, type: "refresh" }

{
access_token: encode(access_payload, ACCESS_TOKEN_EXPIRATION.from_now),
refresh_token: encode(refresh_payload, REFRESH_TOKEN_EXPIRATION.from_now),
expires_in: ACCESS_TOKEN_EXPIRATION.to_i
}
end

def self.revoke_token(token)
payload = decode(token)
return false unless payload

JwtBlacklist.revoke_token(payload[:jti], payload[:exp])
true
end

def self.token_revoked?(token)
payload = decode(token)
return true unless payload

JwtBlacklist.token_revoked?(payload[:jti])
end
end

Authentication Controller

API::V1::AuthController

# app/controllers/api/v1/auth_controller.rb
module Api
module V1
class AuthController < ApplicationController
skip_before_action :authenticate_user!, only: [ :login, :refresh ]

# POST /api/v1/auth/login
def login
user = User.authenticate(login_params[:email], login_params[:password])

if user
tokens = JsonWebToken.generate_tokens(user)
render json: {
user: UserSerializer.new(user).serializable_hash,
tokens: tokens
}, status: :ok
else
render json: { error: "Invalid email or password" }, status: :unauthorized
end
end

# POST /api/v1/auth/refresh
def refresh
token = request.headers["Authorization"]&.split(" ")&.last
payload = JsonWebToken.decode(token)

if payload && payload[:type] == "refresh" && !JsonWebToken.token_revoked?(token)
user = User.find_by(id: payload[:user_id])
if user&.active?
tokens = JsonWebToken.generate_tokens(user)
render json: { tokens: tokens }, status: :ok
else
render json: { error: "User not found or inactive" }, status: :unauthorized
end
else
render json: { error: "Invalid refresh token" }, status: :unauthorized
end
end

# DELETE /api/v1/auth/logout
def logout
token = request.headers["Authorization"]&.split(" ")&.last
JsonWebToken.revoke_token(token) if token
head :no_content
end

# GET /api/v1/auth/me
def me
render json: UserSerializer.new(current_user).serializable_hash
end

private

def login_params
params.require(:auth).permit(:email, :password)
end
end
end
end

Application Controller Updates

ApplicationController

class ApplicationController < ActionController::API
include Pundit::Authorization

before_action :authenticate_user!

rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

private

def authenticate_user!
token = request.headers["Authorization"]&.split(" ")&.last
return render_unauthorized unless token

payload = JsonWebToken.decode(token)
return render_unauthorized unless payload
return render_unauthorized if JsonWebToken.token_revoked?(token)
return render_unauthorized unless payload[:type] == "access"

@current_user = User.find_by(id: payload[:user_id])
render_unauthorized unless @current_user&.active?
end

def current_user
@current_user
end

def render_unauthorized
render json: { error: "Unauthorized" }, status: :unauthorized
end

def user_not_authorized
render json: { error: "You are not authorized to perform this action" }, status: :forbidden
end
end

Pundit Policies

ApplicationPolicy

# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record

def initialize(user, record)
@user = user
@record = record
end

def index?
false
end

def show?
false
end

def create?
false
end

def update?
false
end

def destroy?
false
end

protected

def has_permission?(resource, action)
user.has_permission?(resource, action)
end

def superuser?
user.superuser?
end

def admin?
user.admin?
end

class Scope
def initialize(user, scope)
@user = user
@scope = scope
end

def resolve
raise NotImplementedError, "You must define #resolve in #{self.class}"
end

private

attr_reader :user, :scope
end
end

CompanyPolicy

# app/policies/company_policy.rb
class CompanyPolicy < ApplicationPolicy
def index?
has_permission?("companies", "read")
end

def show?
has_permission?("companies", "read") || has_permission?("companies", "read_full")
end

def create?
has_permission?("companies", "create")
end

def update?
has_permission?("companies", "update")
end

def destroy?
has_permission?("companies", "delete")
end

class Scope < Scope
def resolve
if user.has_permission?("companies", "read_full")
scope.all
elsif user.has_permission?("companies", "read")
# Limited fields for public users
scope.select(:id, :siren, :name, :naf_code, :city)
else
scope.none
end
end
end
end

UserPolicy

# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
def index?
has_permission?("users", "read")
end

def show?
record == user || has_permission?("users", "read")
end

def create?
has_permission?("users", "create")
end

def update?
record == user || has_permission?("users", "update")
end

def destroy?
has_permission?("users", "delete") && record != user
end

def assign_role?
superuser?
end

class Scope < Scope
def resolve
if user.has_permission?("users", "read")
scope.all
else
scope.where(id: user.id)
end
end
end
end

CachePolicy

# app/policies/cache_policy.rb
class CachePolicy < ApplicationPolicy
def manage?
has_permission?("cache", "manage")
end

def clear?
manage?
end

def set?
manage?
end

def refresh?
manage?
end
end

JobPolicy

# app/policies/job_policy.rb
class JobPolicy < ApplicationPolicy
def manage?
has_permission?("jobs", "manage")
end

def view_dashboard?
manage?
end

def retry_failed?
manage?
end

def process_queued?
manage?
end
end

SettingPolicy

# app/policies/setting_policy.rb
class SettingPolicy < ApplicationPolicy
def index?
has_permission?("settings", "read")
end

def show?
has_permission?("settings", "read")
end

def update?
has_permission?("settings", "update")
end

def update_sidekiq_schedule?
has_permission?("settings", "update")
end
end

API Endpoints with Authorization

Example: CompaniesController

# app/controllers/api/v1/companies_controller.rb
module Api
module V1
class CompaniesController < ApplicationController
before_action :set_company, only: [ :show, :update, :destroy ]

# GET /api/v1/companies
def index
authorize Company
@companies = policy_scope(Company).page(params[:page])
render json: CompanySerializer.new(@companies).serializable_hash
end

# GET /api/v1/companies/:id
def show
authorize @company
render json: CompanySerializer.new(@company).serializable_hash
end

# POST /api/v1/companies
def create
authorize Company
@company = Company.new(company_params)

if @company.save
render json: CompanySerializer.new(@company).serializable_hash, status: :created
else
render json: { errors: @company.errors }, status: :unprocessable_entity
end
end

# PATCH/PUT /api/v1/companies/:id
def update
authorize @company

if @company.update(company_params)
render json: CompanySerializer.new(@company).serializable_hash
else
render json: { errors: @company.errors }, status: :unprocessable_entity
end
end

# DELETE /api/v1/companies/:id
def destroy
authorize @company
@company.destroy
head :no_content
end

private

def set_company
@company = Company.find(params[:id])
end

def company_params
params.require(:company).permit(:name, :siren, :naf_code, :city, ...)
end
end
end
end

Frontend Authentication (Admin App)

Auth Context (React)

// admin/src/contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import api from '@/services/api';

interface User {
id: number;
email: string;
firstName: string;
lastName: string;
roles: string[];
permissions: string[];
}

interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<void>;
hasPermission: (resource: string, action: string) => boolean;
hasRole: (role: string) => boolean;
isAuthenticated: boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
// Initialize auth on mount
const initAuth = async () => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
try {
const response = await api.get('/auth/me');
setUser(response.data);
} catch (error) {
// Token invalid, clear storage
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
}
setLoading(false);
};

initAuth();

// Setup token refresh interval (23 hours)
const refreshInterval = setInterval(() => {
refreshToken();
}, 23 * 60 * 60 * 1000);

return () => clearInterval(refreshInterval);
}, []);

const login = async (email: string, password: string) => {
const response = await api.post('/auth/login', { auth: { email, password } });
const { user: userData, tokens } = response.data;

localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
setUser(userData);
};

const logout = async () => {
try {
await api.delete('/auth/logout');
} finally {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setUser(null);
}
};

const refreshToken = async () => {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return;

try {
const response = await api.post('/auth/refresh', {}, {
headers: { Authorization: `Bearer ${refreshToken}` }
});
const { tokens } = response.data;
localStorage.setItem('access_token', tokens.access_token);
} catch (error) {
// Refresh failed, logout user
await logout();
}
};

const hasPermission = (resource: string, action: string): boolean => {
if (!user) return false;
return user.permissions.includes(`${resource}:${action}`);
};

const hasRole = (role: string): boolean => {
if (!user) return false;
return user.roles.includes(role);
};

const isAuthenticated = !!user;

return (
<AuthContext.Provider
value={{
user,
loading,
login,
logout,
refreshToken,
hasPermission,
hasRole,
isAuthenticated
}}
>
{children}
</AuthContext.Provider>
);
}

export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

Protected Route Component

// admin/src/components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { CircularProgress, Box } from '@mui/material';

interface ProtectedRouteProps {
requiredPermission?: { resource: string; action: string };
requiredRole?: string;
}

export default function ProtectedRoute({ requiredPermission, requiredRole }: ProtectedRouteProps) {
const { isAuthenticated, loading, hasPermission, hasRole } = useAuth();

if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
<CircularProgress />
</Box>
);
}

if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}

if (requiredPermission && !hasPermission(requiredPermission.resource, requiredPermission.action)) {
return <Navigate to="/unauthorized" replace />;
}

if (requiredRole && !hasRole(requiredRole)) {
return <Navigate to="/unauthorized" replace />;
}

return <Outlet />;
}

Updated API Service with Token Injection

// admin/src/services/api.ts
import axios from 'axios';

const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});

// Request interceptor: Add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);

// Response interceptor: Handle 401 errors
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;

// If 401 and not already retrying, try refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
const response = await axios.post(
`${import.meta.env.VITE_API_URL}/auth/refresh`,
{},
{ headers: { Authorization: `Bearer ${refreshToken}` } }
);

const { access_token } = response.data.tokens;
localStorage.setItem('access_token', access_token);

// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return api(originalRequest);
}
} catch (refreshError) {
// Refresh failed, redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}

return Promise.reject(error);
}
);

export default api;

Login Page Component

// admin/src/pages/Login.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import {
Box,
Card,
CardContent,
TextField,
Button,
Typography,
Alert
} from '@mui/material';

export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);

const { login } = useAuth();
const navigate = useNavigate();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);

try {
await login(email, password);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
};

return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="100vh"
bgcolor="grey.100"
>
<Card sx={{ maxWidth: 400, width: '100%' }}>
<CardContent>
<Typography variant="h5" component="h1" gutterBottom align="center">
Admin Login
</Typography>

{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}

<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
margin="normal"
required
autoFocus
/>

<TextField
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
/>

<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={loading}
sx={{ mt: 3 }}
>
{loading ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</Box>
);
}

Updated App Router with Protected Routes

// admin/src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from '@/contexts/AuthContext';
import ProtectedRoute from '@/components/ProtectedRoute';
import Login from '@/pages/Login';
import Layout from '@/components/Layout';
import Dashboard from '@/pages/Dashboard';
import Companies from '@/pages/Companies';
import Users from '@/pages/Users';
import Settings from '@/pages/Settings';
import Unauthorized from '@/pages/Unauthorized';

function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/unauthorized" element={<Unauthorized />} />

{/* Protected Routes */}
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />

{/* Companies - requires "companies:read" permission */}
<Route
path="/companies"
element={<ProtectedRoute requiredPermission={{ resource: "companies", action: "read" }} />}
>
<Route index element={<Companies />} />
</Route>

{/* Users - requires "users:read" permission */}
<Route
path="/users"
element={<ProtectedRoute requiredPermission={{ resource: "users", action: "read" }} />}
>
<Route index element={<Users />} />
</Route>

{/* Settings - requires "settings:read" permission */}
<Route
path="/settings"
element={<ProtectedRoute requiredPermission={{ resource: "settings", action: "read" }} />}
>
<Route index element={<Settings />} />
</Route>
</Route>
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}

export default App;

Public React App (New)

The public app will be a separate codebase with:

  • Read-only company search and viewing
  • Similar auth setup but restricted permissions
  • Simplified UI without admin features
  • Separate deployment and domain

Structure:

public/
├── src/
│ ├── components/
│ ├── pages/
│ │ ├── Home.tsx
│ │ ├── Search.tsx
│ │ ├── CompanyDetail.tsx
│ │ ├── Login.tsx (optional for registered users)
│ ├── contexts/
│ │ └── AuthContext.tsx (similar to admin but simpler)
│ ├── services/
│ │ └── api.ts
│ └── App.tsx
├── package.json
└── vite.config.ts

Deployment Strategy

Phase 1: Backend Foundation (Week 1)

  1. Create database migrations for users, roles, permissions
  2. Implement User model with authentication
  3. Add JWT service and blacklist mechanism
  4. Create seed data for roles and permissions

Phase 2: Authorization Layer (Week 1-2)

  1. Add Pundit gem and create policies
  2. Update ApplicationController with auth
  3. Create AuthController with login/logout/refresh
  4. Add authorization to all existing controllers

Phase 3: Admin Frontend (Week 2)

  1. Add AuthContext and ProtectedRoute components
  2. Update API service with token injection
  3. Create Login page
  4. Update router with protected routes
  5. Add permission checks to UI elements

Phase 4: Public Frontend (Week 3)

  1. Create new public/ directory
  2. Copy auth setup from admin (simplified)
  3. Build public-facing pages
  4. Configure separate deployment

Phase 5: Testing & Deployment (Week 3-4)

  1. Write comprehensive tests for auth and policies
  2. Security audit and penetration testing
  3. Deploy backend with backward compatibility
  4. Deploy admin frontend
  5. Deploy public frontend

Security Considerations

  1. Password Security:

    • Use has_secure_password with bcrypt
    • Minimum 8 characters
    • Account lockout after 5 failed attempts
  2. JWT Security:

    • Use strong secret key (Rails credentials)
    • Short-lived access tokens (1 day)
    • Longer-lived refresh tokens (30 days)
    • Token blacklist for revocation
  3. API Security:

    • HTTPS only in production
    • CORS properly configured
    • Rate limiting on auth endpoints
    • SQL injection protection (Rails ORM)
  4. Frontend Security:

    • No sensitive data in JWT payload
    • Tokens in localStorage (XSS risk mitigated by CSP)
    • Automatic token refresh
    • 401 handling with redirect to login
  5. Audit Trail:

    • Log all authentication attempts
    • Track last login time and IP
    • Log role and permission changes

Testing Strategy

Backend Tests

# spec/models/user_spec.rb
RSpec.describe User, type: :model do
describe "authentication" do
it "authenticates with valid credentials" do
user = create(:user, email: "test@example.com", password: "password123")
expect(User.authenticate("test@example.com", "password123")).to eq(user)
end

it "locks account after 5 failed attempts" do
user = create(:user)
5.times { User.authenticate(user.email, "wrong") }
user.reload
expect(user.locked?).to be true
end
end

describe "permissions" do
it "checks permission correctly" do
user = create(:user)
role = create(:role, name: "admin")
permission = create(:permission, resource: "companies", action: "read")
role.permissions << permission
user.roles << role

expect(user.has_permission?("companies", "read")).to be true
expect(user.has_permission?("companies", "delete")).to be false
end
end
end

# spec/policies/company_policy_spec.rb
RSpec.describe CompanyPolicy do
subject { described_class }

let(:public_user) { create(:user, :public_user) }
let(:admin_user) { create(:user, :admin) }
let(:company) { create(:company) }

permissions :show? do
it "grants access to admin" do
expect(subject).to permit(admin_user, company)
end

it "grants access to public user" do
expect(subject).to permit(public_user, company)
end
end

permissions :update? do
it "grants access to admin" do
expect(subject).to permit(admin_user, company)
end

it "denies access to public user" do
expect(subject).not_to permit(public_user, company)
end
end
end

Frontend Tests

// admin/src/contexts/__tests__/AuthContext.test.tsx
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from '../AuthContext';

describe('AuthContext', () => {
it('logs in user successfully', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider
});

await act(async () => {
await result.current.login('test@example.com', 'password123');
});

expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBeDefined();
});

it('checks permissions correctly', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider
});

// Mock user with permissions
act(() => {
result.current.user = {
permissions: ['companies:read', 'companies:update']
};
});

expect(result.current.hasPermission('companies', 'read')).toBe(true);
expect(result.current.hasPermission('companies', 'delete')).toBe(false);
});
});

Rake Tasks

# lib/tasks/auth.rake
namespace :auth do
desc "Create default roles and permissions"
task setup: :environment do
puts "Creating roles..."
roles = [
{ name: "public_user", description: "Public user", level: 0 },
{ name: "admin", description: "Administrator", level: 50 },
{ name: "superuser", description: "Superuser", level: 100 }
]

roles.each do |role_data|
Role.find_or_create_by!(name: role_data[:name]) do |role|
role.description = role_data[:description]
role.level = role_data[:level]
end
end

puts "Creating permissions..."
# ... (permissions from schema section)

puts "Assigning permissions to roles..."
# ... (role-permission assignments from schema section)

puts "✅ Auth setup complete"
end

desc "Cleanup expired JWT blacklist entries"
task cleanup_blacklist: :environment do
count = JwtBlacklist.cleanup_expired
puts "Removed #{count} expired blacklist entries"
end

desc "Create superuser account"
task :create_superuser, [ :email, :password ] => :environment do |t, args|
email = args[:email] || ENV["SUPERUSER_EMAIL"]
password = args[:password] || ENV["SUPERUSER_PASSWORD"]

raise "Email and password required" unless email && password

user = User.create!(
email: email,
password: password,
first_name: "Super",
last_name: "User",
active: true
)

user.roles << Role.find_by(name: "superuser")
puts "✅ Superuser created: #{email}"
end
end

Environment Variables

# .env.example

# JWT Secret (use strong random string in production)
JWT_SECRET_KEY=your_secret_key_here

# Token expiration (seconds)
JWT_ACCESS_TOKEN_EXPIRATION=86400 # 1 day
JWT_REFRESH_TOKEN_EXPIRATION=2592000 # 30 days

# Account lockout
MAX_FAILED_LOGIN_ATTEMPTS=5

# API URLs
VITE_API_URL=http://localhost:3000/api/v1 # Development
# VITE_API_URL=https://api.bizradar.fr/api # Production

Next Steps

  1. Review this specification and provide feedback
  2. Begin Phase 1 implementation (database schema)
  3. Create seed data for testing
  4. Implement JWT authentication service
  5. Add Pundit policies
  6. Update frontend with authentication

Document Version: 1.0 Last Updated: 2025-11-01 Author: Claude Code (SuperClaude Framework)