RBAC: Role-Based Access Control

13 min readIntermediate
RBACAuthorizationSecurityBackend

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:

RoleContoh hak akses
ADMINMengelola semua pengguna, mengubah konfigurasi sistem, menghapus konten
MODERATORMeninjau konten, menonaktifkan akun bermasalah, tetapi tidak menyentuh konfigurasi
USERMembuat 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 akun hendro.
  • Role (peran) — wadah semantik yang menggambarkan fungsi, misalnya ADMIN, MODERATOR, USER.
  • Permission (izin) — aksi spesifik yang boleh dilakukan, misalnya ORDER:UPDATE atau USER: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.

AspekRole langsung (hasRole)Berbasis permission (hasAuthority)
Kemudahan setupTinggiSedang (butuh tabel permission)
Fleksibilitas kebijakanRendah — ubah hak = ubah kodeTinggi — ubah hak = ubah data
AuditabilitySulit melacak "siapa boleh apa" per aksiMudah — permission granular & tercatat
GranularitasKasar (per role)Halus (per aksi)
Cocok untukPrototipe, tim kecil, aturan statisAplikasi 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:

AnotasiAsalGaya ekspresiContoh
@PreAuthorizeSpring SecuritySpEL penuh — bisa baca argumen method@PreAuthorize("hasRole('ADMIN')")
@SecuredSpring SecurityDaftar role/authority sederhana@Secured("ROLE_ADMIN")
@RolesAllowedStandar 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 pemeriksaan ROLE_ADMIN.
  • hasAuthority("ORDER:UPDATE") memeriksa string persis ORDER: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_ di collectAuthorities, atau sebaliknya, memeriksa hasAuthority("ADMIN") padahal yang disimpan ROLE_ADMIN. Satu string yang tidak cocok, hasilnya 403 Forbidden yang 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:

  1. Prinsip least privilege — berikan role sekecil yang dibutuhkan untuk bekerja. Role USER jangan diam-diam memuat permission USER:DELETE. Sebaiknya kita menambahkan permission nanti daripada menguranginya di kemudian hari.
  2. 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).
  3. 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.
  4. 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.
  5. 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
}
AspekRBACABAC
Dasar keputusanPeran userAtribut user + resource + lingkungan
Contoh aturanAdmin boleh hapus userPemilik boleh edit order DRAFT miliknya
GranularitasKasar (per role)Halus (per resource/atribut)
Mudah dikelolaYaLebih rumit; butuh engine policy (XACML, OPA)
Sumber kebenaranTabel relasiPolicy/aturan terpusat
Cocok untukMayoritas aplikasiDomain 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:

KesalahanGejalaPerbaikan
Cek role manual di controller (if (user.getRole() == ...))Aturan tersebar di puluhan tempat, satu terlewat = lubang keamananPindahkan ke authorizeHttpRequests / @PreAuthorize
Lupa @EnableMethodSecurityAnotasi @PreAuthorize diabaikan diam-diam, semua user lolosTambahkan @EnableMethodSecurity di config
Role di token tidak sinkron dengan databaseUser di-demote dari ADMIN tapi masih bisa akses sampai token kedaluwarsaMuat authority dari database per request, atau perpendek umur token
Salah prefix ROLE_403 misterius padahal aturan tampak benarKonsisten: ROLE_ untuk hasRole, tanpa prefix untuk hasAuthority
Allow by defaultEndpoint baru terbuka tanpa disadarianyRequest().denyAll() sebagai penutup
Permission diberi ke role USER secara tidak sengajaUser biasa bisa mengakses fitur terbatasAudit 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

← Back to technical articles