RBAC: Role-Based Access Control
Bayangkan sebuah aplikasi yang punya halaman admin, halaman pengelolaan order, dan halaman profil biasa. Apakah masuk akal mengatur izin satu per satu untuk setiap pengguna? Untuk seratus pengguna mungkin masih, tapi untuk sepuluh ribu pengguna ini akan jadi mimpi buruk. Di sinilah RBAC (Role-Based Access Control) berperan: alih-alih menetapkan izin per individu, kita mengelompokkan pengguna ke dalam peran (role), lalu mengatur izin untuk setiap peran.
RBAC sudah menjadi standar de facto di hampir semua aplikasi web modern. Konsepnya sederhana tapi sangat kuat, dan inilah artikel yang mengupasnya dari fondasi sampai implementasi nyata di Spring Security, lengkap dengan skema database dan cara memutuskan kapan RBAC tidak lagi cukup. Referensi konsep awalnya bisa Anda baca di Wikipedia — Role-based access control.
Apa Itu RBAC?
RBAC adalah model otorisasi yang menentukan hak akses berdasarkan peran yang dipegang seorang pengguna, bukan berdasarkan identitas individunya. Sebelumnya kita sudah belajar bahwa authorization menjawab pertanyaan "kamu boleh apa?" (lihat Authentication vs Authorization). RBAC adalah salah satu jawaban paling populer untuk pertanyaan itu.
Perhatikan contoh nyata di hampir setiap aplikasi:
| Role | Contoh hak akses |
|---|---|
ADMIN | Mengelola semua pengguna, mengubah konfigurasi sistem, menghapus konten |
MODERATOR | Meninjau konten, menonaktifkan akun bermasalah, tetapi tidak menyentuh konfigurasi |
USER | Membuat dan membaca konten miliknya sendiri |
Keuntungan utamanya adalah pemeliharaan (maintainability). Ketika seorang karyawan baru bergabung, Anda cukup memberinya role USER dan semuanya langsung teratur. Ketika sebuah kebijakan berubah — misalnya "moderator kini boleh menghapus konten yang melanggar" — Anda cukup mengubah definisi role MODERATOR, bukan menelusuri ribuan akun. Ini persis yang dimaksud OWASP ketika menyarankan agar otorisasi dirancang secara terpusat dan tidak tersebar di banyak tempat: lihat OWASP Authorization Cheat Sheet.
Konsep Dasar: User, Role, Permission
Semua model RBAC dibangun dari tiga komponen inti yang saling terhubung:
User(pengguna) — entitas yang melakukan aksi, misalnya akunhendro.Role(peran) — wadah semantik yang menggambarkan fungsi, misalnyaADMIN,MODERATOR,USER.Permission(izin) — aksi spesifik yang boleh dilakukan, misalnyaORDER:UPDATEatauUSER:DELETE.
Relasi antar komponen itu membentuk rantai:
User ──(memiliki)──> Role ──(memetakan ke)──> Permission
│ │ │
hendro ADMIN USER:DELETE
siti MODERATOR POST:MODERATE
agus USER ORDER:READ
Satu user bisa punya banyak role (hendro = ADMIN + USER), satu role bisa memetakan ke banyak permission (ADMIN = USER:DELETE + ORDER:UPDATE + ...), dan satu permission bisa dimiliki banyak role. Ini relasi many-to-many di semua sisinya.
Kunci pemahaman: role adalah kumpulan permission. Ketika Anda memeriksa "apakah user ini boleh menghapus user lain?", pertanyaan sebenarnya adalah "apakah ada role yang ia pegang yang memuat permission
USER:DELETE?"
Dua Gaya Model: Role Langsung vs Berbasis Permission
Di lapangan ada dua cara menerjemahkan konsep di atas ke kode, dan keduanya punya trade-off masing-masing.
Gaya 1: Role diperiksa langsung
Paling sederhana — setiap endpoint bertanya "apa role-mu?" dan mencocokkannya:
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }
Di sini role ADMIN sekaligus berperan sebagai permission. Cepat ditulis, mudah dipahami, tapi kaku: jika nanti muncul kebutuhan "moderator juga boleh menghapus user", Anda harus mengubah kode dan menambah hasAnyRole('ADMIN','MODERATOR') — di setiap tempat yang sama-sama membutuhkan.
Gaya 2: Role berisi permission, permission yang dicek
Lebih umum di sistem produksi. User tetap dapat role, tetapi yang diperiksa adalah permission di dalam role:
@PreAuthorize("hasAuthority('USER:DELETE')")
public void deleteUser(Long id) { ... }
Perubahan kebijakan "moderator boleh hapus user" kini cukup dilakukan di data (tambahkan permission USER:DELETE ke role MODERATOR di database), tanpa menyentuh kode sama sekali.
| Aspek | Role langsung (hasRole) | Berbasis permission (hasAuthority) |
|---|---|---|
| Kemudahan setup | Tinggi | Sedang (butuh tabel permission) |
| Fleksibilitas kebijakan | Rendah — ubah hak = ubah kode | Tinggi — ubah hak = ubah data |
| Auditability | Sulit melacak "siapa boleh apa" per aksi | Mudah — permission granular & tercatat |
| Granularitas | Kasar (per role) | Halus (per aksi) |
| Cocok untuk | Prototipe, tim kecil, aturan statis | Aplikasi tumbuh, banyak role & kebijakan berubah |
Kebanyakan sistem yang saya temui di produksi memakai gaya kedua — RBAC di permukaan, permission di dalam. OWASP juga menekankan pentingnya membedakan "autentikasi" dan "otorisasi" sebagai lapisan terpisah; lihat kembali artikel Authentication vs Authorization untuk konteksnya.
Skema Database untuk RBAC
Ini bagian yang paling banyak ditanya: bagaimana menyimpan relasi user-role-permission di database relasional? Jawabannya adalah tabel join (penghubung). Kita butuh lima tabel.
+----------------+ +-------------+ +----------------+
| users | | user_roles | | roles |
+----------------+ +-------------+ +----------------+
| id (PK) |<--+ | user_id (FK)| +-->| id (PK) |
| username | | | role_id (FK)| | | name (UNIQUE) |
| password_hash | +----+-------------+ | +----------------+
| ... | | |
+----------------+ | |
| |
+----------------+ +-------------+ | |
| permissions | |role_permiss | | |
+----------------+ +-------------+ | |
| id (PK) |<--+ | role_id (FK) |<---+ |
| name (UNIQUE) | | | permission_id(FK) |
| description | +----+-------------+ |
+----------------+ |
|
users.roles → user_roles.role_id → roles.id |
roles.permissions → role_permissions.permission_id → permissions.id
Baca arah relasinya: users many-to-many dengan roles melalui tabel user_roles; roles many-to-many dengan permissions melalui tabel role_permissions. Tabel join menyimpan pasangan id saja, sehingga satu user boleh punya banyak role dan satu role boleh memuat banyak permission.
Entitas JPA: User, Role, Permission
Dalam JPA/Hibernate, relasi many-to-many dipetakan dengan anotasi @ManyToMany dan @JoinTable. Contohnya:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
// getter & setter dihilangkan agar ringkas
}
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
}
@Entity
@Table(name = "permissions")
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name; // misal "ORDER:UPDATE"
private String description;
}
FetchType.EAGER dipakai agar role dan permission ikut termuat ketika UserDetailsService mengambil user — ini menyederhanakan kode di bagian berikut, meskipun untuk data besar Anda mungkin ingin beralih ke lazy + query terpisah demi performa.
Implementasi di Spring Security
Sekarang mari hubungkan semuanya. Spring Security menyediakan dua lapis otorisasi yang bisa dikombinasikan, sesuai dokumentasi resmi authorize HTTP requests dan dokumentasi resmi method security.
URL-based: authorizeHttpRequests
Lapis pertama bekerja di filter chain dan mengecek URL request. Cocok untuk aturan global yang seragam.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/css/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/orders/**").hasAuthority("ORDER:READ")
.anyRequest().denyAll()
);
return http.build();
}
}
Urutan penting: aturan dievaluasi dari atas ke bawah, yang pertama cocok akan dipakai. .anyRequest().denyAll() di paling bawah memastikan semua yang tidak terdaftar otomatis ditolak — ini kebiasaan yang harus Anda tanam sejak awal.
Method-based: @PreAuthorize, @Secured, @RolesAllowed
Lapis kedua menempel langsung di method controller/service. Ini lebih presisi karena bisa mengakses argumen method. Ada tiga anotasi yang sering membingungkan pemula:
| Anotasi | Asal | Gaya ekspresi | Contoh |
|---|---|---|---|
@PreAuthorize | Spring Security | SpEL penuh — bisa baca argumen method | @PreAuthorize("hasRole('ADMIN')") |
@Secured | Spring Security | Daftar role/authority sederhana | @Secured("ROLE_ADMIN") |
@RolesAllowed | Standar JSR-250 (jakarta.annotation.security) | Daftar role (tanpa prefix) | @RolesAllowed("ADMIN") |
Perbedaan terbesar: @PreAuthorize mendukung SpEL sehingga bisa menulis aturan rumit seperti hasAuthority('ORDER:UPDATE') and #order.ownerId == principal.id — yang mustahil dilakukan @Secured maupun @RolesAllowed.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('ORDER:UPDATE')")
public Order updateOrder(@PathVariable Long id, @RequestBody OrderRequest request) {
return orderService.updateOrder(id, request);
}
}
Jangan lupa mengaktifkan method security. Tanpa ini, anotasi di atas diam-diam diabaikan:
@Configuration
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
public class MethodSecurityConfig {
}
Secara default @PreAuthorize/@PostAuthorize sudah aktif; @Secured dan @RolesAllowed baru aktif jika kita set securedEnabled dan jsr250Enabled ke true. Referensi lengkapnya di dokumentasi resmi method security.
Bagaimana Spring Menyimpan Authority: SimpleGrantedAuthority
Di balik layar, Spring Security tidak menyimpan objek role yang canggih — ia menyimpan sekadar string dalam GrantedAuthority, konkretnya SimpleGrantedAuthority. Inilah alasan mengapa perbedaan ROLE_ penting:
hasRole("ADMIN")diubah menjadi pemeriksaanROLE_ADMIN.hasAuthority("ORDER:UPDATE")memeriksa string persisORDER:UPDATE.
Jadi jika role Anda bernama ADMIN, Spring membandingkannya dengan authority ROLE_ADMIN. Inilah yang disebut dokumentasi resmi sebagai "ROLE_ prefix". Karena itulah dalam UserDetailsService kita menambahkan prefix secara eksplisit (lihat kode di bawah), lalu permission apa adanya.
Contoh Lengkap UserDetailsService
Ini potongan yang menyatukan semuanya: mengambil user dari database, lalu membungkus role-nya (dengan prefix ROLE_) dan permission-nya (tanpa prefix) menjadi daftar authority.
@Service
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public AppUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User tidak ditemukan: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(collectAuthorities(user))
.build();
}
private List<GrantedAuthority> collectAuthorities(User user) {
List<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : user.getRoles()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
for (Permission permission : role.getPermissions()) {
authorities.add(new SimpleGrantedAuthority(permission.getName()));
}
}
return authorities;
}
}
Benda yang paling sering bikin bug: lupa prefix
ROLE_dicollectAuthorities, atau sebaliknya, memeriksahasAuthority("ADMIN")padahal yang disimpanROLE_ADMIN. Satu string yang tidak cocok, hasilnya403 Forbiddenyang membingungkan.
Jika aplikasi Anda memakai JWT, authority ini biasanya ikut dibungkus ke dalam token saat login (seperti role dan permissions). Pastikan sinkron dengan database — jangan biarkan role di token bertahan lebih lama dari perubahan role di database. Ini salah satu topik yang akan kita bahas di JWT (JSON Web Token).
Aturan Best Practice
Beberapa kebiasaan yang menjaga otorisasi Anda tetap sehat dalam jangka panjang:
- Prinsip least privilege — berikan role sekecil yang dibutuhkan untuk bekerja. Role
USERjangan diam-diam memuat permissionUSER:DELETE. Sebaiknya kita menambahkan permission nanti daripada menguranginya di kemudian hari. - Sentralkan aturan — aturan akses hidup di security config dan anotasi, satu sumber kebenaran. Jangan menaruh
if (role == ...)di dalam controller. OWASP menegaskan hal serupa: otorisasi harus dilakukan di satu titik dan mudah diaudit (Authorization Cheat Sheet). - Hindari hardcode role di banyak tempat — jika Anda menulis
hasRole("ADMIN")di 40 method dan kebijakan berubah, Anda harus menyentuh 40 tempat. Lebih baik definisikan konstanta, atau periksa permission (hasAuthority) yang terpusat di data. - Waspadai role "super admin" yang bisa melakukan segalanya — ini menyederhanakan banyak hal, tetapi membuat batas akses menguap. Jika tetap dipakai, dokumentasikan secara eksplisit dan batasi siapa yang bisa memegangnya.
- Jangan menaruh logika otorisasi di query atau helper yang tersebar — aturan yang terpecah sulit diaudit dan mudah bolong.
Deny by Default vs Allow by Default
Ada dua sikap saat mengkonfigurasi otorisasi:
- Allow by default — semua endpoint terbuka kecuali yang ditandai terbatas. Cepat untuk prototipe, tapi berbahaya: satu endpoint yang lupa ditandai langsung bisa diakses siapa saja.
- Deny by default — semua tertutup kecuali yang dinyatakan bebas. Setiap endpoint baru butuh keputusan eksplisit, sehingga endpoint sensitif tidak pernah terbuka "tanpa sengaja".
Deny by default (aman)
/login, /register ──► permitAll (buka eksplisit)
/api/** ──► diatur per URL / method
sisa yang lain ──► denyAll
Allow by default (berisiko)
semua endpoint ──► terbuka
...yang lupa ditutup ──► TERBUKA tanpa disadari
Gunakan deny by default di produksi. Ini sejalan dengan rekomendasi OWASP agar setiap endpoint melakukan pemeriksaan otorisasi dan tidak mengandalkan "ketidakterlihatan" sebagai perlindungan (Authorization Cheat Sheet).
Kapan RBAC Kurang Cukup: Menuju ABAC
RBAC sangat bagus, tetapi punya keterbatasan fundamental: keputusan hanya berdasar siapa user-nya (role). Perhatikan contoh ini:
Aturan: "Seorang user hanya boleh mengedit order miliknya sendiri, dan hanya jika order itu masih berstatus
DRAFT."
Coba terjemahkan ke RBAC. Kita perlu permission ORDER:UPDATE, tapi permission itu tidak membedakan order siapa dan status apa. Jika semua pemilik order dapat ORDER:UPDATE, si A bisa mengedit order si B. Inilah batas RBAC — ia tidak punya kosakata untuk atribut resource.
Solusinya adalah ABAC (Attribute-Based Access Control): keputusan dibuat dari kumpulan atribut — atribut user (role, departemen), atribut resource (pemilik, status), dan atribut lingkungan (waktu, IP). Dengan ABAC, aturan di atas bisa dinyatakan sebagai policy:
izinkan(request) JIKA
request.user.id == order.ownerId
DAN order.status == "DRAFT"
DAN request.user MEMILIKI permission "ORDER:UPDATE"
Spring Security mendukung gaya ini lewat SpEL yang bisa membaca argumen method:
@PreAuthorize("hasAuthority('ORDER:UPDATE') and #order.ownerId == principal.id and #order.status == 'DRAFT'")
public Order updateOrder(@PathVariable Long id, @RequestBody OrderRequest request) {
// ... logika bisnis
}
| Aspek | RBAC | ABAC |
|---|---|---|
| Dasar keputusan | Peran user | Atribut user + resource + lingkungan |
| Contoh aturan | Admin boleh hapus user | Pemilik boleh edit order DRAFT miliknya |
| Granularitas | Kasar (per role) | Halus (per resource/atribut) |
| Mudah dikelola | Ya | Lebih rumit; butuh engine policy (XACML, OPA) |
| Sumber kebenaran | Tabel relasi | Policy/aturan terpusat |
| Cocok untuk | Mayoritas aplikasi | Domain dengan aturan kompleks (finansial, kesehatan, kepatuhan) |
ABAC bukan pengganti RBAC — ia biasanya dibangun di atas RBAC (tetap ada role dan permission sebagai atribut), lalu menambah dimensi atribut lain. Mulailah dengan RBAC, dan beralih bertahap ke ABAC hanya ketika aturan bisnis menuntutnya.
Kesalahan Umum
Berikut jebakan yang paling sering saya jumpai di code review:
| Kesalahan | Gejala | Perbaikan |
|---|---|---|
Cek role manual di controller (if (user.getRole() == ...)) | Aturan tersebar di puluhan tempat, satu terlewat = lubang keamanan | Pindahkan ke authorizeHttpRequests / @PreAuthorize |
Lupa @EnableMethodSecurity | Anotasi @PreAuthorize diabaikan diam-diam, semua user lolos | Tambahkan @EnableMethodSecurity di config |
| Role di token tidak sinkron dengan database | User di-demote dari ADMIN tapi masih bisa akses sampai token kedaluwarsa | Muat authority dari database per request, atau perpendek umur token |
Salah prefix ROLE_ | 403 misterius padahal aturan tampak benar | Konsisten: ROLE_ untuk hasRole, tanpa prefix untuk hasAuthority |
| Allow by default | Endpoint baru terbuka tanpa disadari | anyRequest().denyAll() sebagai penutup |
Permission diberi ke role USER secara tidak sengaja | User biasa bisa mengakses fitur terbatas | Audit tabel role_permissions secara berkala |
Contoh yang buruk — logika otorisasi manual di dalam controller:
// BURUK: otorisasi dibangun manual, tersebar, sulit diaudit
@DeleteMapping("/{id}")
public void deleteUser(@PathVariable Long id, Authentication auth) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
if (!isAdmin) {
throw new AccessDeniedException("Tidak boleh!");
}
userService.deleteUser(id);
}
Versi deklaratif yang sama, jauh lebih aman dan terbaca:
// BAIK: aturan dinyatakan sekali, terpusat, mudah diaudit
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
}
Ringkasan
RBAC menentukan akses berdasarkan peran, bukan individu: User memiliki Role, dan Role memetakan ke Permission. Di Spring Security, RBAC diwujudkan dengan authorizeHttpRequests untuk aturan berbasis URL, @PreAuthorize/@Secured/@RolesAllowed untuk aturan berbasis method, dan UserDetailsService yang memuat role (dengan prefix ROLE_) serta permission menjadi SimpleGrantedAuthority. Simpan relasi ini dalam tabel join (user_roles, role_permissions) agar perubahan kebijakan cukup mengubah data, bukan kode. Terapkan least privilege, deny by default, dan sentralkan semua aturan. Ketika keputusan bergantung pada atribut resource — seperti "pemilik boleh mengedit order DRAFT miliknya" — RBAC mulai kewalahan dan sudah waktunya melirik ABAC, yang dibangun di atas RBAC dengan menambahkan dimensi atribut.
Lanjut membaca
- Wikipedia — Role-based access control — sejarah dan terminologi formal model RBAC (RBAC0 sampai RBAC3)
- Dokumentasi resmi Spring Security — Method Security —
@PreAuthorize,@Secured,@RolesAlloweddan ekspresi SpEL - Dokumentasi resmi Spring Security — Authorize HTTP Requests — konfigurasi URL-based dan prefix
ROLE_ - OWASP Authorization Cheat Sheet — checklist praktik otorisasi yang aman
- Authentication vs Authorization — dasar dua tahap sebelum otorisasi terjadi
- Artikel terkait: Spring Security Filter Chain — tempat
authorizeHttpRequestsdieksekusi dalam rantai filter - Fondasi pendukung: JPA & Hibernate ORM untuk memetakan tabel RBAC ke entitas
- Jika ingin memperdalam granularity: Desain REST API yang Baik