Series: Odoo 18 Development for Python Developers Target Audience: Python developers who are new to web development and want to learn Odoo Prerequisites: Lessons 1–7 completed,
library_appmodule with views, menus, and chatter working
Odoo Security Model Overview
If you’ve been testing the library module as the admin user, everything works perfectly. But try logging in as a regular user — you’ll get “Access Denied” errors everywhere. That’s because Odoo’s security model follows a deny-by-default philosophy:
Nothing is accessible unless you explicitly grant access.
This is the opposite of most web frameworks (Django, Flask) where you add restrictions as needed. In Odoo, you add permissions as needed.
The Four Layers of Security
Odoo security works in layers, from broad to specific:
Layer 1: User Groups
"Which team does this user belong to?"
(e.g., Library User, Library Manager, Administrator)
│
▼
Layer 2: Access Control Lists (ACL)
"What can this group DO with this model?"
(e.g., Library Users can read and create books, but not delete them)
│
▼
Layer 3: Record Rules
"Which RECORDS can this group see?"
(e.g., Library Users can only see available books)
│
▼
Layer 4: Field-Level Access
"Which FIELDS can this group see?"
(e.g., Only managers can see the cost_price field)
Each layer adds granularity. You need at least Layers 1–2 for your module to work for non-admin users.
How a Request is Checked
When a user tries to read a book record, Odoo checks:
- Is the user in a group that has ACL access to
library.book? → If not, Access Denied - Does any record rule restrict which books this user can see? → If so, filter the results
- Does the user’s group have access to all requested fields? → If not, remove restricted fields
The admin user (base.group_system) bypasses most checks, which is why everything works for admin.
User Groups (res.groups) and Module Categories
What is a Group?
A group is a collection of users who share the same permissions. Think of groups like roles:
- Library User — Can view and borrow books
- Library Manager — Can manage the entire library (create, edit, delete books and authors)
Module Categories
Groups are organized under categories (also called “Application” categories). Categories appear in the user settings form as sections.
Defining Groups in XML
Create the file security/library_security.xml:
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ================================== -->
<!-- MODULE CATEGORY -->
<!-- ================================== -->
<record id="module_category_library" model="ir.module.category">
<field name="name">Library</field>
<field name="description">Library Management</field>
<field name="sequence">200</field>
</record>
<!-- ================================== -->
<!-- USER GROUPS -->
<!-- ================================== -->
<!-- Group 1: Library User (basic access) -->
<record id="group_library_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">
Library Users can view books and authors.
They can borrow and return books.
</field>
</record>
<!-- Group 2: Library Manager (full access) -->
<record id="group_library_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
<field name="comment">
Library Managers have full access to all library features.
They can create, edit, and delete books, authors, and tags.
</field>
</record>
</odoo>
Let’s break this down piece by piece:
The Category Record
<record id="module_category_library" model="ir.module.category">
<field name="name">Library</field>
<field name="sequence">200</field>
</record>
model="ir.module.category"— This is the model that stores application categoriesname— Appears as a section header in Settings → Users → user formsequence— Position in the list (lower = higher)
The Group Records
<record id="group_library_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
model="res.groups"— The model that stores security groupsname— The group name (shown as a radio button or checkbox in user settings)category_id— Links to the category (so it appears under “Library” in settings)implied_ids— Group inheritance. This group implies (includes) the specified groups
Group Inheritance with implied_ids
The implied_ids field creates a hierarchy of groups:
base.group_user (Internal User)
└── group_library_user (Library User)
└── group_library_manager (Library Manager)
This means:
- A Library Manager automatically has all permissions of a Library User
- A Library User automatically has all permissions of an Internal User (base.group_user)
- You don’t need to duplicate permissions — they cascade down
<!-- Library User implies Internal User -->
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<!-- Library Manager implies Library User (which implies Internal User) -->
<field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
(4, ref('...')) is the “link” command for Many2many fields — we learned this in Lesson 4.
How Groups Appear in the UI
After installing the module, go to Settings → Users & Companies → Users. Open any user, and you’ll see a new “Library” section with two options:
Library
○ (empty) — No library access
○ User — Basic access
○ Manager — Full access
The admin user can assign any user to one of these groups.
Access Control Lists (ACL) — ir.model.access.csv
ACLs define what operations a group can perform on a model. They answer the question: “Can users in this group Create, Read, Update, or Delete records in this model?”
The CSV File Format
Create the file security/ir.model.access.csv:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
library_book_user,library.book user,model_library_book,group_library_user,1,0,0,0
library_book_manager,library.book manager,model_library_book,group_library_manager,1,1,1,1
library_author_user,library.author user,model_library_author,group_library_user,1,0,0,0
library_author_manager,library.author manager,model_library_author,group_library_manager,1,1,1,1
library_tag_user,library.tag user,model_library_tag,group_library_user,1,0,0,0
library_tag_manager,library.tag manager,model_library_tag,group_library_manager,1,1,1,1
library_publisher_user,library.publisher user,model_library_publisher,group_library_user,1,0,0,0
library_publisher_manager,library.publisher manager,model_library_publisher,group_library_manager,1,1,1,1
This is a CSV file (Comma-Separated Values). Let’s understand each column:
Column Breakdown
| Column | Purpose | Example |
|---|---|---|
id |
Unique XML ID for this ACL record | library_book_user |
name |
Human-readable description | library.book user |
model_id:id |
The model to grant access to | model_library_book |
group_id:id |
The group receiving access | group_library_user |
perm_read |
Can read? (1=yes, 0=no) | 1 |
perm_write |
Can update? (1=yes, 0=no) | 0 |
perm_create |
Can create? (1=yes, 0=no) | 0 |
perm_unlink |
Can delete? (1=yes, 0=no) | 0 |
The model_id:id Naming Convention
The model_id:id value follows a specific pattern:
model_library_book
│ │ │
│ └───────┴── Model name with dots replaced by underscores
└── Always starts with 'model_'
Examples:
Model _name |
model_id:id |
|---|---|
library.book |
model_library_book |
library.author |
model_library_author |
library.tag |
model_library_tag |
res.partner |
model_res_partner |
Reading the ACL Table
Let’s read our CSV as a table to understand the permissions:
| Model | Library User | Library Manager |
|---|---|---|
library.book |
Read only | Full access (CRUD) |
library.author |
Read only | Full access |
library.tag |
Read only | Full access |
library.publisher |
Read only | Full access |
Since Library Manager implies Library User, a manager has both rows applied. Odoo uses the most permissive combination:
- Manager inherits User’s
read=1AND has their ownread=1, write=1, create=1, unlink=1 - Result: Manager has full CRUD access
Important Rules
- Every model needs at least one ACL line for each group that accesses it. If a group has no ACL for a model, users in that group get “Access Denied.”
- ACLs are additive. If a user belongs to multiple groups, they get the union (OR) of all permissions.
- Don’t forget transient models and related models. If your wizard uses
library.book.borrow.wizard, that model needs ACL too. - The admin group (
base.group_system) has access to everything by default — you don’t need ACL lines for admins.
Common Mistake: Missing ACL
Error: You are not allowed to access 'Library Tag' (library.tag) records.
This error means the current user’s group doesn’t have any ACL line for library.tag. Fix: add a line in the CSV.
Record Rules (ir.rule) — Row-Level Security
ACLs control what operations a group can do. Record rules control which records a group can see. Think of it as adding a WHERE clause to every database query.
The Concept
Without record rules:
- A Library User with read access to
library.booksees ALL books
With record rules:
- A Library User sees only books where
state = 'available' - A Library Manager sees all books (no restriction)
Defining Record Rules in XML
Add these to security/library_security.xml:
<!-- ================================== -->
<!-- RECORD RULES -->
<!-- ================================== -->
<!-- Rule 1: Library Users can only see available and borrowed books -->
<record id="library_book_user_rule" model="ir.rule">
<field name="name">Library Book: Users see available/borrowed</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[('state', 'in', ['available', 'borrowed'])]</field>
<field name="groups" eval="[(4, ref('group_library_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Rule 2: Library Managers can see all books (no restriction) -->
<record id="library_book_manager_rule" model="ir.rule">
<field name="name">Library Book: Managers see all</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_library_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
Record Rule Fields Explained
<record id="library_book_user_rule" model="ir.rule">
<field name="name">Library Book: Users see available/borrowed</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[('state', 'in', ['available', 'borrowed'])]</field>
<field name="groups" eval="[(4, ref('group_library_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
| Field | Purpose |
|---|---|
name |
Human-readable description |
model_id |
Which model this rule applies to |
domain_force |
The domain filter (which records are accessible) |
groups |
Which groups this rule applies to |
perm_read |
Apply this rule to read operations? |
perm_write |
Apply this rule to write operations? |
perm_create |
Apply this rule to create operations? |
perm_unlink |
Apply this rule to delete operations? |
The domain_force Field
This is a domain (like we learned in Lesson 4) that filters which records are accessible:
<!-- Only see available and borrowed books -->
<field name="domain_force">[('state', 'in', ['available', 'borrowed'])]</field>
<!-- See ALL records (no restriction) -->
<field name="domain_force">[(1, '=', 1)]</field>
<!-- (1, '=', 1) is always True — a common pattern for "no restriction" -->
<!-- Only see own records (records created by this user) -->
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<!-- 'user' is a special variable referring to the current user -->
<!-- Only see records from user's company -->
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<!-- 'company_ids' refers to the companies the user has access to -->
Special Variables in domain_force
Inside domain_force, you can use these special variables:
| Variable | Type | Meaning |
|---|---|---|
user |
res.users recordset |
The current user |
user.id |
Integer | The current user’s ID |
company_id |
Integer | The current user’s active company ID |
company_ids |
List of integers | All companies the user has access to |
time |
Python time module |
For date comparisons |
<!-- Example: Only see books that belong to the user's company -->
<field name="domain_force">[('company_id', '=', company_id)]</field>
<!-- Example: Only see books created in the last 30 days -->
<field name="domain_force">
[('create_date', '>=', (datetime.datetime.now() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]
</field>
How Multiple Record Rules Interact
Rules for the same group and model are combined with OR (union):
Rule A: domain = [('state', '=', 'available')] → for group_library_user
Rule B: domain = [('state', '=', 'borrowed')] → for group_library_user
Result: User sees records where state = 'available' OR state = 'borrowed'
Rules for different groups are combined with OR (if the user is in both groups):
Rule A: domain = [('state', '=', 'available')] → for group_library_user
Rule C: domain = [(1, '=', 1)] → for group_library_manager
If user is a Manager (which implies User):
Result: User sees ALL records (because Rule C matches everything)
Global Rules (No Group)
If you don’t assign a group to a record rule, it becomes a global rule — applied to ALL users (except superuser):
<!-- Global rule: nobody can see lost books (not even managers) -->
<record id="library_book_hide_lost_rule" model="ir.rule">
<field name="name">Library Book: Hide lost books globally</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[('state', '!=', 'lost')]</field>
<!-- No 'groups' field = global rule -->
</record>
Global rules are combined with AND with group rules. This means:
- Group rule says: user can see books where
state in ('available', 'borrowed') - Global rule says: nobody can see books where
state = 'lost' - Combined: user sees books where
state in ('available', 'borrowed')ANDstate != 'lost'
Use global rules sparingly — they restrict everyone, including managers.
Field-Level Access: groups Attribute on Fields
For finer control, you can restrict individual fields to specific groups:
# In models/book.py
cost_price = fields.Float(
string='Cost Price',
groups='library_app.group_library_manager',
# Only managers can see this field
)
internal_notes = fields.Text(
string='Internal Notes',
groups='library_app.group_library_manager,base.group_system',
# Visible to library managers AND system administrators
# Multiple groups separated by commas = OR logic
)
What happens in practice:
- If a Library User opens the book form,
cost_priceis completely invisible — the field doesn’t appear in the UI and is stripped from API responses - If a Library Manager opens the same form,
cost_priceis visible and editable - The
groupsattribute works on both Python field definitions and XML view elements
Groups on View Elements
You can also use groups in XML views:
<!-- Only managers see this group of fields -->
<group string="Financial" groups="library_app.group_library_manager">
<field name="cost_price"/>
<field name="profit_margin"/>
</group>
<!-- Only managers see this button -->
<button name="action_mark_lost"
type="object"
string="Mark as Lost"
groups="library_app.group_library_manager"/>
<!-- Only managers see this menu -->
<menuitem
id="library_config_menu"
name="Configuration"
parent="library_root_menu"
groups="library_app.group_library_manager"
sequence="90"/>
groups on Fields vs Views
| Where | Effect |
|---|---|
| On Python field | Field is completely hidden from the ORM for unauthorized users (both UI and API) |
| On XML view element | Element is hidden in the UI, but the field is still accessible via the API |
For true security, put groups on the Python field. The XML groups attribute is more of a UI convenience — a determined user could still access the field through the API if it’s not restricted at the Python level.
Superuser vs sudo() — When and Why
The Superuser
Odoo has a special superuser (also called the __system__ user). This user:
- Bypasses ALL security: ACLs, record rules, field-level groups
- Cannot log in through the web interface
- Is only accessible through code (
self.env.su) - Has
user.id = SUPERUSER_ID(usually 1)
The admin user (the one you log in with) is NOT the superuser. The admin user belongs to base.group_system, which has broad permissions — but still follows record rules and ACLs.
sudo() Recap
From Lesson 5, we know sudo() switches to the superuser:
# Normal: follows current user's security rules
books = self.env['library.book'].search([])
# sudo(): bypasses ALL security
all_books = self.env['library.book'].sudo().search([])
When to Use sudo()
| Scenario | Use sudo()? |
Why |
|---|---|---|
| Business logic needs to read a model the user can’t access | ✅ Yes | e.g., reading system settings |
| Creating log records in a restricted model | ✅ Yes | Users shouldn’t need direct access to log models |
| Checking permissions for another user | ✅ Yes | Need to read group memberships |
| Displaying data to the user | ❌ No | Users would see data they shouldn’t |
| Bypassing broken security rules | ❌ No | Fix the rules instead |
| “Making things work” without understanding why | ❌ No | You’re hiding a real problem |
Best Practice Pattern
def action_borrow_book(self):
self.ensure_one()
# Check something with sudo (need to read system config)
max_days = int(
self.env['ir.config_parameter']
.sudo()
.get_param('library.max_borrow_days', '14')
)
# Business logic runs with NORMAL permissions
# (don't use sudo for the actual operation)
self.write({
'state': 'borrowed',
'borrow_date': fields.Date.today(),
'return_date': fields.Date.today() + timedelta(days=max_days),
})
Multi-Company Security Rules
If your Odoo instance has multiple companies, you’ll need multi-company security to ensure users only see data from their own company.
Adding Company Support to Your Model
class LibraryBook(models.Model):
_name = 'library.book'
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
# Default to the current user's active company
)
Company Record Rule
<record id="library_book_company_rule" model="ir.rule">
<field name="name">Library Book: Multi-Company</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<!-- 'company_ids' = companies the user has access to -->
<field name="global" eval="True"/>
<!-- global=True means this applies to ALL users -->
</record>
For our library module, we won’t add multi-company support in the hands-on (to keep things simple). But in production, most business models should have a company_id field and a corresponding rule.
Common Security Pitfalls for New Developers
Pitfall 1: Forgetting ACL for a Model
Symptom: “You are not allowed to access ‘Library Tag’ records”
Fix: Add a line in ir.model.access.csv for the missing model.
Prevention: Every time you create a new model, immediately add ACL lines for it.
Pitfall 2: Missing ACL for Related Models
Symptom: The book form loads, but the Author dropdown is empty or shows an error.
Cause: The user has access to library.book but not to library.author. The Many2one dropdown tries to search library.author and fails.
Fix: Add read access to library.author for the same group.
Pitfall 3: Using sudo() Instead of Fixing Security
Symptom: Your code is full of .sudo() calls.
Cause: The security rules are too restrictive or missing.
Fix: Review and fix your ACLs and record rules. Reserve sudo() for legitimate cases (system config, cross-model operations).
Pitfall 4: Record Rule Blocks Create/Write
Symptom: User can see records but gets “Access Error” when trying to save.
Cause: The record rule’s domain filters out the record the user is trying to create (e.g., creating a draft book when the rule only allows state = 'available').
Fix: Make sure the record rule domain allows the states that users need to create. Or use separate perm_read / perm_create rules:
<!-- Rule for reading: only see available/borrowed -->
<record id="rule_read" model="ir.rule">
<field name="domain_force">[('state', 'in', ['available', 'borrowed'])]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Rule for writing: can edit any non-lost book -->
<record id="rule_write" model="ir.rule">
<field name="domain_force">[('state', '!=', 'lost')]</field>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
Pitfall 5: Forgetting noupdate
When you define record rules and later change them, Odoo might not update them because they’re protected by noupdate. We’ll cover this in Lesson 10 (Data Files), but for now:
- During development, you can manually delete old rules in Settings → Technical → Record Rules
- Or wrap your data in
to force updates
Security Checklist
Before releasing a module, check:
- [ ] All models have ACL entries in
ir.model.access.csv - [ ] At least two groups defined (User and Manager or similar)
- [ ] Record rules defined for sensitive data
- [ ]
groupsattribute on sensitive fields (cost, profit, internal notes) - [ ] Menus restricted with
groupswhere appropriate - [ ]
sudo()used only where truly necessary - [ ] Tested with a non-admin user
Hands-on: Define Groups, ACL, and Record Rules for the Library Module
Let’s make our library module work for non-admin users.
Step 1: Create security/library_security.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="0">
<!-- ================================== -->
<!-- MODULE CATEGORY -->
<!-- ================================== -->
<record id="module_category_library" model="ir.module.category">
<field name="name">Library</field>
<field name="description">Library Management Application</field>
<field name="sequence">200</field>
</record>
<!-- ================================== -->
<!-- USER GROUPS -->
<!-- ================================== -->
<!-- Library User: can read books/authors/tags, borrow and return books -->
<record id="group_library_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">
Library Users can browse the book catalog,
view author information, and borrow/return books.
</field>
</record>
<!-- Library Manager: full access to all library features -->
<record id="group_library_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_library"/>
<field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
<field name="comment">
Library Managers have full control over books, authors,
tags, and all library configuration.
</field>
</record>
<!-- ================================== -->
<!-- RECORD RULES -->
<!-- ================================== -->
<!-- Users: can read all active books (any state) -->
<record id="library_book_user_rule" model="ir.rule">
<field name="name">Library Book: User access</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[('active', '=', True)]</field>
<field name="groups" eval="[(4, ref('group_library_user'))]"/>
</record>
<!-- Managers: can access all books including archived -->
<record id="library_book_manager_rule" model="ir.rule">
<field name="name">Library Book: Manager full access</field>
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_library_manager'))]"/>
</record>
</data>
</odoo>
Step 2: Create security/ir.model.access.csv
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,group_library_user,1,1,0,0
access_library_book_manager,library.book.manager,model_library_book,group_library_manager,1,1,1,1
access_library_author_user,library.author.user,model_library_author,group_library_user,1,0,0,0
access_library_author_manager,library.author.manager,model_library_author,group_library_manager,1,1,1,1
access_library_tag_user,library.tag.user,model_library_tag,group_library_user,1,0,0,0
access_library_tag_manager,library.tag.manager,model_library_tag,group_library_manager,1,1,1,1
access_library_publisher_user,library.publisher.user,model_library_publisher,group_library_user,1,0,0,0
access_library_publisher_manager,library.publisher.manager,model_library_publisher,group_library_manager,1,1,1,1
Permission breakdown:
| Model | Library User | Library Manager |
|---|---|---|
| Book | Read + Write (can borrow/return) | Full CRUD |
| Author | Read only | Full CRUD |
| Tag | Read only | Full CRUD |
| Publisher | Read only | Full CRUD |
Library Users can write to books (to change state when borrowing/returning) but cannot create or delete books.
Step 3: Update __manifest__.py
Make sure security files are loaded first in the data list:
'data': [
'security/library_security.xml', # Groups and record rules (FIRST)
'security/ir.model.access.csv', # ACLs (after groups)
'views/book_views.xml',
'views/author_views.xml',
'views/tag_views.xml',
'views/menu_views.xml', # Menus (LAST)
],
Step 4: Add groups to the Configuration Menu
Update views/menu_views.xml to restrict the Configuration menu to managers:
<menuitem
id="library_config_menu"
name="Configuration"
parent="library_root_menu"
groups="group_library_manager"
sequence="90"/>
Step 5: Upgrade and Test
# Upgrade
docker compose exec odoo odoo -d odoo18dev -u library_app --stop-after-init
docker compose restart odoo
Step 6: Create a Test User
- Go to Settings → Users & Companies → Users
- Click “New”
- Fill in:
– Name: Library Test User – Email: libraryuser@example.com – Password: (set one)
- In the “Library” section, select “User”
- Save
Step 7: Test as the Library User
- Log out of the admin account
- Log in as
Library Test User - Check:
– ✅ Can you see the Library menu? – ✅ Can you see the book list? – ✅ Can you open a book form and view details? – ✅ Can you click “Borrow” and “Return” buttons? – ❌ Can you create a new book? (Should NOT be allowed) – ❌ Can you delete a book? (Should NOT be allowed) – ❌ Can you see the Configuration menu? (Should be hidden)
If all checks pass, your security is working correctly.
Summary & What’s Next
Key Takeaways
- Odoo security is deny-by-default. You must explicitly grant access.
- Four security layers: Groups → ACL → Record Rules → Field-Level.
- Groups define user roles. Use
implied_idsfor hierarchy (Manager implies User). - ACLs (
ir.model.access.csv) control CRUD operations per model per group. Every model needs ACL entries. - Record Rules (
ir.rule) add domain filters to restrict which records a group can see. Group rules combine with OR; global rules combine with AND. - Field-level
groupshide sensitive fields from unauthorized users. Putgroupson the Python field for true security. sudo()bypasses all security — use it sparingly and only for legitimate purposes.- Test with a non-admin user before releasing your module.
Security Cheat Sheet
# Define groups in XML:
<record id="group_library_user" model="res.groups">
<field name="name">User</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
# ACL in CSV:
# id, name, model_id:id, group_id:id, read, write, create, delete
access_book_user,book.user,model_library_book,group_library_user,1,0,0,0
# Record Rule in XML:
<record id="book_rule" model="ir.rule">
<field name="model_id" ref="model_library_book"/>
<field name="domain_force">[('state', '=', 'available')]</field>
<field name="groups" eval="[(4, ref('group_library_user'))]"/>
</record>
# Field-level security:
cost = fields.Float(groups='library_app.group_library_manager')
# Menu security:
<menuitem groups="library_app.group_library_manager"/>
What’s Next?
In Lesson 9: Inheritance — Odoo’s Most Powerful Pattern, we’ll learn:
- Classical inheritance (extending existing models)
- Prototype inheritance (copying models)
- Delegation inheritance (
_inherits) - View inheritance deep dive (XPath revisited)
- Extending built-in models like
res.partner
Inheritance is how professional Odoo development works — you rarely build from scratch, you extend what exists.
Exercises: 1. Create a third group
group_library_librarianthat sits between User and Manager. Librarians can create and edit books but cannot delete them. Update the ACL accordingly. 2. Add a record rule so that Library Users can only see books that areavailable(not borrowed, not draft, not lost). Test by logging in as the test user. 3. Add acost_pricefield tolibrary.bookwithgroups='library_app.group_library_manager'. Verify that the field is invisible when logged in as a Library User. 4. Test what happens when you try to access a model without ACL: – Remove the ACL line forlibrary.tagfor the User group – Upgrade the module – Log in as a Library User and try to open a book with tags – What error do you see? Restore the ACL and upgrade again 5. Create a global record rule that hides all books withstate = 'lost'from everyone (including managers). Test to verify even the admin can’t see lost books. Then remove the rule (global rules can be dangerous!).
Previous lesson: Lesson 7 — Advanced Views & Widgets Next lesson: Lesson 9 — Inheritance: Odoo’s Most Powerful Pattern
