guide →  ·  atau Space · tekan E edit
Mode edit aktif · klik teks untuk mengubah ·
01 / 15
Arsitektur Keamanan · Odoo 19

Bagaimana Odoo
membatasi akses?

RBAC, Record Rules & Row-Level Security — menelusuri jalur dari seorang user yang klik menu sampai ke baris SQL yang benar-benar dikembalikan.

sessionsiapa user?
groupsperan efektif
ACLboleh model?
rulebaris mana?
WHERESQL filter
02 / 15
Pertanyaan Inti

Dua user, satu Odoo.
Kenapa pengalamannya berbeda?

USER A · sales manager
  • Lihat menu Invoicing & Sales di sidebar
  • Bisa buka account.move & crm.lead
  • Hanya lihat 50 faktur — milik tim-nya sendiri
  • Faktur perusahaan lain tidak muncul sama sekali
USER B · portal customer
  • Menu Invoicing hilang dari layar
  • Tidak bisa buka account.move (AccessError)
  • Hanya lihat faktur miliknya via portal
  • Kolom harga & biaya tidak terlihat

Bukan sihir. Setiap request melewati 5 lapisan yang bekerja otomatis — beberapa di database, beberapa cuma di UI.

03 / 15
Konsep Dasar

RBAC = User → Role → Permission

Model NIST RBAC: user diberi role, role diberi permission berupa pasangan (object, operation). Tapi Odoo punya jebakan terminologi:

Istilah OdooPadanan RBAC standar
res.usersUser
res.groupsRole (meski namanya "group")
implied_idsRole hierarchy / graph
ir.model.access + ir.rulePermission
res.groups.privilegelabel UI saja BUKAN permission
all_group_idsEffective role set (closure)
⚠ Jebakan

Di Odoo, "group" berfungsi sebagai role, dan "privilege" cuma kategori untuk form user — tidak ada enforcement. Jangan tertukar saat implementasi ulang.

// rantai yang benar di Odoo:
res.users → res.groups → [ir.model.access, ir.rule, field.groups] ↑ permission sungguhan ada di sini
04 / 15
Peta Lintas-Lapisan

Lima lapisan yang membatasi setiap request

L1Authentication res.users._check_credentials · session token HMAC"Siapa user ini?"
L2Users & Groups res.groups · implied_ids → all_group_ids"Apa peran efektifnya?"
L3ACL — model-level ir.model.access (read / write / create / unlink)"Boleh sentuh model ini?"
L4Record Rules — row-level ir.rule · domain_force → SQL WHERE"Baris mana yang boleh?"
L5Field + Menu/View field.groups= · ir.ui.menu.group_ids"Kolom & UI mana?"

L3 + L4 ditegakkan di ORM & SQL (security sungguhan). L5 menu/view sebagian besar cuma UX — data tetap diamankan oleh L3/L4. sudo() melewati L3–L5 sekaligus.

05 / 15
Lapisan 1 · Authentication

Setiap request: siapa kamu?

Sebelum bicara peran atau izin, Odoo harus membuktikan identitas user di setiap request — bukan sekadar saat login.

  • Password di-hash (passlib pbkdf2_sha512), tidak pernah plaintext
  • session token = HMAC over field identitas + database.secretstateless
  • Ubah password → semua session logout otomatis (token di-recompute)
  • Per route: auth method menentukan kebijakan
nonetanpa identitas
publicanon visitor
userwajib login
bearerAPI key
Kontrak ekstensi
# pluggable: password / TOTP / OAuth / API key
def _check_credentials(cred):
  # cek password, lalu return auth_info
  return {'uid': uid,
          'auth_method': 'password',
          'mfa': 'default'}

# per-request:
check_session(sid)  # recompute HMAC
if not consteq(tok, exp): logout

extension 2FA/OAuth/WebAuthn hanya override _check_credentials — tak perlu sentuh login flow.

06 / 15
Lapisan 2 · Users & Groups

Explicit vs effective group set

Seorang user punya dua versi keanggotaan group:

  • group_ids — group yang eksplisit di-assign (M2M)
  • all_group_ids — closure: group_ids + semua yang di-imply

Setiap cek downstream (ACL, rule, menu) hanya membaca all_group_ids, tidak pernah group_ids mentah.

res.users (id=5)explicit: group_ids = [Sales]
↓ compute closure
all_group_ids[Sales, User, Employee, Multi-Company]
↓ cached
@ormcache(uid) def _get_group_ids(self):
  return self.all_group_ids._ids
07 / 15
Lapisan 2 · Role Inheritance

implied_ids — sebuah DAG peran

Group bisa meng-imply group lain: "setiap anggota Manager otomatis juga User".

# base/security/base_groups.xml
group_system → group_erp_manager → group_user
(Administrator implies Access-Rights implies User)
  • all_implied_ids = reflexive transitive closure
  • Disjoint: user-type group saling eksklusif (internal/portal/public)
  • Dihitung via SetDefinitions (aljabar himpunan, di-cache global)
Closure visual
Administrator
└─ implies
Access Rights
└─ implies
User (internal)
+ Employee, Multi-Company, ...

Anggota Administrator otomatis dapat semua permission di rantai bawahnya.

08 / 15
Lapisan 3 · ACL (model-level)

ir.model.access — "bolehkah sentuh model ini?"

Tiap baris ACL = (model, group, r/w/c/d). Sebuah user boleh mode pada model jika salah satu group efektifnya punya grant.

# security/ir.model.access.csv
"access_inv_user",account.move,group_account_invoice,1,1,0,0
  • OR atas group efektif → satu grant cukup
  • Hasil di-cache: _get_allowed_models(uid, mode)
  • Ditolak sebelum record rule sempat jalan
Algoritma evaluasi
def check(model, mode):
  if env.su: return True  # bypass
  groups = user._get_group_ids()  # closure
  return model in _get_allowed_models(uid, mode)
readbaca
writeubah
createbuat
unlinkhapus

User B (portal) tidak punya baris ACL untuk account.moveAccessError instan.

09 / 15
Lapisan 4 · Record Rules (row-level)

ir.rule — "baris mana yang boleh dilihat?"

Inilah jantungnya. Tiap rule punya domain_force — sebuah filter — yang disuntikkan langsung ke klausa WHERE di setiap pencarian.

base_security.xml # record rule: faktur per user
[&..., ('user_id', '=', user.id)]

# multi-company isolation (GLOBAL)
[('company_id', 'in', company_ids)]

↓ safe_eval + Domain AST + _to_sql
SQL yang dihasilkan SELECT id, name, amount
FROM account_move
WHERE user_id = 5
  AND company_id IN (1, 3)
  AND -- user's own domain
      state = 'posted'

/* baris perusahaan lain TIDAK
  pernah dikembalikan DB */

kunci Forbidden row tidak "dihabis setelahnya" — domain sudah masuk WHERE. Tidak ada post-filter.

10 / 15
Lapisan 4 · Aturan Kombinasi

Global AND, Group OR

Ini sifat paling penting & paling sering disalahpahami. Beberapa rule untuk user+model digabung jadi satu domain:

Hukum kombinasi
final = global₁ AND global₂ AND ... AND
      (group_a₁ OR group_a₂ OR ...)
  • global (tanpa group) → WAJIB, berlaku untuk semua user
  • group → OR di antaranya (permissive)
  • Bucket group lalu di-AND dengan global
Konsekuensi

Karena global di-AND, rule group tidak pernah bisa melemahkan rule global.

global: company_id ∈ [1,3]
group: user_id = 5 (lebih longgar)
final: company_id ∈ [1,3] AND (user_id=5)

Itulah kenapa isolasi multi-company tidak bisa di-bypass dengan memberi role tambahan — syarat global tetap berlaku.

11 / 15
Lapisan 5 · Field Security

groups= — membatasi per kolom

Field bisa ditandai hanya boleh dibaca/ditulis oleh group tertentu. Ditegakkan di server, bukan cuma di UI.

smtp_pass = fields.Char(
  groups='base.group_system')

request = fields.Char(
  groups=fields.NO_ACCESS# selalu dilarang
  • _check_field_access dipanggil di read & write
  • fields_get menyembunyikan field terlarang dari metadata UI
UI vs security sungguhan
MekanismeTipe
field groups=server enforce
view node groups=UI strip
menu groupsUX only

User B tidak melihat kolom harga karena dua hal: view node di-strip dan field-nya juga punya groups=. Bekasnya cuma UX, keduanya diamankan di server.

12 / 15
Lapisan 5 · Menu & View

Visibilitas menu = UX, bukan security

Menu terlihat jika group_ids-nya kosong atau beririsan dengan peran user, dan model action-nya boleh dibaca (ACL).

# _visible_menu_ids
if group_ids ∩ user.all_group_ids:
  and access.check(action.model, 'read')
  • Folder menu terlihat iff ada anak yang terlihat
  • View node groups= di-strip server-side
⚠ Penting

Menu yang disembunyikan tidak melindungi data. User yang tahu URL action tetap bisa invoke — tapi kemudian ACL & record rule akan menolak datanya.

hide menu  → UX only
strip view nodeUX only
model ACL    → real security
record rule  → real security

Jangan pernah andalkan "user tidak bisa lihat tombol" untuk keamanan.

13 / 15
Menyatukan Semua

User klik menu → kenapa record hilang?

1
Request masuk — session divalidasi ulang: check_session() bandingkan HMAC token (constant-time).
2
Auth method_auth_method_user() pastikan uid nyata, bukan public.
3
load_menus_visible_menu_ids() saring menu by group efektif + ACL action.
4
User klik → controller → Model.search(domain)
5
ACL gateir.model.access.check(model,'read'). Ditolak? AccessError.
6
Rule domainir.rule._compute_domain() → gabungkan global AND + group OR.
7
Suntik ke WHERE — domain → SQL → satu query. Row asing tidak pernah dikembalikan.
_search() → Query SELECT id, name, amount
FROM account_move
WHERE -- domain user
  state = 'posted'
  AND -- record rule (global)
  company_id IN (1,3)
  AND -- record rule (group)
  (user_id=5 OR team_id=2)
ORDER BY date DESC;

→ User A lihat 50 baris. User B dapat AccessError di langkah 5.

14 / 15
Tombol Darurat

sudo() — satu flag, lewati semua

sudo() menyalakan env.su — diperiksa di setiap lapisan dan langsung di-short-circuit:

def sudo(self, flag=True):
  return self.with_env(env(su=flag))
  • ir.model.access.check → return True
  • ir.rule._get_rules → return kosong
  • _has_field_access → True
  • cek tenant allowed_company_ids → diskip
SUPERUSER_ID = 1

User id 1 (base.user_root) selalu superuser. Tidak bisa dinonaktifkan; harus ada ≥1 admin.

def with_user(self, user):
  return self.with_env(
    env(user=user, su=False))
  # kecuali user == SUPERUSER_ID

⚠ hati-hati sudo menyeberang batas perusahaan — bisa campur record terisolasi. Pakai hanya untuk operasi internal terpercaya.

15 / 15
Mental Model

Bukan sihir —
domain + SQL.

Setiap panggilan ORM melewati rantai yang sama. Hafalkan ini dan 90% "kenapa saya tidak bisa lihat X?" jelas.

userall_group_ids
ACLboleh model?
ruledomain → WHERE
fieldkolom ok?
menuUX saja
L3+L4 = security

ACL & rule ditegakkan di ORM + SQL. Row asing tidak pernah dikembalikan DB.

L5 = UX

Menu & view hiding cuma pengalaman. Data tetap diamankan oleh L3/L4.

sudo = bypass

Satu flag lewati semua, menyeberang perusahaan. Pakai terukur.

→ Read the practical guide (debug, write rules, cheatsheet)

// full source-level docs: docs/security-architecture/