16 KiB
Menu Module Access Control System
📋 Overview
Sistem ini memaksimalkan penggunaan master_menus dan master_modules untuk mengatur akses berbasis user-level yang lebih granular. Sistem ini memungkinkan:
- Menu memiliki banyak modul - Satu menu dapat terdiri dari berbagai modul (view, create, edit, delete, etc.)
- Akses berbasis user-level - User-level dapat dikonfigurasi untuk mengakses modul-modul tertentu
- Pengecekan akses otomatis - Middleware untuk validasi akses sebelum user mengakses endpoint
🗂️ Database Schema
1. master_modules (Enhanced)
Tabel modul yang sudah ditingkatkan dengan field action_type.
ALTER TABLE master_modules
ADD COLUMN action_type VARCHAR NULL COMMENT 'Tipe aksi: view, create, edit, delete, approve, export, etc';
2. menu_modules (New)
Tabel relasi many-to-many antara menu dan modul.
CREATE TABLE menu_modules (
id SERIAL PRIMARY KEY,
menu_id INT NOT NULL,
module_id INT NOT NULL,
position INT NULL,
client_id UUID NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (menu_id) REFERENCES master_menus(id),
FOREIGN KEY (module_id) REFERENCES master_modules(id),
UNIQUE(menu_id, module_id)
);
CREATE INDEX idx_menu_modules_menu_id ON menu_modules(menu_id);
CREATE INDEX idx_menu_modules_module_id ON menu_modules(module_id);
3. user_level_module_accesses (New)
Tabel untuk mengatur akses user-level ke modul-modul tertentu.
CREATE TABLE user_level_module_accesses (
id SERIAL PRIMARY KEY,
user_level_id INT NOT NULL,
module_id INT NOT NULL,
can_access BOOLEAN DEFAULT TRUE,
client_id UUID NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_level_id) REFERENCES user_levels(id),
FOREIGN KEY (module_id) REFERENCES master_modules(id),
UNIQUE(user_level_id, module_id)
);
CREATE INDEX idx_user_level_module_accesses_user_level_id ON user_level_module_accesses(user_level_id);
CREATE INDEX idx_user_level_module_accesses_module_id ON user_level_module_accesses(module_id);
📊 Entity Relationships
master_menus (1) ─────< (N) menu_modules (N) >───── (1) master_modules
│
│ (1)
│
∨
user_levels (1) ─────< (N) user_level_module_accesses (N) >┘
🔧 API Endpoints
Menu Modules API
1. Get All Menu Modules
GET /api/menu-modules?menu_id=1&page=1&limit=10
Authorization: Bearer {token}
2. Get Modules by Menu ID
GET /api/menu-modules/menu/{menu_id}
Authorization: Bearer {token}
Response:
{
"success": true,
"messages": ["MenuModules by menu successfully retrieved"],
"data": [
{
"id": 1,
"menu_id": 1,
"module_id": 5,
"position": 1,
"module": {
"id": 5,
"name": "View Articles",
"description": "View article list",
"path_url": "/api/articles",
"action_type": "view"
}
}
]
}
3. Create Menu Module
POST /api/menu-modules
Authorization: Bearer {token}
Content-Type: application/json
{
"menu_id": 1,
"module_id": 5,
"position": 1
}
4. Create Menu Modules in Batch
POST /api/menu-modules/batch
Authorization: Bearer {token}
Content-Type: application/json
{
"menu_id": 1,
"module_ids": [5, 6, 7, 8]
}
5. Update Menu Module
PUT /api/menu-modules/{id}
Authorization: Bearer {token}
Content-Type: application/json
{
"position": 2,
"is_active": true
}
6. Delete Menu Module
DELETE /api/menu-modules/{id}
Authorization: Bearer {token}
User Level Module Accesses API
1. Get All Accesses
GET /api/user-level-module-accesses?user_level_id=1&page=1&limit=10
Authorization: Bearer {token}
2. Get Accesses by User Level ID
GET /api/user-level-module-accesses/user-level/{user_level_id}
Authorization: Bearer {token}
Response:
{
"success": true,
"messages": ["UserLevelModuleAccesses by user level successfully retrieved"],
"data": [
{
"id": 1,
"user_level_id": 1,
"module_id": 5,
"can_access": true,
"module": {
"id": 5,
"name": "View Articles",
"description": "View article list",
"path_url": "/api/articles",
"action_type": "view"
}
}
]
}
3. Check Access
POST /api/user-level-module-accesses/check-access
Authorization: Bearer {token}
Content-Type: application/json
{
"user_level_id": 1,
"module_id": 5
}
Response:
{
"success": true,
"messages": ["Access check completed"],
"data": {
"has_access": true
}
}
4. Create Access
POST /api/user-level-module-accesses
Authorization: Bearer {token}
Content-Type: application/json
{
"user_level_id": 1,
"module_id": 5,
"can_access": true
}
5. Create Accesses in Batch
POST /api/user-level-module-accesses/batch
Authorization: Bearer {token}
Content-Type: application/json
{
"user_level_id": 1,
"module_ids": [5, 6, 7, 8],
"can_access": true
}
🛡️ Middleware Usage
1. Check Module Access by Module ID
import (
"netidhub-saas-be/app/middleware"
)
// Di router setup
moduleAccessMiddleware := middleware.NewModuleAccessMiddleware(db)
// Protect endpoint dengan module ID
app.Get("/api/articles",
authMiddleware.ValidateToken(),
moduleAccessMiddleware.CheckModuleAccess(uint(5)), // module_id = 5
articleController.GetAll,
)
2. Check Module Access by Path URL
// Protect endpoint dengan path_url
app.Get("/api/articles",
authMiddleware.ValidateToken(),
moduleAccessMiddleware.CheckModuleAccess("/api/articles"), // path_url
articleController.GetAll,
)
3. Check Module Access by Current Path (Auto)
// Auto-detect dari path yang sedang diakses
app.Get("/api/articles",
authMiddleware.ValidateToken(),
moduleAccessMiddleware.CheckModuleAccessByPath(), // auto-detect
articleController.GetAll,
)
4. Check Menu Access
// Check akses ke menu (minimal punya akses ke 1 modul di menu tersebut)
app.Get("/api/articles/menu",
authMiddleware.ValidateToken(),
moduleAccessMiddleware.CheckMenuAccess(uint(1)), // menu_id = 1
articleController.GetByMenu,
)
📝 Implementation Example
Scenario: Article Management System
Step 1: Create Modules
-- Insert modules untuk Article
INSERT INTO master_modules (name, description, path_url, action_type, status_id, is_active) VALUES
('View Articles', 'View article list', '/api/articles', 'view', 1, true),
('Create Article', 'Create new article', '/api/articles/create', 'create', 1, true),
('Edit Article', 'Edit existing article', '/api/articles/edit', 'edit', 1, true),
('Delete Article', 'Delete article', '/api/articles/delete', 'delete', 1, true),
('Approve Article', 'Approve article', '/api/articles/approve', 'approve', 1, true);
Step 2: Create Menu
-- Insert menu Article
INSERT INTO master_menus (name, description, module_id, icon, "group", position, status_id, is_active) VALUES
('Article Management', 'Manage articles', 1, 'article-icon', 'Content', 1, 1, true);
Step 3: Link Modules to Menu
POST /api/menu-modules/batch
{
"menu_id": 1,
"module_ids": [1, 2, 3, 4, 5]
}
Or via SQL:
INSERT INTO menu_modules (menu_id, module_id, position, is_active) VALUES
(1, 1, 1, true), -- View Articles
(1, 2, 2, true), -- Create Article
(1, 3, 3, true), -- Edit Article
(1, 4, 4, true), -- Delete Article
(1, 5, 5, true); -- Approve Article
Step 4: Grant Access to User Levels
Admin Pusat (user_level_id = 1) - Full Access:
POST /api/user-level-module-accesses/batch
{
"user_level_id": 1,
"module_ids": [1, 2, 3, 4, 5],
"can_access": true
}
Editor (user_level_id = 2) - Limited Access:
POST /api/user-level-module-accesses/batch
{
"user_level_id": 2,
"module_ids": [1, 2, 3], // Only view, create, edit
"can_access": true
}
Viewer (user_level_id = 3) - Read Only:
POST /api/user-level-module-accesses/batch
{
"user_level_id": 3,
"module_ids": [1], // Only view
"can_access": true
}
Step 5: Protect Routes
func SetupArticleRoutes(app *fiber.App, db *database.Database, articleController controller.ArticleController) {
moduleAccessMw := middleware.NewModuleAccessMiddleware(db)
articles := app.Group("/api/articles")
// View - Accessible by all levels with access
articles.Get("/",
authMiddleware.ValidateToken(),
moduleAccessMw.CheckModuleAccess(uint(1)), // module_id for "View Articles"
articleController.GetAll,
)
// Create - Accessible by Admin & Editor only
articles.Post("/",
authMiddleware.ValidateToken(),
moduleAccessMw.CheckModuleAccess(uint(2)), // module_id for "Create Article"
articleController.Create,
)
// Edit - Accessible by Admin & Editor only
articles.Put("/:id",
authMiddleware.ValidateToken(),
moduleAccessMw.CheckModuleAccess(uint(3)), // module_id for "Edit Article"
articleController.Update,
)
// Delete - Accessible by Admin only
articles.Delete("/:id",
authMiddleware.ValidateToken(),
moduleAccessMw.CheckModuleAccess(uint(4)), // module_id for "Delete Article"
articleController.Delete,
)
// Approve - Accessible by Admin only
articles.Post("/:id/approve",
authMiddleware.ValidateToken(),
moduleAccessMw.CheckModuleAccess(uint(5)), // module_id for "Approve Article"
articleController.Approve,
)
}
🔄 Migration Script
Create file: docs/migrations/004_add_menu_module_access_system.sql
-- Migration: Add Menu Module Access System
-- Description: Enhance master_modules and create menu_modules & user_level_module_accesses tables
-- Step 1: Enhance master_modules with action_type
ALTER TABLE master_modules
ADD COLUMN IF NOT EXISTS action_type VARCHAR NULL;
COMMENT ON COLUMN master_modules.action_type IS 'Tipe aksi: view, create, edit, delete, approve, export, etc';
-- Step 2: Create menu_modules table
CREATE TABLE IF NOT EXISTS menu_modules (
id SERIAL PRIMARY KEY,
menu_id INT NOT NULL,
module_id INT NOT NULL,
position INT NULL,
client_id UUID NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (menu_id) REFERENCES master_menus(id) ON DELETE CASCADE,
FOREIGN KEY (module_id) REFERENCES master_modules(id) ON DELETE CASCADE,
UNIQUE(menu_id, module_id, client_id)
);
CREATE INDEX idx_menu_modules_menu_id ON menu_modules(menu_id);
CREATE INDEX idx_menu_modules_module_id ON menu_modules(module_id);
CREATE INDEX idx_menu_modules_client_id ON menu_modules(client_id);
CREATE INDEX idx_menu_modules_is_active ON menu_modules(is_active);
COMMENT ON TABLE menu_modules IS 'Relasi many-to-many antara menu dan modul';
COMMENT ON COLUMN menu_modules.position IS 'Urutan modul dalam menu';
-- Step 3: Create user_level_module_accesses table
CREATE TABLE IF NOT EXISTS user_level_module_accesses (
id SERIAL PRIMARY KEY,
user_level_id INT NOT NULL,
module_id INT NOT NULL,
can_access BOOLEAN DEFAULT TRUE,
client_id UUID NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_level_id) REFERENCES user_levels(id) ON DELETE CASCADE,
FOREIGN KEY (module_id) REFERENCES master_modules(id) ON DELETE CASCADE,
UNIQUE(user_level_id, module_id, client_id)
);
CREATE INDEX idx_user_level_module_accesses_user_level_id ON user_level_module_accesses(user_level_id);
CREATE INDEX idx_user_level_module_accesses_module_id ON user_level_module_accesses(module_id);
CREATE INDEX idx_user_level_module_accesses_client_id ON user_level_module_accesses(client_id);
CREATE INDEX idx_user_level_module_accesses_is_active ON user_level_module_accesses(is_active);
COMMENT ON TABLE user_level_module_accesses IS 'Mengatur akses user_level ke modul-modul tertentu';
COMMENT ON COLUMN user_level_module_accesses.can_access IS 'Apakah user level ini boleh akses modul ini';
-- Step 4: Migrate existing data (optional)
-- Copy existing menu.module_id relationship to menu_modules
INSERT INTO menu_modules (menu_id, module_id, position, client_id, is_active, created_at, updated_at)
SELECT
id as menu_id,
module_id,
1 as position,
client_id,
is_active,
created_at,
updated_at
FROM master_menus
WHERE module_id IS NOT NULL
ON CONFLICT (menu_id, module_id, client_id) DO NOTHING;
-- Success message
DO $$
BEGIN
RAISE NOTICE 'Migration completed successfully!';
RAISE NOTICE 'Tables created: menu_modules, user_level_module_accesses';
RAISE NOTICE 'Column added: master_modules.action_type';
END $$;
🧪 Testing the System
Test 1: Setup Test Data
-- Create test modules
INSERT INTO master_modules (name, description, path_url, action_type, status_id) VALUES
('Test View', 'Test view module', '/test/view', 'view', 1),
('Test Create', 'Test create module', '/test/create', 'create', 1),
('Test Edit', 'Test edit module', '/test/edit', 'edit', 1);
-- Create test menu
INSERT INTO master_menus (name, description, module_id, status_id) VALUES
('Test Menu', 'Test menu for module access', 1, 1);
-- Link modules to menu
INSERT INTO menu_modules (menu_id, module_id, position) VALUES
(1, 1, 1),
(1, 2, 2),
(1, 3, 3);
-- Grant access to user level
INSERT INTO user_level_module_accesses (user_level_id, module_id, can_access) VALUES
(1, 1, true), -- Level 1 can view
(1, 2, true), -- Level 1 can create
(2, 1, true); -- Level 2 can only view
Test 2: Check Access via API
# Check if user level 1 can access module 1 (should return true)
curl -X POST http://localhost:3000/api/user-level-module-accesses/check-access \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"user_level_id": 1,
"module_id": 1
}'
# Check if user level 2 can access module 2 (should return false)
curl -X POST http://localhost:3000/api/user-level-module-accesses/check-access \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"user_level_id": 2,
"module_id": 2
}'
📚 Best Practices
- Granular Modules: Pisahkan setiap action menjadi modul terpisah untuk kontrol akses yang lebih baik
- Action Types: Gunakan action_type konsisten:
view,create,edit,delete,approve,export - Batch Operations: Gunakan batch endpoints untuk mengatur banyak akses sekaligus
- Middleware Layers: Kombinasikan dengan middleware lain (auth, CSRF, rate limit)
- Audit Trail: Log setiap akses yang ditolak untuk security monitoring
- Default Deny: Jika tidak ada record di
user_level_module_accesses, default adalah tidak ada akses
🎯 Benefits
✅ Kontrol Akses Granular - Atur akses hingga level action (view, create, edit, delete)
✅ Fleksibel - Mudah menambah/mengurangi modul tanpa mengubah kode
✅ Scalable - Support multi-client/tenant
✅ Maintainable - Struktur yang jelas dan terorganisir
✅ Secure - Middleware otomatis block unauthorized access