Almost every Odoo 19 project I inherit has the same open ticket: a user hits “You are not allowed to access records of type X” and nobody can explain why. It looks like one problem. It is almost always two, because Odoo enforces security in two distinct layers, and until you know which layer rejected the operation you are guessing. In my day to day Odoo customization services work, access errors are the most common category of “it works on my machine” bug.
Odoo 19 made this easier by consolidating the old two-call security API into a single entry point. This article covers how the access control list and record level filtering interact, what changed in the ORM, and the debugging sequence I run when an AccessError hits production.
The Two Layers Behind Every Odoo 19 Access Error
Odoo security is two gates in series, and both must open before an operation proceeds. The order matters, because the failing layer determines the fix, and applying an ACL fix to a record rule problem will burn an afternoon.
Layer One: Model Level ACLs in ir.model.access
The first gate is the model level access control list, stored in ir.model.access and declared in an ir.model.access.csv file in your module’s security folder. It answers a coarse question: may this security group read, write, create, or unlink this model at all? The perm_read, perm_write, perm_create, and perm_unlink columns are plain booleans. ACLs are additive: if any of the user’s groups carries the permission, the model gate opens.
A row with an empty group_id applies to every user. Ship a model with no ACL row at all and non superuser access is denied outright: correct for internal technical models, a nasty surprise on a customer facing one.
Layer Two: Record Level Filtering with ir.rule
The second gate is the record rule in ir.rule. Where ACLs deal in models, record rules deal in rows. Each rule carries a domain_force expression, a set of groups, and its own read, write, create, and unlink flags. Odoo evaluates the applicable rules, builds a combined domain through _compute_domain, and applies it to the query.
Why Global Rules and Group Rules Fail Differently
A rule with no groups attached is a global record rule, and global rules combine with AND. They are hard floors, so a global rule failure is absolute and adding the user to another group will not help. Rules that carry groups combine with OR, so membership in any group whose rule matches is enough. The effective filter is “all global rules AND at least one matching group rule.” Once you internalise that shape, multi-company behaviour stops feeling arbitrary. It also explains how a permissive group rule quietly widens access: when I audit a system and find a group rule with domain [(1, '=', 1)], that is usually the real vulnerability, not the error I was called about.
What Changed in Odoo 19: check_access(), has_access(), and _filtered_access()
For years the ORM exposed the two layers as two calls: check_access_rights() for the ACL layer and check_access_rule() for record rules. Developers routinely called one and forgot the other, producing code that passed review and leaked data. Odoo consolidated them:
# Raises AccessError if the ACL layer or the record rules reject the operation
record.check_access('write')
# Boolean form, no exception raised
if record.has_access('write'):
record.write(vals)
# Returns only the subset of the recordset the user may actually touch
records._filtered_access('unlink').unlink()check_access(operation) runs the access control list first and record rules second, and it works on an empty recordset, so you can ask “may this user write to this model at all” without a record in hand. has_access(operation) returns a boolean instead of raising, removing a lot of defensive try and except blocks from controller code. _filtered_access(operation) is the one I reach for most in batch logic, because it returns the permitted subset rather than failing the whole operation on one forbidden row.
I covered the internals in my walkthrough on what check_access() actually does in Odoo 19, including its @api.private decoration and why it cannot be called over RPC. The older check_access_rights() and check_access_rule() calls survive in Odoo 19 as deprecated wrappers, so nothing breaks on upgrade day, but they will be removed and they do not benefit from the access caching the new API enables. Migrate them during your upgrade, not after.
My Debugging Sequence for Odoo 19 Access Errors
This sequence resolves most access tickets within fifteen minutes.
First, isolate the model and the operation. Reproduce with the server log at debug level and read the traceback. Do not trust the UI label, because related field access propagation means the failing model is often not the one on screen: a form on sale.order can fail on res.partner. Also distinguish AccessError from AccessDenied. The latter is an authentication failure, and no ACL edit will fix a login problem.
Second, test the ACL layer on an empty recordset. Because the new API accepts empty recordsets, this isolates the model gate cleanly:
env = env(user=affected_user_id)
env['sale.order'].browse().check_access('write')If that raises, the problem is entirely in ir.model.access and no record rule is involved. Check the CSV, confirm the module actually upgraded, and verify the group the user is really in rather than the one you assume.
Third, if step two passes, inspect the computed record rule domain. Enable developer mode, open Settings, Technical, Record Rules, filter by the model, and resolve the effective domain for that user at runtime. Reading domain_force in the XML is not enough, because it usually contains context such as user.company_ids that materialises per user.
Four causes account for most of what I find: missing ACL rows after a model rename; multi-company record rules where the user’s company_ids excludes the record’s company, producing a read error that looks like the record does not exist; portal access where an ACL was granted but the portal record rule was forgotten, so the model opens and every row stays invisible; and related fields reaching into a restricted model.
When sudo() Is the Right Fix and When It Hides a Bug
sudo() sets env.su to true, and when that flag is set both gates are skipped entirely. That makes it a superb tool and a dangerous habit. It is correct when your code performs a framework level operation the user legitimately triggers but should not perform directly: writing a sequence, logging an audit trail, creating a mail message on a restricted record. It is wrong when you use it to make an error disappear. If you sudo() a user facing read, you have not fixed the permission model, you have removed it. The safer middle ground is _filtered_access(), which respects the security design while letting the batch continue on records the user genuinely owns.
If an access error survives this sequence, or you want a security model audited before it goes live, I run these reviews regularly and can usually pinpoint the failing layer in one session. Book a Consultation and bring the traceback with you.
Conclusion
Odoo 19 access errors stop being mysterious once you accept that there are two gates, not one, and that they fail for different reasons. The access control list in ir.model.access decides whether the model is reachable. The record rules in ir.rule decide which rows are visible, with global rules combining through AND and group rules through OR. The consolidated check_access() API, alongside has_access() and _filtered_access(), lets you test each layer in isolation instead of guessing. Migrate off the deprecated calls during your upgrade, test with an empty recordset first, and reserve sudo() for cases where bypassing security is genuinely the intent.
Frequently Asked Questions
Does check_access() replace both check_access_rights() and check_access_rule() in Odoo 19?
Yes. check_access(operation) runs the access control list check and the record rule check in one call. The older methods remain as deprecated wrappers so existing modules keep working, but they are scheduled for removal.
Why does my user pass the ACL check but still see no records?
That is a record rule problem. The ACL opened the model gate, then ir.rule filtered every row out of the result. Inspect the computed domain for that user and check multi-company rules first, since a mismatch between the record’s company and the user’s company_ids is the most frequent cause.
How do global and group specific record rules combine?
Rules with no groups attached are global, combine with AND, and cannot be bypassed. Group specific rules combine with OR, so membership in any group whose rule matches is enough. The effective filter is all global rules AND at least one matching group rule.
Should I use sudo() to fix an AccessError?
Only when bypassing security is genuinely intended, such as internal framework operations or audit logging. For user facing operations it removes the permission model rather than correcting it. Use _filtered_access(operation) when a batch operation needs to proceed on the records the user is actually entitled to.
Why does an access error name a model my user never opened?
Related and computed fields propagate access checks to the models they reach, so a form on one model can trigger a check on another. Always read the model technical name from the traceback rather than assuming it matches the view on screen.