Odoo Access Rights vs Record Rules: Getting Security Right

Abstract technical illustration for the article: Odoo Access Rights vs Record Rules: Getting Security Right

Sooner or later, every Odoo developer meets the two faces of Odoo security. The first face is a wall: a normal user clicks a button and gets You are not allowed to access ... while the administrator sails through. The second face is a leak: a user quietly sees — or edits — records they should never have known about. Both faces come from the same place, and both are fixed the same way: by understanding that Odoo's security model is not one mechanism but two, applied in a strict order.

The two mechanisms are access rights (ir.model.access) and record rules (ir.rule). Access rights answer the question “may this user touch this model at all?” Record rules answer the finer question “which rows of this model may this user touch?” Get the order and the interaction right and security stops being guesswork; get it wrong and you ship a system that is either unusable or untrustworthy.

Gate one: access rights are model-wide

Access rights are the first gate. For every model in the system, Odoo asks: does this user have the right to read, write, create, or delete records of this model at all? The answer lives in ir.model.access records, which by convention are loaded from security/ir.model.access.csv in your module. Each line grants one group four booleans on one model:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlinkaccess_vehicle_user,vehicle.user,model_fleet_vehicle,estate.group_fleet_user,1,1,1,0access_vehicle_manager,vehicle.manager,model_fleet_vehicle,estate.group_fleet_manager,1,1,1,1

Three properties of access rights matter more than the syntax:

  • Additive, never subtractive. A user's effective rights are the union of every group they belong to. There is no “deny” line anywhere. You restrict by not granting, never by overriding. If a user belongs to a group that grants write on a model, no other group can take that away.
  • Model-wide, not row-level. An access right answers “may she write vehicles at all?” It cannot express “may she write this vehicle but not that one?” That is what record rules are for.
  • Absent means denied. If no access line matches the user for a model, every operation on that model is denied to them. A brand-new custom model with no access line silently 403s for everyone except the superuser. This is the single most common first-module bug in Odoo.

Two subtleties deserve attention. First, an empty group_id:id in the CSV grants the line to every user — including portal and public (unauthenticated website) users. That is occasionally what you want, and often a security hole. Second, the internal superuser (__system__, uid 1) bypasses both gates entirely. That is why odoo shell works without any access lines, and why testing security as the administrator account is misleading: the admin login is not the superuser, but it is born into the most powerful groups, which hides real-user failures. The honest way to test is to impersonate a real user — or to re-run code with with_user(), which re-applies the full permission check.

Where groups fit in

Groups (res.groups) are the lever that moves both gates. You grant access rights to groups, attach record rules to groups, and then assign users to groups — or use implied inheritance, where membership in one group automatically pulls in another (the classic Sales / Administrator inheriting Sales / User). Keep your group design flat and semantic — “Fleet User”, “Fleet Manager” — rather than one group per field; you will thank yourself when the rule count grows.

Gate two: record rules are row-level

Once access rights allow a model, record rules decide which rows. A record rule is an ir.rule record, usually written in XML rather than CSV because rules are complex and rarely created in bulk:

<record id="rule_vehicle_own" model="ir.rule">    <field name="name">Fleet: users see their own vehicles</field>    <field name="model_id" ref="model_fleet_vehicle"/>    <field name="domain_force">[('user_id', '=', user.id)]</field>    <field name="groups" eval="[(4, ref('estate.group_fleet_user'))]"/>    <field name="perm_read" eval="True"/>    <field name="perm_write" eval="True"/>    <field name="perm_create" eval="True"/>    <field name="perm_unlink" eval="False"/></record>

The engine of the rule is domain_force: an Odoo domain (a list of conditions) evaluated with a small set of variables in scope — user (the current user as a singleton record), user.id, company_id, company_ids (the companies the user may access), and the time module. Non-matching records are silently filtered from read results, but raise an AccessError on write, create, or unlink.

The perm_* flags choose which operations the rule applies to — and an unselected operation is not checked at all. A rule that restricts read but leaves write off does not protect the records: the user can still write to rows the read rule was supposed to hide. Cover every operation you mean to restrict.

The evaluation algorithm you must memorize

For any operation, the rows a non-superuser may touch are computed as:

(AND of all global rules) AND (OR of the matching group rules)

In plain terms:

  • Global rules (rules with no groups set) are AND-ed together. Every global rule must pass. Adding a global rule can only tighten access — for everyone.
  • Group rules (rules attached to one or more groups) are OR-ed across the groups the user belongs to. Belonging to any group whose rule matches grants those rows.
  • The global set and the group set intersect. If the user has no group rules for the operation, only the globals apply.

Multi-company isolation is the same machinery: each company-aware module ships a global rule like ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] for its own models, which is why records from another company are invisible rather than editable. And sudo() skips the whole computation — including record rules — which makes it the standard tool for system-level code and the standard source of security holes when used carelessly in user-facing paths.

A worked example: fleet mechanics and their manager

Imagine a custom fleet.vehicle model. The requirement: mechanics see and edit only the vehicles assigned to them; the fleet manager sees everything.

Step one is the CSV — the model-wide gate:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlinkaccess_vehicle_mechanic,vehicle.mechanic,model_fleet_vehicle,fleet.group_fleet_mechanic,1,1,1,0access_vehicle_manager,vehicle.manager,model_fleet_vehicle,fleet.group_fleet_manager,1,1,1,1

At this point both roles can touch the model, but a mechanic can open any vehicle. Step two is the row filter — the record rule above, attached to the mechanics' group, with domain_force set to [('user_id', '=', user.id)]. Now trace the evaluation for each person:

  • A mechanic opening the list view: the access right grants read on the model, then the rule filters every row to those where user_id is her. She never sees her colleagues' vehicles.
  • A mechanic trying to delete a vehicle: the access right denies unlink outright (perm_unlink = 0), so the record rule never even enters the picture. Model-level denial short-circuits.
  • The manager: her access right grants everything, and no rule is attached to her group, so no rows are filtered. She sees all vehicles.

Notice the design principle: rights grant, rules only ever narrow. You cannot use a record rule to give someone access the CSV withheld. If you find yourself fighting a rule that “should allow” something, check the access right first — nine times out of ten it is the ACL, not the rule, blocking you.

The mistakes that break Odoo security

These are the failures seen in the wild, roughly in order of frequency:

  1. No access line on a new model. Custom model, no CSV line: silent denial for everyone but the superuser. Ship the CSV with the model.
  2. Rules that cover some operations but not all. A read-only rule with write left unchecked leaks. Audit the perm_* flags on every rule as carefully as the domain.
  3. Testing as admin or superuser. admin sits in the powerful groups and sudo() skips everything. Test as the actual role — a private browser window logged in as a demo user is worth a hundred code reviews.
  4. Global-rule arithmetic. Adding a second global rule ANDs with the first; non-overlapping globals can remove access entirely. Check the intersection before adding one.
  5. Public portal grants. An empty group_id in the CSV reaches portal and public users too. Fine-tune any line that touches base.group_portal or base.group_public.
  6. Confusing view invisibility with security. Hiding a field with invisible="1" in the XML view hides it from the UI only — the ORM still serves it. Real field-level control is the groups="..." attribute on the field definition, which the ORM enforces.
  7. sudo() in user-facing code paths. Every sudo() is a deliberate bypass of both gates. Use it for system work (scheduled actions, onboarding wizards) and never as a convenience shortcut in a controller or button handler.

Debugging a permission problem

When a user hits AccessError (or sees the wrong rows), work the gates in order: reproduce as the real user first; then check the ACL union under Settings → Technical → Security → Access Rights; then the record rules (developer mode) for a rule narrowing the rows unexpectedly; then confirm sudo() makes the failure vanish — that isolates it to the gates, not the business logic. In code, the ORM exposes the same checks explicitly: recordset.check_access_rights('write') for the model-level gate and recordset.check_access_rule('write') for the row-level gate. Odoo's own documentation recommends running both when you gate an operation manually, since checking rules without rights misses the first gate entirely.

Further reading

Similar Posts

Leave a Reply