This guide breaks down how Odoo protects your data and controls user permissions across your business. It explains how roles, permissions, and security rules work together to ensure employees only see and manage the specific records they need—keeping your business information safe and organized.
Odoo provides two main data-driven mechanisms to manage or restrict access to data without writing custom hardcoded logic. Both mechanisms link to users through User Groups (res.groups): a user belongs to any number of groups, and security mechanisms are attached to groups to govern permissions.
This guide provides a comprehensive breakdown of Odoo security architecture using a Hospital Management (custom_hospital_management) module as an example.
User groups define roles and serve as the foundation for both Access Rights and Record Rules.
Access Control Lists (ACLs) grant access to an entire model for a given set of operations (Create, Read, Update, Delete). If no access right matches an operation on a model for a user (through their assigned groups), the user is denied access.
File location: custom_hospital_management/security/ir.model.access.csv
Code snippet
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hospital_patient_user,access.hospital.patient.user,model_hospital_patient,base.group_user,1,1,1,0
In this configuration, standard internal users (base.group_user) can Read, Write, and Create patient records (hospital.patient), but cannot Delete (perm_unlink = 0) them.
Record rules are row-level conditions evaluated record-by-record after Access Rights pass. While Access Rights grant access to the table, Record Rules filter which individual rows inside that table a user can see or modify.
When writing expressions inside domain_force, Odoo provides the following evaluation variables:
Rule Type | Composition Behavior | Technical Rule |
Global Rules (groups empty) | Intersection (AND) | Adding global rules always restricts access further. All global rules must be satisfied simultaneously. |
Group Rules (groups specified) | Unification (OR) | Adding group rules expands access. If any group rule matches, access is granted. |
Combined Evaluation | Intersection (AND) | Global rulesets and Group rulesets intersect. The user must pass ALL Global rules AND at least ONE matching Group rule. |
DANGER: Creating multiple Global rules is risky. If two global rules are mutually exclusive (non-overlapping), all user access to the model will be completely blocked.
Fields can be restricted directly in Python models using the groups attribute:
Python
class HospitalPatient(models.Model):
_name = ‘hospital.patient’
_description = ‘Hospital Patient Record’
name = fields.Char(string=’Full Name’, required=True)
# Medical notes restricted strictly to Healthcare Managers
note = fields.Text(string=’Medical History’, groups=’custom_hospital_management.group_hospital_manager’)
Any public Python method (methods not starting with _) can be executed remotely via RPC calls. Access Rights are only enforced automatically during standard ORM CRUD operations.
Python
#INCORRECT: Arguments and state execution cannot be trusted
def action_discharge(self):
if self.state == “draft” and self.env.user.has_group(‘base.group_user’):
self._set_state(“discharged”)
#CORRECT: Keep business execution private, apply explicit checks
def _set_state(self, new_state):
self.sudo().write({“state”: new_state})
Never bypass the ORM to execute raw SQL queries via self.env.cr when ORM methods (search, write) can perform the task. Bypassing the ORM skips automated security checks, domain filtering, and record-level rules.
Python
# CRITICAL VULNERABILITY: SQL Injection via string concatenation
self.env.cr.execute(‘SELECT id FROM hospital_patient WHERE state = ‘ + user_input)
# POOR PRACTICE: Bypassing ORM security logic manually
self.env.cr.execute(‘SELECT id FROM hospital_patient WHERE state = %s’, (state_val,))
# CORRECT: Clean ORM usage enforcing access rights & domain safety
patients = self.search([(‘state’, ‘=’, state_val)])
If manual SQL queries are unavoidable, strictly use Odoo’s SQL wrapper to prevent injection:
Python
from odoo.tools import SQL
# SECURE: Safe parameter handling via SQL wrapper
self.env.cr.execute(SQL(“””
SELECT id FROM hospital_patient
WHERE age > %s AND state = %s
“””, min_age, state_val))
Do not concatenate raw lists to build search domains, as unnormalized user inputs can manipulate domain logic. Use the Domain utility object:
Python
from odoo.osv.expression import Domain
# UNSAFE: User domain can override security rules via logical ORs
domain = user_provided_domain
domain += [(‘create_uid’, ‘=’, self.env.uid)]
# SECURE: Enforce security parameters using domain intersection
domain = Domain(user_provided_domain) & Domain([(‘create_uid’, ‘=’, self.env.uid)])
patients = self.search(domain)
Python
from odoo.tools import Markup, escape
# UNSAFE: Raw dynamic strings inserted directly into HTML structure
html_out = f”<div>User: {patient.name}</div>”
# SECURE: Using Markup to ensure variables are safely escaped
html_out = Markup(“<div>User: %s</div>”) % patient.name
Do not use generic getattr() or setattr() calls on ORM records, as they expose internal private methods and system attributes. Access fields dynamically using record dictionary indexing:
Python
# UNSAFE: Gives full access to internal private methods/attributes
value = getattr(patient_record, user_input_field)
# SECURE: Restricts dynamic access strictly to model fields
value = patient_record[user_input_field]
Avoid using Python’s eval() or safe_eval() to parse string inputs. Use specific non-evaluating data parsers:
Data Type | Target Language | Safe Parser |
Integer / Float | Python | int(), float() |
Integer / Float | JavaScript | parseInt(), parseFloat() |
Dictionaries / Lists | Python | json.loads(), ast.literal_eval() |
Objects / Arrays | JavaScript | JSON.parse() |