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

Foto oleh Unsplash

Foto oleh Unsplash
Kontrol akses adalah salah satu aspek paling kritis dan paling sering kurang dirancang dalam ERP kustom. Ketika saya membangun ERP untuk Commsult Indonesia, kontrol akses dirancang dari hari pertama sebagai perhatian utama, bukan ditambahkan belakangan.
Kami 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.
Kami 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. Kami 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.