“I know that quotation exists, but it’s not showing up in my list.” You check the database — the record is there, untouched, not archived. You open it yourself with no problem. The user swears they’re looking in the right place. Nobody touched the module in weeks.
This is one of the most common support tickets in any Odoo deployment, and it’s almost never a bug. It’s one of three independent access-control layers doing exactly what it was configured to do — just not what anyone remembers configuring. Knowing which of the three is actually responsible turns a 45-minute guessing session into a two-minute check.
The short version: Odoo checks three separate things, in this order, and each one can only take away access, never grant it back — model access rights (can this group touch this model at all), record rules (which specific rows), and field-level security (which parts of a visible row). Everything below is how to tell which one is actually misconfigured, instead of guessing.
The three layers, in the order Odoo checks them
Odoo decides what a user can do with a record through three mechanisms that stack on top of each other. They look similar (both access rights and record rules live under Settings → Technical → Security in developer mode) but they answer different questions, and — per Odoo’s own developer documentation on security — they’re evaluated in a fixed order:
- Model access rights (
ir.model.access, their.model.access.csvfile in a module) — can this group read/write/create/delete this model at all, with no awareness of individual records. - Record rules (
ir.rule) — given that the model-level check passed, which specific records is this user allowed to see, expressed as an Odoo domain. - Field-level security (groups on a field or a view element) — the record itself is visible, but this particular field is hidden or read-only for this user’s group.
They’re checked in that order, and each one can only narrow what the previous layer already allowed — there’s no layer that grants access back once an earlier one has denied it.
| Layer | Enforced by | Granularity | Typical failure mode |
|---|---|---|---|
| 1. Access rights | ir.model.access.csv | Whole model, per CRUD operation | Hard error: “you are not allowed to access this document” |
| 2. Record rules | ir.rule, a domain per group | Individual records | Silent: the row just isn’t in the list |
| 3. Field security | groups on a field/button/view element | A field or UI element within a visible record | Silent: the field or button just isn’t there |
Layer 1: model access rights are a blunt on/off switch
ir.model.access.csv answers one question per group: can this group read, write, create, or delete records of this model, full stop. It has no idea what a specific record looks like. A real row from this file looks like this:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_sale_order_salesman,sale.order.salesman,sale.model_sale_order,sales_team.group_sale_salesman,1,1,1,0
That id column — access_sale_order_salesman — is an external ID, and it follows the same naming convention every other record in an Odoo module does. If you’re adding a new access-rights line and want it named the way the rest of the codebase (and the rest of the Odoo ecosystem) expects, the External ID / XML ID Helper will suggest one from the model and group name rather than you guessing at a convention.
If a user’s group has no access line for sale.order at all, they get a hard “you don’t have access to this document” error the moment they try to open the list — not an empty list, an actual access-denied screen. This is the first thing to rule out, and it’s the fastest to check: Settings → Technical → Access Rights, filter by model, and confirm the user’s group appears with the permission they need. If it’s missing here, nothing below it matters yet.
Watch out: a model with zero access-rights rows isn't just "read-only" for everyone — it's completely invisible. No menu item pointing at it will even render for a group with no access-rights line, which is a very different-looking symptom ("the menu is gone") from a record rule silently emptying a list.
Layer 2: record rules filter which rows come back
This is the layer that actually produces the symptom from the top of this article — the list loads fine, permissions are clearly fine, and one particular row is just… not in it. A record rule adds an implicit domain filter to every query a group of users makes against a model. The actual XML record behind a rule like this looks like:
<record id="sale_order_rule_salesman" model="ir.rule">
<field name="name">Sales Order: own orders only</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
</record>
A salesperson group with this rule attached only ever sees orders where they’re the assigned salesperson — every list, every search, every report silently has [('user_id', '=', user.id)] ANDed onto it. The record exists, the user has full model-level access to sale.order, and it still never shows up, because the row itself doesn’t match the rule’s domain.
The part that actually trips people up: how multiple rules combine
A model can have several record rules attached to different groups at once, and Odoo doesn’t simply AND all of them together — that would mean the most restrictive group always wins, defeating the purpose of having several groups in the first place. Per the same official reference above:
- Rules that apply globally (no group set —
group_idis empty) are combined with AND. Every one of them has to pass. - Rules that are tied to a specific group are combined with OR among themselves, then that combined result is ANDed with the global rules.
| Rule type | groups field | Combines with other rules of the same type | Effect on final access |
|---|---|---|---|
| Global | empty | AND | Always narrows access further, no exceptions |
| Group-specific | one or more groups | OR (among group rules) | Union of what any of the user’s groups allow |
So if a user belongs to both “Salesperson” (user_id = user.id) and “Regional Manager” (team_id in user.region_team_ids), they get the union of both — anything that’s theirs or in their region — not the intersection. Add a global rule restricting everyone to company_id = user.company_id for multi-company setups, and that one applies on top of the OR’d result, no exceptions. Miscounting an AND for an OR here (or forgetting a rule is global) is the single most common reason “it should show up, they’re in that group” turns out to be wrong.
The multi-company trap
Multi-company installs almost always ship a global rule along the lines of ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] on every company-dependent model. Because it’s global, it’s ANDed on top of every group-specific rule that model has — including ones nobody remembers writing, from modules installed years ago. A user reporting “I can’t see any orders at all, not even my own” after being moved to a new company is very often this rule doing its job correctly against a user whose allowed companies were never updated, not a broken salesperson rule.
Where this gets debugged
Paste the exact domain from the record rule that’s suspect into the Domain Filter Builder & Explainer — switch to Explainer mode and it translates something like ['|', ('user_id','=',user.id), ('team_id','in',user.region_team_ids)] into plain English precedence, the same way it explains any other domain. It won’t show you which rules apply to this user (that still means opening Settings → Technical → Record Rules and filtering by model), but once you’ve found the candidate rule, confirming what its domain actually matches is exactly what the Domain Filter tool is for — it’s the same prefix-notation logic either way, whether the domain came from a search view or an ir.rule.
Layer 3: field security hides pieces, not whole records
The third layer is easy to miss because the symptom looks identical from a distance: “this field is just not there for this user.” Odoo lets a field, a button, or a whole view element carry a groups attribute — if the user isn’t in one of those groups, the element is stripped from the view entirely (not just disabled). A cost field restricted to an “Accounting” group, or a “Force cancel” button restricted to managers, both disappear this way. The record itself is fully visible; one part of it isn’t.
This is exactly the mechanism behind the Report Builder’s own Visible for (groups) editor on every node — a groups attribute on a QWeb report element hides that block of the report for anyone outside the listed groups, the same way it hides a field or button in a form view. If a printed report is missing a section for some users and not others, it’s this layer, applied to a <template> instead of a form.
This one is the quickest to confirm: open the record as an administrator and as the affected user side by side. If the row is missing from a list, it’s layer 2. If the row is there but a field or button is missing, it’s layer 3.
Quick reference: which layer is it?
| Symptom | Layer | Where to check |
|---|---|---|
| Hard “you are not allowed to access this document” error, whole screen | 1. Access rights | Settings → Technical → Access Rights |
| The menu item itself doesn’t appear at all | 1. Access rights | Settings → Technical → Access Rights |
| List/search loads, but one specific record is missing | 2. Record rules | Settings → Technical → Record Rules |
| Nothing shows up at all after a company/team change | 2. Record rules (often a global multi-company rule) | Settings → Technical → Record Rules, filter for global rules |
| The record is there, but a field or button is missing | 3. Field security | Compare the view as admin vs. as the user |
| A printed report is missing a whole section for some users | 3. Field security (groups on a QWeb node) | The report’s own arch/view definition |
A three-question checklist for the next ticket
- Can the user’s group access the model at all? Settings → Technical → Access Rights, filtered by model. If this is missing, stop here.
- Is a record rule filtering the list? Settings → Technical → Record Rules, filtered by model. For each rule that applies to a group the user belongs to, check whether it’s global (ANDed with everything) or group-specific (ORed with the user’s other group rules) — then test the domain itself in the Domain Filter Explainer.
- Is it the whole record or just a field? Compare the record as an admin and as the user. A missing row is layer 2; a missing field or button is layer 3.
Odoo’s own error messages don’t always distinguish these cleanly — “you are not allowed to access this document” can mean either layer 1 or layer 2 depending on version and context. Working through the checklist in order is still faster than guessing, because each step either resolves the ticket or rules out an entire layer.
Frequently asked questions
Does the order I check these in actually matter?
Yes — checking record rules before confirming basic model access wastes time if the real problem is layer 1, and the reverse is just as common: assuming a hard access-denied error must be a record rule when it's actually a missing access-rights line. Layer 1 first is always the fastest elimination.
Can a record rule grant access that model access rights deny?
No. Record rules only ever narrow what model access rights already allow — they can't grant read access to a model the user's group has no access-rights line for at all.
Is a record rule with no group_id the same as applying to everyone?
Yes, and that's exactly why it's dangerous to get wrong: a rule with an empty group_id is global, ANDed against every other rule regardless of which groups exist. A typo that leaves group_id blank on what was meant to be a group-specific rule silently restricts the entire model for everyone, not just that one group.
Where do I actually see which record rules exist for a model?
Developer mode → Settings → Technical → Record Rules, then filter by the model name. Each rule shows its domain, whether it's global or tied to a specific group, and which CRUD operations it restricts (a rule can apply only to reads, only to writes, or everything).
Does the superuser/admin account go through any of this?
No — a superuser call (sudo() context in code, or the actual admin/OdooBot account by default) bypasses both access rights and record rules entirely. This is exactly why "it works fine when I test it as admin" proves nothing about what a real user's group can see — always reproduce access-control tickets logged in as the affected user, or by explicitly simulating their groups.
Can I combine perm_read/perm_write differently for the same group on a record rule?
Yes — a record rule's perm_read/perm_write/perm_create/perm_unlink fields control which operations the rule applies to, all enabled by default. A rule can, for instance, restrict which records a group can edit without restricting which they can see, by leaving perm_read off and the rest on.
Further reading
This article covers the debugging workflow, not the full reference — for the complete field-by-field specification:
- Security in Odoo — official developer documentation, the primary source for everything above.
- Restrict access to data — official developer tutorial, a hands-on walkthrough of adding groups, access rights, and record rules to a real module.
- ir.rule reference — Odoo Development docs, a field-level breakdown of the
ir.rulemodel itself. - How to Create Security Group & Manage Access Rights in Odoo 18 — Cybrosys, a step-by-step walkthrough of building groups and access rights from the UI.
And on this site: the Domain Filter Builder & Explainer for testing any record rule’s domain, and the External ID / XML ID Helper for naming new access-rights and record-rule records consistently.