The hands-on guide.
The slides explain how Odoo security works. This page explains what to do when it blocks you — debug an AccessError, write your first record rule, look up domain operators, and know when sudo() is the right call.
Debug an AccessError
The most common Odoo developer experience: you click save, and the screen turns red with AccessError. Here's the triage flow.
Step 1: identify which layer blocked you
The error message tells you which of the two real layers (ACL vs record rule) is the culprit. Read the first line:
| Error says… | Blocked by | Layer |
|---|---|---|
| "doesn't have 'write' access to Model Name" | ir.model.access | ACL (model-level) |
| "top-secret records" / "stumbled upon" | ir.rule | Record rule (row-level) |
Step 2: enable debug mode
Without debug mode, record-rule errors are deliberately vague (information hiding — attackers shouldn't learn which rules exist). With debug on and as an internal user, Odoo reveals:
- The names of the failing rules ("Blame the following rules: …")
- The first 6 record ids that were denied
- If it's a multi-company issue, it suggests switching company
The extended error is gated by has_group('base.group_no_one') and _is_internal(). Both must be true. Portal/public users never see the detail, even in debug.
Step 3: triage in the Odoo shell
Open the shell (./odoo-bin shell -d mydb) and run these commands:
If sudo().read() also fails, it's not a security issue — the record doesn't exist, is archived, or you have the wrong id. Use record.exists() to check.
Write your first record rule
The most common developer task. A record rule is an XML record in your module's security/ directory that restricts which rows a user can see or modify.
The XML structure
The domain eval context
The string in domain_force is evaluated via safe_eval with three variables available:
| Variable | What it is | Example use |
|---|---|---|
| user | The current user record (res.users) | user.id, user.commercial_partner_id |
| company_id | The current active company (single) | ('company_id', '=', company_id) |
| company_ids | All activated companies (list) | ('company_id', 'in', company_ids) |
File location
- Create the XML in
my_module/security/security.xml - Add it to
__manifest__.pyunder"data" - Upgrade the module:
./odoo-bin -u my_module -d mydb
Omit the groups field → the rule is global (applies to ALL users, AND-ed with everything — can't be weakened). Add groups → only that group, OR-ed with other group rules. Use global for mandatory isolation (multi-company), group for role-specific visibility.
Real example from Odoo source
Domain operator cheatsheet
Every operator you can use inside domain_force or any search domain. Sourced from orm/domains.py — STANDARD_CONDITION_OPERATORS.
| Operator | What it does | Example |
|---|---|---|
| = | Equality (single value) | ('user_id', '=', user.id) |
| != | Not equal | ('state', '!=', 'draft') |
| in | Value is in a collection | ('company_id', 'in', company_ids) |
| not in | Value is not in a collection | ('state', 'not in', ['cancel']) |
| < > <= >= | Numeric/date inequality | ('amount_total', '>=', 1000) |
| like | Case-sensitive pattern (adds % wildcards) | ('name', 'like', 'Inv') → %Inv% |
| ilike | Case-insensitive + unaccent (adds wildcards) | ('name', 'ilike', 'cafe') → matches Café |
| =like | Exact LIKE pattern (no auto wildcards) | ('code', '=like', 'INV_%') |
| =ilike | Exact ILIKE + unaccent (no wildcards) | ('ref', '=ilike', 'ABC-001') |
| not like / not ilike | Negated pattern match | ('name', 'not ilike', 'test') |
| any | Relational: record matches a sub-domain | ('partner_id', 'any', [('active', '=', True)]) |
| not any | Relational: record does NOT match sub-domain | ('tag_ids', 'not any', [('name', '=', 'VIP')]) |
| child_of | Hierarchical descendant | ('parent_id', 'child_of', partner_ids) |
| parent_of | Hierarchical ancestor | ('company_id', 'parent_of', company_ids) |
any! and not any! bypass record rules on the related model. They exist for internal framework use (e.g. rule evaluation itself). Never accept them from untrusted input — it's a privilege escalation.
Combining conditions
Domains use prefix notation. The operators & (AND) and | (OR) are binary (take exactly 2 operands). ! (NOT) is unary. Default joining is AND if no prefix is given.
When to use sudo()
sudo() bypasses all security checks. It's a tool, not a crime — but misusing it is the #1 cause of data leaks in custom Odoo code. Here's how to decide.
Legitimate: scheduled actions & system code
Cron jobs (ir.cron) already run as a configured user. If a scheduled action needs to touch records outside that user's normal access (e.g. archiving expired records across all companies), sudo() is the intended tool.
Legitimate: reading system config
env['ir.config_parameter'].sudo().get_param('...') is standard — system parameters have no user-facing ACL, and every user needs to read them.
Legitimate: data migration / import scripts
One-off migration scripts that create or update records bypassing the normal workflow. These should be audited and removed after go-live.
Anti-pattern: "it works with sudo, ship it"
If your business logic only works under sudo(), you probably have a missing ACL grant or a record rule that's too restrictive. Fix the security, don't paper over it.
Anti-pattern: sudo in workflows that should respect the user
If a salesperson creates a quote, the write should go through their normal ACL and rules — not sudo(). Otherwise you can create records the user can't see afterwards.
Anti-pattern: sudo to read another company's data
sudo() skips multi-company isolation. If you use it to fetch records from company B while in company A, you've just broken tenant isolation — possibly silently.
Alternatives before reaching for sudo
| You want to… | Instead of sudo() | Why |
|---|---|---|
| Act as a specific user | with_user(uid) | Changes identity but keeps all checks |
| Let a group access a model | Add an ir.model.access row | Explicit, auditable, cached |
| Let a group see more rows | Add a group record rule | OR-ed, doesn't weaken global rules |
| Read a field you can't see | Remove the field's groups= restriction | Deliberate, not hidden |
Before writing .sudo(), ask: "Would I be comfortable explaining to the client why this data crossed a boundary the user couldn't cross themselves?" If the answer is no, don't use it.
Version differences (17 vs 18+)
Odoo 18 unified the security API. If you're reading old tutorials or working on a 17-or-earlier codebase, the method names differ.
| Task | Odoo 18–19 | Odoo 17 & earlier |
|---|---|---|
| Check ACL + rules together | record.check_access('write') | not available |
| Boolean check (ACL + rules) | record.has_access('write') | not available |
| Get allowed subset | record._filtered_access('write') | not available |
| Check ACL only | check_access_rights() deprecated | check_access_rights('write') |
| Check rules only | check_access_rule() deprecated | check_access_rule('write') |
In 18+, check_access(operation) runs both checks (ACL first, then record rules) in one call. The old methods still exist but are @api.deprecated — they delegate to check_access() internally. The deprecation annotations are at orm/models.py:4159 and :4173.
The concepts in the slides (ACL → record rules → SQL injection) are identical across all versions. Only the Python method names changed. The ir.model.access CSV, ir.rule XML, and sudo() behavior are the same since Odoo 8.
For the complete source-level documentation — every method, every file, every call chain — see the 17-document reverse-engineering spec.