Odoo Development

Access Security in Odoo — ir.model.access.csv and Record Rules (Odoo 16–19)

A complete developer reference for Odoo's two-layer security system: model-level CRUD permissions via ir.model.access.csv and record-level filtering via ir.rule. Verified for Odoo 18 & 19.

iWesabe Editorial TeamMay 8, 20219 min read

Odoo enforces access control at two independent layers. The first layer — model-level access — defines which user groups can perform create, read, write, and delete (CRUD) operations on a model as a whole. The second layer — record rules — further restricts which specific records within a model a user can see or modify. Both layers must be configured correctly for a secure module. Missing access control on a custom model is one of the most common causes of `AccessError` exceptions and unintended data exposure.

The Two Security Layers

Odoo access security — two-layer overview
LayerMechanismFile / modelControlsEvaluation
1 — Model accessir.model.access (ACL)security/ir.model.access.csvCan group X do CRUD on model Y at all?Checked first. If denied here, record rules are never evaluated.
2 — Record accessir.rule (Record Rules)security/*.xml (ir.rule records)Which specific records can group X read/write/create/delete?Checked second. Domain filter applied to the query.

Part 1 — Model Access: ir.model.access.csv

Every custom model must have at least one `ir.model.access` record, or all access to it is denied by default. The standard approach is to define these records in a CSV file at `security/ir.model.access.csv` and declare it in `__manifest__.py` under the `data` key.

ir.model.access.csv — Column Reference

ir.model.access.csv columns — Odoo 16–19
ColumnRequiredFormat / exampleNotes
idYesaccess_my_model_userExternal ID for this ACL row. Must be unique across the module. Convention: access_<model>_<group_suffix>.
nameYesaccess_my_model_userHuman-readable label. Can match id. Shown in the Access Rights UI.
model_id:idYesmodel_my_module_my_modelExternal ID of the model. Format: model_ + model name with dots → underscores. E.g. sale.order → model_sale_order.
group_id:idNobase.group_userExternal ID of the group. Leave empty to grant access to ALL users (no group restriction). Common values: base.group_user (internal users), base.group_system (admin).
perm_readYes0 or 11 = allow read. Required for any list or form view to open.
perm_writeYes0 or 11 = allow write (editing existing records).
perm_createYes0 or 11 = allow create (new records). A user with write but not create can edit but not add records.
perm_unlinkYes0 or 11 = allow delete. Set to 0 for regular users; reserve for managers.

ir.model.access.csv — Full Example

text
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink

# Internal users — read + write + create, no delete
access_project_task_user,access_project_task_user,model_project_task,base.group_user,1,1,1,0

# Managers — full CRUD
access_project_task_manager,access_project_task_manager,model_project_task,project.group_project_manager,1,1,1,1

# Portal users — read only
access_project_task_portal,access_project_task_portal,model_project_task,base.group_portal,1,0,0,0

Declaring Security Files in __manifest__.py

python
{
    'name': 'My Module',
    'depends': ['base'],
    'data': [
        # Security files must come BEFORE views in the data list
        # so that groups are defined before views reference them.
        'security/groups.xml',           # custom group definitions
        'security/ir.model.access.csv',  # model-level ACL
        'security/record_rules.xml',     # record rules (ir.rule)
        # Views and other data come after:
        'views/my_model_views.xml',
    ],
}

Part 2 — Groups (res.groups)

Groups are the link between users and access rights. A user belongs to one or more groups; ACL rows and record rules are keyed to groups. You can define custom groups for your module or reuse Odoo's built-in groups. Groups can inherit from other groups — a user in a child group automatically has all the access of the parent group.

xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <!-- Define a module-specific group category -->
    <record id="module_category_my_module" model="ir.module.category">
        <field name="name">My Module</field>
        <field name="sequence">100</field>
    </record>

    <!-- User group — basic access -->
    <record id="group_my_module_user" model="res.groups">
        <field name="name">User</field>
        <field name="category_id" ref="module_category_my_module"/>
        <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
    </record>

    <!-- Manager group — inherits user group, adds extra rights -->
    <record id="group_my_module_manager" model="res.groups">
        <field name="name">Manager</field>
        <field name="category_id" ref="module_category_my_module"/>
        <field name="implied_ids" eval="[(4, ref('group_my_module_user'))]"/>
        <field name="users" eval="[(4, ref('base.user_admin'))]"/>
    </record>
</odoo>

Part 3 — Record Rules (ir.rule)

Record rules add a domain filter on top of the model-level ACL. Even if a group has read permission on a model, a record rule can restrict them to only seeing records that match a domain — for example, records belonging to their own company or created by themselves. Record rules come in two types: global rules and group-specific rules.

Global vs. group-specific record rules — logic difference
Rule typegroups fieldLogic with other rulesUse case
Global ruleEmpty (no groups)AND — always applied, combined with all other rules using ANDEnforce a constraint on all users — e.g. company isolation in multi-company
Group-specific ruleOne or more groupsOR — among rules for the same group; AND with global rulesGrant extra access to a specific group — e.g. managers see all records

ir.rule XML Field Reference

ir.rule XML field reference
FieldTypeRequiredDescription
nameCharYesHuman-readable label shown in the Record Rules UI.
model_idMany2one → ir.modelYesThe model this rule applies to. Reference with ref='<module>.<model_external_id>'.
domain_forceChar (domain string)YesThe domain filter. Records matching this domain are accessible; others are invisible to the affected group.
groupsMany2many → res.groupsNoGroups this rule applies to. Leave empty for a global rule.
perm_readBooleanNo (default True)Apply this rule to read operations.
perm_writeBooleanNo (default True)Apply this rule to write operations.
perm_createBooleanNo (default True)Apply this rule to create operations.
perm_unlinkBooleanNo (default True)Apply this rule to delete operations.

Common Record Rule Patterns

xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>

  <!-- Pattern 1: Own-records rule
       Users can only read/write records they created.
       Global rule — applies to all users. -->
  <record id="rule_my_model_own_records" model="ir.rule">
    <field name="name">My Model: Own Records Only</field>
    <field name="model_id" ref="model_my_module_my_model"/>
    <field name="domain_force">[('create_uid', '=', user.id)]</field>
    <!-- No groups → global rule, applies to everyone -->
  </record>

  <!-- Pattern 2: Manager bypass
       Managers can see ALL records (overrides the own-records rule above
       for members of the manager group via OR logic). -->
  <record id="rule_my_model_manager_all" model="ir.rule">
    <field name="name">My Model: Manager Sees All</field>
    <field name="model_id" ref="model_my_module_my_model"/>
    <field name="domain_force">[(1, '=', 1)]</field>
    <field name="groups" eval="[(4, ref('my_module.group_my_module_manager'))]"/>
  </record>

  <!-- Pattern 3: Multi-company isolation
       Global rule — users can only see records of their own company.
       Combining allowed_company_ids ensures multi-company switching works. -->
  <record id="rule_my_model_company" model="ir.rule">
    <field name="name">My Model: Company Isolation</field>
    <field name="model_id" ref="model_my_module_my_model"/>
    <field name="domain_force">
      ['|',
        ('company_id', '=', False),
        ('company_id', 'in', company_ids)]
    </field>
    <!-- Global rule — no groups field -->
  </record>

</odoo>

Troubleshooting

Common access security errors and fixes
Error / symptomLikely causeFix
AccessError: You don't have the right to access objects of type <model>No ir.model.access row exists for this model and the user's groupAdd a row to ir.model.access.csv for the model and the correct group, then upgrade the module.
Record list is empty or a specific record cannot be opened — no error shownA record rule's domain_force is filtering out the records the user expects to seeIn developer mode go to Settings → Technical → Security → Record Rules and check rules on the model. Test the domain in a Python shell: self.env['my.model'].search([]).
External ID not found: <module>.group_xyz during module installgroups.xml is listed after ir.model.access.csv in __manifest__.py data listMove groups.xml above ir.model.access.csv in the data list — groups must load before ACL rows that reference them.
Admin user bypasses record rules — expected, not a bugOdoo's superuser (admin, uid=1) bypasses all record rules by designTo test record rules, use a non-admin user. In unit tests, use self.env['my.model'].with_user(regular_user).search([]) to simulate the non-admin context.
[(1, '=', 1)] domain in manager rule still blocks some recordsA global rule is applying AND logic on top of the manager's OR rule, narrowing resultsReview all global rules (no groups set) on the model — these always apply AND. If the global rule is too restrictive, scope it to non-manager groups or remove it from the manager's effective domain.

Version Notes

Access security across Odoo versions
Odoo versionKey changes affecting access security
Odoo 16No breaking changes to ACL CSV or ir.rule. company_ids variable available in domain_force for multi-company rules. `<list>` rename has no effect on security.
Odoo 17No breaking changes to ACL or record rule API. Odoo 17 tightens validation of domain_force strings — malformed domains that previously silently failed may now raise errors.
Odoo 18No breaking changes. ir.model.access.csv format unchanged. record rules API unchanged. New base groups added for the website/portal flows — check group inheritance if upgrading modules with portal rules.
Odoo 19No breaking changes to ACL or record rules. ORM performance improvements reduce overhead of domain_force evaluation on large tables.

Need a Security Audit or Custom Access Control for Your Odoo Modules?

Our Odoo-certified developers design and audit access control for Saudi businesses — including ZATCA-sensitive financial data, PDPL-compliant HR records, and multi-company isolation across group structures.

WhatsApp

Frequently Asked Questions

What happens if I install a module with no ir.model.access.csv file?
Any user who is not the Odoo superuser (admin, uid=1) will receive an `AccessError` when trying to read, write, create, or delete records on your custom model. The superuser bypasses all ACL checks by design. Always add at minimum a read-only ACL row for `base.group_user` if the model should be accessible to regular users.
Can I grant access to all users without assigning a specific group?
Yes — leave the `group_id:id` column empty in the CSV row. An ACL row with no group applies to every authenticated user. Use this carefully: it gives all internal users, portal users, and public users the same access level. For portal or public access, it is usually better to set `group_id:id` to `base.group_portal` or `base.group_public` explicitly.
What is the difference between perm_write and perm_create in the ACL?
`perm_write` controls whether the user can edit the fields of an existing record (`write()` ORM call). `perm_create` controls whether the user can create new records (`create()` ORM call). A user with `perm_write=1` and `perm_create=0` can open and edit existing records but cannot save a new one. Both are needed for a user who should be able to create and edit. `perm_unlink` (delete) is independent of both.
How do global record rules interact with group-specific rules?
Global rules (no groups) always apply with AND logic — every user must satisfy them. Group-specific rules apply with OR logic among rules for the same group. The final effective domain for a user is: (global rule 1 AND global rule 2 AND ...) AND (group rule A OR group rule B). This means a global rule that restricts to company_id cannot be bypassed by a group rule — it permanently narrows the result set for all users.
How do I write a record rule that lets users see only their own company's records in a multi-company setup?
Use `company_ids` (not `user.company_id.id`) in your domain_force to support multi-company switching. `company_ids` is a list of all company IDs the user has active. The domain `[('company_id', 'in', company_ids)]` shows records for all companies the user currently has switched to. Add `('company_id', '=', False)` as an OR alternative if the model has records that are not company-specific: `['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]`.
Can record rules restrict which fields a user sees, not just which records?
No. Record rules only filter which records are returned — they do not restrict field visibility. Field-level access control in Odoo is handled differently: use `groups` attribute on a `` element in the view XML to hide a field from users not in a specific group, or use `invisible` expressions for conditional hiding. For true field-level security (preventing server-side access), use a custom `_fields_view_get()` override or `ir.model.fields` access restrictions — both are advanced patterns beyond standard record rules.
iWesabe Editorial Team

iWesabe Editorial Team

Practitioner insights on Odoo ERP, ZATCA compliance, and Saudi enterprise digital operations — written by iWesabe's consulting, finance, and engineering teams.

About iWesabe

Related Articles