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
- Rails API as Authority: All authorization decisions happen server-side
- JWT for Authentication: Stateless authentication with refresh token strategy
- Granular Permissions: Permission-based authorization (not just role-based)
- Defense in Depth: Backend policies + frontend route guards
- 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)
- Create database migrations for users, roles, permissions
- Implement User model with authentication
- Add JWT service and blacklist mechanism
- Create seed data for roles and permissions
Phase 2: Authorization Layer (Week 1-2)
- Add Pundit gem and create policies
- Update ApplicationController with auth
- Create AuthController with login/logout/refresh
- Add authorization to all existing controllers
Phase 3: Admin Frontend (Week 2)
- Add AuthContext and ProtectedRoute components
- Update API service with token injection
- Create Login page
- Update router with protected routes
- Add permission checks to UI elements
Phase 4: Public Frontend (Week 3)
- Create new public/ directory
- Copy auth setup from admin (simplified)
- Build public-facing pages
- Configure separate deployment
Phase 5: Testing & Deployment (Week 3-4)
- Write comprehensive tests for auth and policies
- Security audit and penetration testing
- Deploy backend with backward compatibility
- Deploy admin frontend
- Deploy public frontend
Security Considerations
-
Password Security:
- Use
has_secure_passwordwith bcrypt - Minimum 8 characters
- Account lockout after 5 failed attempts
- Use
-
JWT Security:
- Use strong secret key (Rails credentials)
- Short-lived access tokens (1 day)
- Longer-lived refresh tokens (30 days)
- Token blacklist for revocation
-
API Security:
- HTTPS only in production
- CORS properly configured
- Rate limiting on auth endpoints
- SQL injection protection (Rails ORM)
-
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
-
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
- Review this specification and provide feedback
- Begin Phase 1 implementation (database schema)
- Create seed data for testing
- Implement JWT authentication service
- Add Pundit policies
- Update frontend with authentication
Document Version: 1.0 Last Updated: 2025-11-01 Author: Claude Code (SuperClaude Framework)