An ERP system contains your company's most sensitive data: payroll figures, vendor contracts, bank account details, customer credit terms, and complete financial records. Get the permissions design wrong and you have a compliance risk, a fraud risk, and a data breach risk all in one. Compliance frameworks like SOC 2, ISO 27001, and — increasingly important for Indonesian businesses — the requirements of UU No. 27 Tahun 2022 (Undang-Undang Perlindungan Data Pribadi) all require controlled access and role-based permissions. This post covers how to design ERP security that satisfies auditors and protects your business.
Least privilege is the foundational principle: every user should have access only to the data and functions needed to do their job, and no more. In practice, this means a warehouse staff member can view inventory records but cannot see payroll data. A finance approver can approve payment vouchers but cannot delete records. An HR manager can see all employee data but cannot access financial records. The challenge in ERP design is that least privilege sounds simple but is complex to implement — it requires a complete map of every role, every resource, and every action before the system is built.
Start with the job functions, not the system permissions. For each job function in the business, ask: what data does this role need to read? What data does it need to create? What data does it need to modify? What data does it need to delete? Never? Design the role hierarchy from these answers. In a typical Indonesian SME ERP: System Admin (all access), Finance Manager (all finance modules, view-only HR), Finance Staff (create and read AP/AR, no delete, no approval), Finance Approver (read AP/AR, approve payments, no create), HR Manager (all HR, view-only finance), Employee (own profile, own leave requests, own payslips — nothing else). Each role is narrower than the one above it.
Separation of duties (SoD) ensures that no single user can complete a financially significant transaction alone. The classic example: the person who enters a vendor invoice should not be the same person who approves the payment. In ERP design, SoD rules are implemented as workflow constraints — the system enforces that an invoice cannot be approved by its creator. For Indonesian businesses, SoD is increasingly required by internal audit standards and is a key control that external auditors will test. Build SoD into the system design, not as a policy memo that depends on users following rules voluntarily.
ERP RBAC Permission Matrix (Indonesian SME)
║ EMPLOYEE │ MGR │ FIN │ FIN │ HR │ SYS ║
RESOURCE ║ │ │ STAFF │ APPROVER│ ADMIN│ ADMIN║
══════════════════╬══════════╪═══════╪═══════╪════════╪═══════╪══════╣
Own Leave Request ║ CRUD │ R │ R │ │ CRUD │ CRUD ║
Team Leave Status ║ │ R │ │ │ CRUD │ CRUD ║
Leave Approval ║ │ APPROV│ │ │ APPROV│ ALL ║
──────────────────╫──────────┼───────┼───────┼────────┼───────┼──────╢
AP Invoice ║ │ │ CR │ R │ │ CRUD ║
AP Approval ║ │ APPROV│ │ APPROV │ │ ALL ║
AP Payment ║ │ │ R │ APPROV │ │ CRUD ║
──────────────────╫──────────┼───────┼───────┼────────┼───────┼──────╢
AR Invoice ║ │ │ CR │ R │ │ CRUD ║
AR Collections ║ │ │ CR │ R │ │ CRUD ║
──────────────────╫──────────┼───────┼───────┼────────┼───────┼──────╢
Salary Data ║ Own only │ │ │ │ CRUD │ CRUD ║
All Employee Data ║ │ Dept │ │ │ CRUD │ CRUD ║
──────────────────╫──────────┼───────┼───────┼────────┼───────┼──────╢
System Config ║ │ │ │ │ │ CRUD ║
User Management ║ │ │ │ │ │ CRUD ║
Audit Logs ║ │ │ │ │ R │ CRUD ║
CRUD = Create, Read, Update, Delete
R = Read only
APPROV = Can approve/reject (not create/delete)
Dept = Can see only their department's data
Own only = Can only see their own record
SoD Rules enforced by system (not policy):
• AP invoice creator ≠ AP approver (same person blocked)
• Payment amount > Rp 25M requires BOTH Manager + Finance Approver
• Audit log entries are immutable (no UPDATE or DELETE)From my experience implementing ERPs at Commsult: create a permission matrix before you build the system. The matrix has roles as columns and system actions as rows. Each cell is Allow, Deny, or Conditional (with the condition specified). Review the permission matrix with the internal audit function before development starts. Catching a separation-of-duties gap in a spreadsheet costs nothing. Catching it in a production system means reworking workflows and re-training users.
Module-level access (can this user access the AP module?) is the first layer of security. But some data requires finer control. Field-level security restricts which fields within a record a user can see or edit — a Finance Staff member can see vendor name and invoice amount but not the vendor's bank account details. Record-level security restricts which records a user can access within a module — a branch manager can see only the transactions for their branch, not all branches. Implementing both levels adds complexity to the system design but is essential for businesses with sensitive data or multi-branch operations.
// NestJS: Role-Based Access Control + Separation of Duties
// 1. Define roles enum
export enum UserRole {
EMPLOYEE = 'employee',
MANAGER = 'manager',
FINANCE_STAFF = 'finance_staff',
FINANCE_APPROVER = 'finance_approver',
HR_ADMIN = 'hr_admin',
SYSTEM_ADMIN = 'system_admin',
}
// 2. Custom decorator for role-based route protection
export const Roles = (...roles: UserRole[]) =>
SetMetadata('roles', roles);
// 3. Guard enforces roles at API level
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
'roles',
[context.getHandler(), context.getClass()]
);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some(role => user.roles?.includes(role));
}
}
// 4. SoD enforcement: AP invoice approval
@Patch(':id/approve')
@Roles(UserRole.MANAGER, UserRole.FINANCE_APPROVER)
async approveInvoice(@Param('id') id: string, @CurrentUser() user: User) {
const invoice = await this.apService.findOne(id);
// SoD check: creator cannot approve their own invoice
if (invoice.createdById === user.id) {
throw new ForbiddenException(
'You cannot approve an invoice you created (separation of duties)'
);
}
return this.apService.approve(id, user.id);
}
// 5. Field-level security: salary data
@Get(':id')
async getEmployee(@Param('id') id: string, @CurrentUser() user: User) {
const employee = await this.hrService.findOne(id);
// Non-HR roles cannot see salary fields
if (!user.roles.includes(UserRole.HR_ADMIN) &&
!user.roles.includes(UserRole.SYSTEM_ADMIN)) {
const { baseSalary, bankAccount, ...safeFields } = employee;
return safeFields;
}
return employee;
}Permissions control what users can do. Audit trails record what they actually did. A complete ERP audit trail captures: who performed the action (user ID and name), what action was performed (CREATE, UPDATE, DELETE), what was changed (before and after values for UPDATE actions), when it happened (timestamp with timezone), and from where (IP address, device). Audit trails are non-negotiable for financial data — they're required for tax audit defense, fraud investigation, and PDP Law compliance. Implement audit logging at the database level, not just the application level, so that direct database changes are also captured.
In almost every ERP system I've audited, there are more admin accounts than necessary, and those admin accounts are used for daily work. An admin account doing routine data entry can accidentally (or intentionally) modify system configuration, delete records, or bypass workflows. Best practice: every person has exactly one account with least-privilege permissions for their role. Admin accounts are separate, named accounts (not shared 'admin' logins) used only for system administration, with every session logged. The number of admin accounts should equal the number of people whose job requires admin access — which is usually 1–2 for a typical SME.
Permissions designed at go-live will drift from reality. Staff change roles. Temporary access granted for a project never gets revoked. Former employees' accounts remain active. Quarterly access reviews — where every user's permissions are reviewed and certified by their manager — are the standard control. The review asks: is this user's role still correct for their current job? Do they still work here? Are there any permissions that look broader than their role requires? Act on the findings immediately — disable former employee accounts, correct wrong role assignments, and document the review as an audit control.
Indonesia's UU PDP (Undang-Undang Perlindungan Data Pribadi, No. 27 Tahun 2022) establishes requirements for how personal data is accessed and protected. For ERP systems containing employee personal data (name, address, NPWP, salary, bank account, health information for BPJS), access must be limited to those with a legitimate need, access must be logged, and data subjects have rights to know who has accessed their data. Your ERP's RBAC design needs to be documented and defensible to a data protection audit. This means: written role definitions, a permission matrix, quarterly access reviews, and audit logs retained for the legally required period.