If you have shipped Odoo modules across a few versions, you have written check_access_rights() and check_access_rule() beside each other more times than you can count. In Odoo 19 that pattern is finished. check_access() is now the single ORM method that verifies whether the current user may perform an operation on a recordset, and it covers both layers of the Odoo security model in one call: the model level access control list stored in ir.model.access, and the record level filtering defined by record rules in ir.rule.
Most of the Odoo customization services work I take on in Australia ends up touching security eventually, usually because a custom model shipped with a permissive ACL and nobody noticed until an audit or a portal user saw something they should not have. The consolidation into check_access() matters for exactly that reason: it makes the correct check the easiest one to write. Below is what the method does, the two companion methods you should be using with it, and what actually changes when you migrate a module from Odoo 17 or 18.
Why Odoo Collapsed Two Access Checks Into One
The old API split one question into two. check_access_rights(operation) asked whether the user held model level permission, reading perm_read, perm_write, perm_create and perm_unlink from the ACL table. check_access_rule(operation) then asked whether the specific records in the recordset survived the active record rules, evaluating each domain_force against the user’s groups and companies.
Two problems followed from that split. First, developers routinely called the first method and forgot the second, which produced code that looked secure and was not: the user had permission on the model, so nothing raised, and the record rule was never evaluated. Second, check_access_rights() had a variable return type. With raise_exception=True it raised an AccessError, and with raise_exception=False it returned a boolean, so calling code had to be read carefully to know which contract it was operating under.
Odoo 19 fixes both by separating the question of intent from the question of mechanism. If you want an exception, call check_access(). If you want a boolean, call has_access(). The mechanism, ACL first and record rules second, is handled once in a shared internal method.
What check_access() Actually Does
The signature is deliberately minimal:
def check_access(self, operation: str) -> Noneoperation is one of read, write, create or unlink. The method returns nothing on success and raises an AccessError if the operation is forbidden on the model in general, or on any single record in the recordset. There is no raise_exception flag, because raising is the entire point of this method.
tasks = self.env['project.task'].search([('stage_id.is_closed', '=', False)])
tasks.check_access('write')
tasks.write({'priority': '1'})Two behaviours are worth committing to memory. The check is skipped entirely when self.env.su is true, which is why sudo() bypasses it. And it is decorated with @api.private, so it cannot be called over RPC from an external client or from JavaScript. If you need a permission check that a web client can call, has_access() is the public one.
The method also works on an empty recordset, which the old API could not do cleanly. This is the idiomatic way to ask whether the user has any permission on the model at all, which is what you want before rendering a button or building a wizard:
if not self.env['account.move'].browse().has_access('create'):
raise UserError(_("You are not allowed to create journal entries."))has_access() and _filtered_access(): The Other Two Methods You Need
has_access(operation) returns a boolean and is fully consistent with check_access(). Same rules, same evaluation order, different contract. Use it in conditionals, view visibility logic and controller guards.
_filtered_access(operation) returns the subset of the recordset the user is actually allowed to touch. It is functionally equivalent to self.filtered(lambda record: record.has_access(operation)) but evaluates the record rule domain once for the whole set rather than record by record, which matters on batch operations.
def action_bulk_archive(self):
allowed = self._filtered_access('write')
skipped = self - allowed
allowed.write({'active': False})
if skipped:
_logger.info("Skipped %s records the user cannot write", len(skipped))
return TrueThat pattern is the right shape for server actions and scheduled jobs, where raising an AccessError on record 40 of 500 would abort the whole transaction rather than degrade gracefully.
Inside _check_access(): ACLs First, Record Rules Second
All three public methods delegate to _check_access(operation). It returns None when access is permitted, and otherwise a tuple of the forbidden records and a callable that builds the matching exception. The evaluation order is fixed. ir.model.access.check() runs first, and if the model level ACL denies the operation, the entire recordset is forbidden and record rules are never evaluated. If the ACL passes, ir.rule._compute_domain() builds the applicable domain, the recordset is filtered against it in superuser mode with active_test disabled, and anything that falls out is returned as forbidden.
Because _check_access() is a documented extension hook, it is the correct place to add custom restrictions rather than overriding write() or unlink() and raising by hand:
class AccountMove(models.Model):
_inherit = 'account.move'
def _check_access(self, operation):
result = super()._check_access(operation)
if operation == 'unlink' and result is None:
locked = self.filtered(lambda move: move.state == 'posted')
if locked:
return locked, lambda: AccessError(
_("Posted journal entries cannot be deleted.")
)
return resultRestrictions added here are respected by check_access(), has_access() and _filtered_access() at once, so your buttons, your batch jobs and your ORM writes all agree. If you want the wider picture of how the recordset layer fits together before you start overriding internal hooks, my walkthrough of how the Odoo 19 ORM works behind the scenes covers the CRUD path these checks sit on.
Migrating From check_access_rights() and check_access_rule()
The old methods still exist in Odoo 19 as deprecated wrappers, so an upgrade will not break your module on day one. They emit deprecation warnings and delegate to the new API, and they will be removed. The mapping is mechanical:
check_access_rights(op)becomescheck_access(op)on the recordset, orbrowse().check_access(op)for a model level checkcheck_access_rights(op, raise_exception=False)becomeshas_access(op)check_access_rule(op)becomescheck_access(op)_filter_access_rules(op)and_filter_access_rules_python(op)both become_filtered_access(op)
The one case that needs thought is code that called only check_access_rights(). Swapping it for check_access() on a populated recordset now also enforces record rules, which is correct but may surface an AccessError that was silently absent before. Treat those as genuine findings, not migration noise.
If your team is planning an Odoo 19 upgrade and you would rather have the access layer reviewed before it reaches production, Book a Consultation and we can walk the custom modules together, map the deprecated calls, and tighten the ACLs and record rules that the old two step pattern was quietly hiding.
Conclusion
check_access() is a small API change with a large correctness payoff. One method, one operation argument, both security layers, and no ambiguous return type. Pair it with has_access() for boolean checks and _filtered_access() for batch work, override _check_access() when you need custom restrictions, and your access logic stays in one predictable place instead of scattered through overridden CRUD methods. For anyone maintaining custom Odoo modules, adopting the new API during a version 19 migration is one of the cheapest security improvements available.
Frequently Asked Questions
Does check_access() replace both check_access_rights() and check_access_rule()?
Yes. A single check_access(operation) call evaluates the ir.model.access entries and the applicable record rules, in that order, and raises an AccessError if either layer denies the operation.
What is the difference between check_access() and has_access() in Odoo 19?
They apply identical logic. check_access() raises an AccessError and returns nothing, while has_access() returns a boolean. Choose based on whether the calling code wants to fail or branch.
Can I call check_access() from JavaScript or an external RPC client?
No. It is marked private, so it is not exposed over RPC. Use has_access() for client side permission checks, or expose your own controller endpoint that performs the check server side.
Does check_access() still work when the environment is in superuser mode?
It short circuits. When env.su is true, both check_access() and has_access() return immediately without evaluating access rights or record rules, which is exactly why sudo() should never be used to make an access error disappear.
How do I restrict access to specific records in a custom module?
Define a record rule with a domain_force for the declarative cases, and override _check_access() for logic that a domain cannot express, such as state dependent deletion rules. Both are respected by all three public access methods.