Access Rights (ir.model.access.csv)
Access rights are defined in a CSV file — security/ir.model.access.csv. Each row grants or denies CRUD permissions for a user group on a model.
CSV format:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
Real example:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_library_book_user,library.book user,model_library_book,library_management.group_library_user,1,0,0,0
access_library_book_librarian,library.book librarian,model_library_book,library_management.group_library_librarian,1,1,1,0
access_library_book_manager,library.book manager,model_library_book,library_management.group_library_manager,1,1,1,1
access_library_book_public,library.book public,model_library_book,base.group_public,1,0,0,0
| Column | Value | Meaning |
|---|---|---|
id | XML ID | Unique identifier for this access rule |
name | String | Human-readable description |
model_id:id | model_ + model name (dots→underscores) | The model this rule applies to |
group_id:id | XML ID of res.groups record | The user group; empty = all users |
perm_read | 1 or 0 | Can read/search records |
perm_write | 1 or 0 | Can update existing records |
perm_create | 1 or 0 | Can create new records |
perm_unlink | 1 or 0 | Can delete records |
The ir.model.access.csv file must be the FIRST item in your manifest's data list. If views load before security, Odoo may throw access errors during development.
Defining User Groups
User groups (res.groups) define categories of users with different permission levels. Define them in an XML file under security/:
<odoo>
<data>
<!-- Base group — all users start here -->
<record id="group_library_user" model="res.groups">
<field name="name">Library User</field>
<field name="category_id" ref="base.module_category_services_library"/>
<field name="users" eval="[(4, ref('base.user_demo'))]"/>
</record>
<!-- Librarian group — inherits from user -->
<record id="group_library_librarian" model="res.groups">
<field name="name">Librarian</field>
<field name="category_id" ref="base.module_category_services_library"/>
<field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
</record>
<!-- Manager group — inherits from librarian -->
<record id="group_library_manager" model="res.groups">
<field name="name">Library Manager</field>
<field name="category_id" ref="base.module_category_services_library"/>
<field name="implied_ids" eval="[(4, ref('group_library_librarian'))]"/>
</record>
</data>
</odoo>
implied_ids — a group that implies another group automatically grants all the implied group's permissions too. Manager implies Librarian implies User — a user in the Manager group has all three groups' permissions.
| XML ID | Who it represents |
|---|---|
base.group_public | Non-logged-in (anonymous) visitors |
base.group_portal | Portal users (customers with login) |
base.group_user | Internal users (all logged-in employees) |
base.group_system | Settings administrators |
base.group_erp_manager | Full ERP access |
Record Rules (ir.rule)
Record rules are row-level security filters — they restrict which records a user can see or modify, applied as an automatic domain filter on every query.
<odoo>
<data noupdate="1">
<!-- Users can only see their own borrowings -->
<record id="rule_borrowing_user" model="ir.rule">
<field name="name">Borrowings: see own only</field>
<field name="model_id" ref="model_library_borrowing"/>
<field name="groups" eval="[(4, ref('group_library_user'))]"/>
<field name="domain_force">[('borrower_id', '=', user.id)]</field>
<field name="perm_read">1</field>
<field name="perm_write">1</field>
<field name="perm_create">1</field>
<field name="perm_unlink">0</field>
</record>
<!-- Librarians can see all borrowings in their branch -->
<record id="rule_borrowing_librarian" model="ir.rule">
<field name="name">Borrowings: librarian sees branch</field>
<field name="model_id" ref="model_library_borrowing"/>
<field name="groups" eval="[(4, ref('group_library_librarian'))]"/>
<field name="domain_force">[('branch_id', 'in', user.branch_ids.ids)]</field>
</record>
<!-- Global rule: never show deleted/archived records -->
<record id="rule_borrowing_global" model="ir.rule">
<field name="name">Borrowings: active only (global)</field>
<field name="model_id" ref="model_library_borrowing"/>
<!-- No groups = global rule (applies to everyone) -->
<field name="domain_force">[('active', '=', True)]</field>
</record>
</data>
</odoo>
| Access Rights | Record Rules | |
|---|---|---|
| Scope | All records of a model | Specific records matching a domain |
| Level | Model level | Row level |
| Defined in | CSV file | XML file |
| When applied | Every CRUD operation | As an additional WHERE clause on queries |
| Multiple rules | Combined with OR (any rule allows = access granted) | Combined with OR per group |
Field-Level Security
Restrict specific fields to certain groups using the groups attribute on the field definition:
class LibraryBook(models.Model):
_name = 'library.book'
name = fields.Char(string='Title')
cost_price = fields.Float(
string='Cost Price',
groups='library_management.group_library_manager', # only managers see this
)
internal_note = fields.Text(
string='Internal Notes',
groups='library_management.group_library_librarian',
)
Fields with groups are invisible AND not sent in RPC responses to users outside the group — this is server-side security, not just UI hiding.
Or in view XML:
<!-- Hide a field in the view for non-managers (client-side only — use Python groups for real security) -->
<field name="cost_price" groups="library_management.group_library_manager"/>
Using sudo() to Bypass Access Rights
sudo() gives a recordset superuser rights for a specific operation. Use carefully:
def action_auto_confirm(self):
# This runs as the current user — may fail if user lacks write access
self.write({'state': 'confirmed'})
def action_system_confirm(self):
# Run as superuser — bypasses all access checks
# Use only when the business logic requires it and you've manually checked permissions
self.sudo().write({'state': 'confirmed'})
# sudo() with a specific user
def action_confirm_as_admin(self):
admin = self.env.ref('base.user_admin')
self.sudo(admin).write({'state': 'confirmed'})
Never use sudo() to silently work around an access error you don't understand. Always fix the access rights properly. Reserve sudo() for intentional privilege elevation — background jobs, automated flows, or cases where the operation legitimately needs to run as a different user.
Checking and Debugging Access Rights
In developer mode, go to Settings → Technical → Security → Access Rights to see all loaded rules. To check rights programmatically:
# Check if current user can read a model
self.env['library.book'].check_access_rights('read', raise_exception=False)
# Returns True or False
# Check access on specific records
self.env['library.book'].browse([1, 2, 3]).check_access_rule('write')
# Raises AccessError if any record is blocked
# Useful for debugging — print current user's groups
print(self.env.user.groups_id.mapped('full_name'))
📋 Key Points
ir.model.access.csvdefines CRUD permissions per model per group — load it FIRST in manifestmodel_id:idformat:model_+ model_namewith dots→underscores- User groups (
res.groups) define permission tiers;implied_idscreates inheritance - Record rules add row-level domain filters — applied automatically as WHERE clauses
- Field-level security via
groupsattribute on field definitions (server-side, not just UI) sudo()bypasses access rights — use deliberately and sparingly
FAQ
They get an AccessError when trying to read, write, create, or delete records on that model. An empty CSV or missing access rule means no access at all — not full access. Security in Odoo is deny-by-default.
groups on a <field> in a view vs in the Python model? +In a view, groups hides the field from the UI — it's still sent in RPC if you know the field name. In the Python model definition, groups prevents the field value from being sent at all in RPC responses, and makes it invisible in all views automatically. Use Python-level groups for true security, view-level groups for UX.
Yes. A record rule with no groups field is a global rule — it applies to all users including administrators. Use global rules for fundamental data constraints like "never show archived records" or "multi-company isolation".
(4, ref('base.group_user')) syntax mean? +This is an Odoo ORM command for Many2many fields. 4 means "link this record without removing existing links". Other commands: 3 = unlink (disconnect), 6, 0, [ids] = replace all with these IDs. eval="[(4, ref('base.group_user'))]" links the base.group_user group to this record's Many2many field.