▸ practical companion

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.

Odoo 18–19 5 sections ← back to slides
01 — debug

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 byLayer
"doesn't have 'write' access to Model Name"ir.model.accessACL (model-level)
"top-secret records" / "stumbled upon"ir.ruleRecord 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
▸ source check

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:

# 1. Is it an ACL issue or a record-rule issue? # has_access() checks BOTH layers (Odoo 18+): record = env['account.move'].browse(42) record.has_access('write') # → False # 2. Check ACL alone (does the user have model-level write?): env['ir.model.access'].check('account.move', 'write') # → True or AccessError # 3. See the actual record-rule domain for this user+model: env['ir.rule']._compute_domain('account.move', 'write') # → e.g. Domain([('company_id', 'in', [1, 3])]) # 4. Confirm it's a security issue, not a data issue: record.sudo().read(['name']) # → works? it's ACL/rule, not missing data # 5. ACL error tells you which groups DO have access: env['ir.model.access'].group_names_with_access('account.move', 'write')
⚠ common gotcha

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.

02 — create

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

<!-- my_module/security/security.xml --> <record model="ir.rule" id="my_module_my_rule"> <field name="name">only see own records</field> <field name="model_id" ref="model_my_model"/> <field name="domain_force">[('user_id', '=', user.id)]</field> <!-- optional: restrict to a specific group (omit = GLOBAL rule) --> <field name="groups" eval="[(6, 0, [ref('my_group')])]"/> <!-- which operations does this rule apply to? --> <field name="perm_read" eval="True"/> <field name="perm_write" eval="True"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> </record>

The domain eval context

The string in domain_force is evaluated via safe_eval with three variables available:

VariableWhat it isExample use
userThe current user record (res.users)user.id, user.commercial_partner_id
company_idThe current active company (single)('company_id', '=', company_id)
company_idsAll activated companies (list)('company_id', 'in', company_ids)

File location

  • Create the XML in my_module/security/security.xml
  • Add it to __manifest__.py under "data"
  • Upgrade the module: ./odoo-bin -u my_module -d mydb
◆ global vs group

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

<!-- base/security/base_security.xml — multi-company partner isolation --> <record model="ir.rule" id="res_partner_rule"> <field name="model_id" ref="base.model_res_partner"/> <field name="domain_force">['|', '|', ('partner_share', '=', False), ('company_id', 'parent_of', company_ids), ('company_id', '=', False)]</field> </record> <!-- no groups → GLOBAL rule. No group can weaken this. -->
03 — reference

Domain operator cheatsheet

Every operator you can use inside domain_force or any search domain. Sourced from orm/domains.py — STANDARD_CONDITION_OPERATORS.

OperatorWhat it doesExample
=Equality (single value)('user_id', '=', user.id)
!=Not equal('state', '!=', 'draft')
inValue is in a collection('company_id', 'in', company_ids)
not inValue is not in a collection('state', 'not in', ['cancel'])
< > <= >=Numeric/date inequality('amount_total', '>=', 1000)
likeCase-sensitive pattern (adds % wildcards)('name', 'like', 'Inv')%Inv%
ilikeCase-insensitive + unaccent (adds wildcards)('name', 'ilike', 'cafe') → matches Café
=likeExact LIKE pattern (no auto wildcards)('code', '=like', 'INV_%')
=ilikeExact ILIKE + unaccent (no wildcards)('ref', '=ilike', 'ABC-001')
not like / not ilikeNegated pattern match('name', 'not ilike', 'test')
anyRelational: record matches a sub-domain('partner_id', 'any', [('active', '=', True)])
not anyRelational: record does NOT match sub-domain('tag_ids', 'not any', [('name', '=', 'VIP')])
child_ofHierarchical descendant('parent_id', 'child_of', partner_ids)
parent_ofHierarchical ancestor('company_id', 'parent_of', company_ids)
⚠ any! is internal

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.

# A AND B (implicit AND when no prefix): [('user_id', '=', 5), ('active', '=', True)] # A OR B: ['|', ('user_id', '=', 5), ('team_id', '=', 3)] # (A OR B) AND C: ['|', ('user_id', '=', 5), ('team_id', '=', 3), ('active', '=', True)] # NOT A: ['!', ('state', '=', 'cancel')]
04 — judgment

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 userwith_user(uid)Changes identity but keeps all checks
Let a group access a modelAdd an ir.model.access rowExplicit, auditable, cached
Let a group see more rowsAdd a group record ruleOR-ed, doesn't weaken global rules
Read a field you can't seeRemove the field's groups= restrictionDeliberate, not hidden
⚠ the one question

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.

05 — compatibility

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.

TaskOdoo 18–19Odoo 17 & earlier
Check ACL + rules togetherrecord.check_access('write')not available
Boolean check (ACL + rules)record.has_access('write')not available
Get allowed subsetrecord._filtered_access('write')not available
Check ACL onlycheck_access_rights() deprecatedcheck_access_rights('write')
Check rules onlycheck_access_rule() deprecatedcheck_access_rule('write')
▸ what changed

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.

⚠ if you're on Odoo 16 or earlier

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.

▸ full reference

For the complete source-level documentation — every method, every file, every call chain — see the 17-document reverse-engineering spec.

← back to the slides