Role-Based Access Control di ERP Kustom: NestJS Guards, Tabel Izin PostgreSQL, dan Kepatuhan UU PDP

Foto oleh Unsplash
Tim memilih untuk tidak mengimplementasikan warisan peran agar model izin tetap eksplisit dan sepenuhnya dapat diaudit. Setiap izin ditetapkan langsung ke peran, sehingga tidak ada rantai hak tersembunyi yang bisa memberikan akses tak terduga. Pendekatan ini memudahkan untuk menjawab pertanyaan 'siapa tepatnya yang bisa menyetujui voucher pembayaran?' tanpa harus menelusuri pohon izin.
Tanpa cache, setiap permintaan API melakukan join database penuh untuk menyelesaikan izin pengguna, menambahkan latensi 5–10 ms per panggilan — latensi yang berlipat ketika satu halaman membuat lebih dari sepuluh panggilan API. Redis menyimpan cache set izin yang telah diselesaikan dengan TTL lima menit, sehingga pemeriksaan berikutnya membutuhkan waktu kurang dari 1 ms. Trade-off-nya adalah ada jendela singkat di mana perubahan peran belum tercermin; cache harus dibatalkan segera saat peran pengguna diperbarui.
UU PDP Indonesia (UU No. 27 Tahun 2022) mewajibkan organisasi untuk membuktikan bahwa akses ke data pribadi — termasuk data karyawan, informasi gaji, dan detail kontak pelanggan — dibatasi hanya bagi mereka yang memiliki kebutuhan sah. Kolom granted_by dan granted_at di tabel user_roles menciptakan catatan bertanda waktu untuk setiap penugasan peran, menyediakan bukti audit yang diharapkan oleh regulator.
NestJS guards menerapkan aturan akses di lapisan aplikasi, tetapi bug atau miskonfigurasi aplikasi dapat membypass-nya. Kebijakan RLS PostgreSQL diterapkan di tingkat database dan tetap aktif terlepas dari perilaku aplikasi. Untuk ERP multi-cabang — di mana misalnya invoice cabang Jakarta tidak boleh terlihat oleh staf cabang Surabaya — RLS memberikan batas penegakan yang tidak bisa ditembus oleh kesalahan lapisan aplikasi mana pun.
Developer tidak boleh diberikan akses SUPER_ADMIN ke database produksi. Post ini merekomendasikan penggunaan peran database read-only untuk debugging, mewajibkan proses persetujuan formal untuk setiap akses tulis produksi, dan merotasi kredensial segera setelah setiap penggunaan. Ini secara langsung mengatasi apa yang digambarkan sebagai kegagalan kontrol akses paling umum dalam proyek ERP kustom.

Foto oleh Unsplash
Kontrol akses adalah salah satu aspek paling kritis dan paling sering kurang dirancang dalam ERP kustom. Dalam pekerjaan konsultasi ERP freelance saya, kontrol akses saya rancang dari hari pertama sebagai perhatian utama, bukan ditambahkan belakangan.
Saya menggunakan model RBAC standar: pengguna ditugaskan satu atau lebih peran, peran memiliki izin, dan izin adalah pasangan (resource, action). Izin untuk membuat invoice adalah resource='invoice', action='create'.
Resource memetakan ke entitas ERP utama: 'invoice', 'leave_request', 'ap_voucher', 'employee', 'customer', 'vendor', 'report'. Aksi adalah: 'create', 'read', 'update', 'delete', 'approve', 'reject', 'void', 'export'. Tidak semua aksi berlaku untuk semua resource.
Saya mendefinisikan empat level peran: SUPER_ADMIN (akses penuh), FINANCE_MANAGER (akses AR/AP penuh), FINANCE_STAFF (buat dan baca invoice, tanpa persetujuan), dan EMPLOYEE (buat permintaan cuti, baca hanya catatan sendiri).
ERP RBAC Data Model (PostgreSQL)
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ users │──────►│ user_roles │◄──────│ roles │
│─────────────│ N:M │──────────────────│ N:M │──────────────│
│ id │ │ user_id (FK) │ │ id │
│ name │ │ role_id (FK) │ │ name │
│ email │ │ granted_by │ │ description │
│ dept_id │ │ granted_at │ └──────┬───────┘
│ is_active │ └──────────────────┘ │
└─────────────┘ │ N:M
▼
┌────────────────────┐
│ role_permissions │
│────────────────────│
│ role_id (FK) │
│ permission_id (FK) │
└──────────┬─────────┘
│
▼
┌────────────────────┐
│ permissions │
│────────────────────│
│ id │
│ resource (e.g. │
│ 'invoice') │
│ action (e.g. │
│ 'create','read', │
│ 'approve','void')│
└────────────────────┘
Role Hierarchy Example:
SUPER_ADMIN > FINANCE_MANAGER > FINANCE_STAFF > EMPLOYEECache pencarian izin di Redis dengan TTL singkat (5 menit). Dengan cache Redis, pemeriksaan izin menambahkan kurang dari 1ms setelah permintaan pertama. Batalkan cache segera ketika peran pengguna berubah.
Skema RBAC terdiri dari empat tabel: users, roles, permissions, user_roles, dan role_permissions. Saya menambahkan kolom granted_by dan granted_at ke user_roles sehingga selalu ada audit trail tentang siapa yang menugaskan peran mana.
RBAC guard menggunakan antarmuka CanActivate NestJS. Ia membaca dekorator @RequirePermission(resource, action), mengekstrak ID pengguna dari JWT, dan memeriksa PermissionService. Jika pengguna tidak memiliki izin, ia melempar ForbiddenException dengan pesan spesifik.
// NestJS: Custom RBAC guard with PostgreSQL permission lookup
// 1. Permission decorator
export const RequirePermission = (resource: string, action: string) =>
SetMetadata('permission', { resource, action });
// 2. Guard implementation
@Injectable()
export class RbacGuard implements CanActivate {
constructor(
private reflector: Reflector,
private permissionService: PermissionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const permission = this.reflector.get<{ resource: string; action: string }>(
'permission',
context.getHandler(),
);
if (!permission) return true; // No permission required
const request = context.switchToHttp().getRequest();
const userId = request.user?.id;
if (!userId) throw new UnauthorizedException();
const hasAccess = await this.permissionService.userHasPermission(
userId,
permission.resource,
permission.action,
);
if (!hasAccess) {
throw new ForbiddenException(
`Missing permission: ${permission.resource}:${permission.action}`
);
}
return true;
}
}
// 3. Permission service with cached lookups
@Injectable()
export class PermissionService {
constructor(
@InjectRepository(UserRole) private userRoleRepo: Repository<UserRole>,
private cacheManager: Cache,
) {}
async userHasPermission(
userId: string,
resource: string,
action: string,
): Promise<boolean> {
const cacheKey = `perm:${userId}:${resource}:${action}`;
const cached = await this.cacheManager.get<boolean>(cacheKey);
if (cached !== undefined) return cached;
const result = await this.userRoleRepo.query(`
SELECT 1
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1
AND p.resource = $2
AND p.action = $3
LIMIT 1
`, [userId, resource, action]);
const hasAccess = result.length > 0;
await this.cacheManager.set(cacheKey, hasAccess, 300); // 5 min TTL
return hasAccess;
}
}
// 4. Usage on controller
@Controller('invoices')
@UseGuards(JwtAuthGuard, RbacGuard)
export class InvoiceController {
@Post()
@RequirePermission('invoice', 'create')
create(@Body() dto: CreateInvoiceDto) { ... }
@Post(':id/approve')
@RequirePermission('invoice', 'approve')
approve(@Param('id') id: string) { ... }
@Post(':id/void')
@RequirePermission('invoice', 'void')
void(@Param('id') id: string) { ... }
}UU PDP Indonesia (UU No. 27 Tahun 2022) membebankan kewajiban pada organisasi yang memproses data pribadi. RBAC adalah kontrol kunci untuk kepatuhan UU PDP — Anda harus dapat menunjukkan bahwa akses ke data pribadi dibatasi hanya bagi mereka yang memiliki kebutuhan yang sah.
Jangan berikan semua pengembang akses SUPER_ADMIN ke database produksi. Gunakan peran database read-only untuk debugging, wajibkan proses persetujuan formal untuk akses tulis produksi, dan rotasi kredensial segera setelah setiap penggunaan.
Jika ERP melayani beberapa cabang yang seharusnya tidak melihat data satu sama lain, Anda memerlukan row-level security selain RBAC. PostgreSQL mendukung kebijakan RLS di tingkat database — ini diberlakukan bahkan jika lapisan aplikasi memiliki bug.
Logika kontrol akses harus memiliki cakupan test yang komprehensif. Untuk setiap endpoint yang dilindungi, tulis test untuk pengguna dengan peran yang benar, pengguna dengan peran yang tidak cukup (403), permintaan tidak diautentikasi (401), dan SUPER_ADMIN selalu bisa mengakses.