Your Users Can See (and Edit) More Data Than You Think
Every Odoo implementation starts with a demo where everything works because the admin user has full access. Then the system goes live, 40 employees log in, and within a week someone in marketing discovers they can read every employee's salary. A warehouse user accidentally edits a confirmed purchase order. A portal customer finds they can see invoices belonging to other companies in the group.
These aren't bugs in Odoo. They're the default behavior when access control is not explicitly configured. Odoo 19's security model is powerful — groups, record rules, field-level attributes, and multi-company isolation give you granular control over who sees what. But the framework is permissive by design: if you don't define a rule, access is granted, not denied.
This guide covers the complete access control stack in Odoo 19 — from defining res.groups and group categories, through ir.model.access CRUD matrices and ir.rule record rules, to field-level groups attributes and the dangerous power of sudo(). Every example is production-tested across multi-company deployments with portal users, external integrators, and regulatory audit requirements.
Defining Security Groups in Odoo 19: res.groups, Categories, and Implied Groups
Security groups are the foundation of Odoo's access control. Every permission check in the framework ultimately resolves to "does this user belong to group X?" Groups are defined as res.groups records, organized into categories for the Settings UI, and linked via implied_ids to create permission hierarchies.
Odoo 19 uses two patterns for group hierarchies: independent groups (user can have any combination) and exclusive selection groups (user gets exactly one level within a category, where each level implies the ones below). The Sales module is a classic example: a "Sales Manager" implies "Salesperson" implies "Own Documents Only."
<odoo>
<!-- ── Group Category (appears in Settings → Users) ── -->
<record id="module_category_warehouse_advanced" model="ir.module.category">
<field name="name">Warehouse Advanced</field>
<field name="description">Advanced warehouse security controls</field>
<field name="sequence">20</field>
</record>
<!-- ── Level 1: Read-only access ── -->
<record id="group_warehouse_viewer" model="res.groups">
<field name="name">Viewer</field>
<field name="category_id" ref="module_category_warehouse_advanced"/>
<field name="comment">Can view stock levels and transfers. Cannot modify.</field>
</record>
<!-- ── Level 2: Operator (implies Viewer) ── -->
<record id="group_warehouse_operator" model="res.groups">
<field name="name">Operator</field>
<field name="category_id" ref="module_category_warehouse_advanced"/>
<field name="implied_ids" eval="[(4, ref('group_warehouse_viewer'))]"/>
<field name="comment">Can process transfers and adjustments.</field>
</record>
<!-- ── Level 3: Manager (implies Operator → Viewer) ── -->
<record id="group_warehouse_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_warehouse_advanced"/>
<field name="implied_ids" eval="[(4, ref('group_warehouse_operator'))]"/>
<field name="comment">Full warehouse access including configuration.</field>
</record>
</odoo> The implied_ids chain is critical. When you assign a user to "Manager," Odoo automatically adds them to "Operator" and "Viewer" as well. This means your ir.model.access rules only need to reference the lowest-level group for read access — the hierarchy propagates upward automatically.
Your module's __manifest__.py must reference the security files in the correct load order — groups first, then model access, then record rules. If record rules reference groups that haven't been loaded yet, the module installation fails with a cryptic ValueError: External ID not found.
{
'name': 'Warehouse Advanced',
'version': '19.0.1.0.0',
'category': 'Inventory',
'depends': ['stock', 'base'],
'data': [
# 1. Groups MUST load before access rules and record rules
'security/security_groups.xml',
# 2. Model access references groups defined above
'security/ir.model.access.csv',
# 3. Record rules reference both groups and models
'security/record_rules.xml',
# 4. Views can now use groups= attributes safely
'views/custom_transfer_views.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
} When multiple groups share the same category_id and form an implied chain, Odoo renders them as a dropdown selector in Settings → Users. The user gets exactly one level. If your groups are independent (e.g., "Can Export" and "Can Archive" are unrelated), use different categories or no category at all — they'll render as checkboxes instead.
The ir.model.access.csv CRUD Matrix: Who Can Touch Which Models
Model-level access is defined in ir.model.access.csv — a CSV file in your module's security/ directory. Each row grants a specific group Create, Read, Update, and/or Delete access to an entire model. If no row exists for a group-model combination, access is denied by default. This is the first layer of defense: before Odoo even evaluates record rules or field-level security, it checks whether the user's groups have model access at all.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_custom_transfer_viewer,custom.transfer.viewer,model_custom_transfer,group_warehouse_viewer,1,0,0,0
access_custom_transfer_operator,custom.transfer.operator,model_custom_transfer,group_warehouse_operator,1,1,1,0
access_custom_transfer_manager,custom.transfer.manager,model_custom_transfer,group_warehouse_manager,1,1,1,1
access_custom_transfer_line_viewer,custom.transfer.line.viewer,model_custom_transfer_line,group_warehouse_viewer,1,0,0,0
access_custom_transfer_line_operator,custom.transfer.line.operator,model_custom_transfer_line,group_warehouse_operator,1,1,1,0
access_custom_transfer_line_manager,custom.transfer.line.manager,model_custom_transfer_line,group_warehouse_manager,1,1,1,1| Group | Read | Write | Create | Delete | Use Case |
|---|---|---|---|---|---|
| Viewer | Yes | No | No | No | Dashboard users, auditors, executives who only view data |
| Operator | Yes | Yes | Yes | No | Day-to-day users who create and edit but can't delete records |
| Manager | Yes | Yes | Yes | Yes | Full access including deletion — typically team leads |
A common mistake is leaving perm_unlink = 1 for operational groups. In most business processes, deletion should be restricted to managers. Operators should archive (deactivate) records instead. Deletion removes audit trails, breaks relational integrity, and can't be undone without a database restore.
Notice that the CSV references model_custom_transfer — this is the XML ID that Odoo generates from your model's _name. For a model named custom.transfer, the corresponding ir.model XML ID is model_custom_transfer (dots replaced with underscores). Getting this wrong is a silent error: the CSV loads without complaint, but the access rule points to a nonexistent model and has no effect.
Also watch the id column (first column). Each ID must be unique across your entire module. We use a consistent naming convention: access_{model}_{group}. Duplicate IDs cause the second rule to silently overwrite the first — another common source of "my access rules don't work" bugs.
If you leave the group_id column empty in ir.model.access.csv, that access rule applies to all users, including portal and public users. This is almost never what you want. Always specify a group. We've seen custom modules accidentally expose internal models to the website because of a missing group reference in the CSV.
Record Rules (ir.rule): Domain-Based Row-Level Security in Odoo 19
Model access answers "can this user read invoices?" Record rules answer "which invoices can this user read?" They're domain filters applied at the ORM level — before results reach the controller, before they reach the view, before they reach the user. Record rules are the backbone of multi-company isolation, department-level data separation, and "own documents only" patterns.
Odoo 19 evaluates record rules in two categories: global rules (no group assigned) which apply to every user with an AND operator, and group rules (assigned to one or more groups) which are combined with OR within the same model. Understanding this distinction is the difference between a secure system and one that leaks data.
<odoo>
<!-- ── Global Rule: Multi-company isolation ── -->
<!-- No groups → applies to ALL users (AND logic) -->
<record id="rule_custom_transfer_company" model="ir.rule">
<field name="name">Custom Transfer: Multi-Company</field>
<field name="model_id" ref="model_custom_transfer"/>
<field name="global" eval="True"/>
<field name="domain_force">
['|', ('company_id', '=', False),
('company_id', 'in', company_ids)]
</field>
</record>
<!-- ── Group Rule: Operators see own warehouse only ── -->
<record id="rule_custom_transfer_operator" model="ir.rule">
<field name="name">Custom Transfer: Operator Own Warehouse</field>
<field name="model_id" ref="model_custom_transfer"/>
<field name="groups" eval="[(4, ref('group_warehouse_operator'))]"/>
<field name="domain_force">
['|', ('warehouse_id.responsible_id', '=', user.id),
('warehouse_id.member_ids', 'in', [user.id])]
</field>
<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>
<!-- ── Group Rule: Managers see all transfers ── -->
<record id="rule_custom_transfer_manager" model="ir.rule">
<field name="name">Custom Transfer: Manager Full Access</field>
<field name="model_id" ref="model_custom_transfer"/>
<field name="groups" eval="[(4, ref('group_warehouse_manager'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- ── Portal Rule: Partners see own transfers only ── -->
<record id="rule_custom_transfer_portal" model="ir.rule">
<field name="name">Custom Transfer: Portal Own Records</field>
<field name="model_id" ref="model_custom_transfer"/>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="domain_force">
[('partner_id', '=', user.partner_id.id)]
</field>
</record>
</odoo>Global vs Group Rules: The AND/OR Logic
| Rule Type | Groups Field | Combination Logic | Effect |
|---|---|---|---|
| Global | Empty (no groups) | AND with all other rules | Restricts everyone — cannot be bypassed by group rules. Used for multi-company isolation. |
| Group | One or more groups | OR with other group rules for same model | If a user is in multiple groups with different rules, they get the union of all matching records. |
The OR behavior of group rules is the most misunderstood part of Odoo security. If an operator rule limits users to "own warehouse" and a manager rule grants "all records," a user who has both groups gets all records — because the rules are OR'd. This is by design: the manager permission is additive. But if you accidentally assign both groups to a regular user, they get manager-level visibility without manager-level intent.
Record rules also support per-operation permissions via the perm_read, perm_write, perm_create, and perm_unlink fields. In the example above, the operator rule sets perm_unlink to False — even though the operator has model-level delete access (from ir.model.access), the record rule restricts which records they can delete. This is the intended layering: model access controls the verb, record rules control the noun.
One pattern we use frequently in Odoo 19 multi-company deployments is the shared master data rule. Product templates, payment terms, and fiscal positions often need to be visible across all companies but editable only by the company that owns them. The record rule domain for this pattern is: ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] for reads, combined with a separate write rule: ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)].
In Odoo 19 record rule domains, company_ids refers to the list of companies the current user has access to (set in Settings → Users → Allowed Companies). The pattern ('company_id', 'in', company_ids) is the standard multi-company filter. Always include ('company_id', '=', False) as an OR clause to handle records with no company (shared master data like product templates).
Field-Level Security: The groups Attribute and Sensitive Data Protection
Model access controls who can see records. Record rules control which records they see. Field-level security controls which columns are visible on those records. In Odoo 19, any field can be restricted to specific groups using the groups attribute — both in the Python model definition and in the XML view.
When a user without the required group accesses a record, the restricted field simply doesn't exist from their perspective. The ORM strips it from search results, form views, and API responses. It's not hidden with CSS — the data never leaves the server.
from odoo import models, fields, api
class CustomTransfer(models.Model):
_name = 'custom.transfer'
_description = 'Warehouse Transfer'
name = fields.Char(string='Reference', required=True, copy=False)
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('done', 'Done'),
('cancelled', 'Cancelled'),
], default='draft', tracking=True)
# ── Visible to everyone with model access ──
partner_id = fields.Many2one('res.partner', string='Customer')
warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse')
scheduled_date = fields.Datetime(string='Scheduled Date')
# ── Restricted to Operators and above ──
internal_notes = fields.Text(
string='Internal Notes',
groups='my_module.group_warehouse_operator',
)
# ── Restricted to Managers only ──
cost_price = fields.Monetary(
string='Cost Price',
groups='my_module.group_warehouse_manager',
currency_field='currency_id',
)
margin_percent = fields.Float(
string='Margin %',
groups='my_module.group_warehouse_manager',
compute='_compute_margin',
)
override_reason = fields.Text(
string='Override Reason',
groups='my_module.group_warehouse_manager',
)
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company)
@api.depends('cost_price')
def _compute_margin(self):
for rec in self:
rec.margin_percent = 0.0 # actual logic omitted You can also restrict fields at the view level using the groups attribute on XML elements. This is useful when you want to show a field from a base model only to certain groups without modifying the Python model:
<record id="view_custom_transfer_form" model="ir.ui.view">
<field name="name">custom.transfer.form</field>
<field name="model">custom.transfer</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group string="General">
<field name="name"/>
<field name="partner_id"/>
<field name="warehouse_id"/>
<field name="scheduled_date"/>
</group>
<group string="Financial"
groups="my_module.group_warehouse_manager">
<field name="cost_price"/>
<field name="margin_percent"/>
<field name="override_reason"/>
</group>
</group>
<!-- Notes tab: visible to operators and above -->
<notebook>
<page string="Internal Notes"
groups="my_module.group_warehouse_operator">
<field name="internal_notes"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>Model-level groups (on the Python field) is enforced by the ORM — the data is stripped server-side regardless of which view renders it. View-level groups (on the XML element) only hides the element in that specific view. If the same field appears in another view without a groups restriction, it's visible. For sensitive data like cost prices and salaries, always use model-level groups. View-level groups are for UX convenience, not security.
sudo(), with_user(), and Security Bypasses: When and How to Elevate Privileges
Sometimes business logic requires accessing data that the current user can't see. A salesperson confirming an order needs to decrement stock in a warehouse they don't have access to. A portal user submitting a helpdesk ticket needs to create an hr.ticket record on a model they can't normally write to. This is where sudo() enters — and where most security mistakes happen.
from odoo import models, api
from odoo.exceptions import AccessError
class CustomTransfer(models.Model):
_inherit = 'custom.transfer'
def action_confirm(self):
"""Confirm transfer — needs to update stock across companies."""
self.ensure_one()
# BAD: sudo() on the entire method — bypasses ALL security
# self.sudo().write({{'state': 'confirmed'}})
# GOOD: sudo() only for the cross-boundary operation
self.write({{'state': 'confirmed'}}) # runs as current user
# Create stock move in target warehouse (may be different company)
stock_move = self.env['stock.move'].sudo().create({{
'product_id': self.product_id.id,
'location_id': self.source_location_id.id,
'location_dest_id': self.dest_location_id.id,
'product_uom_qty': self.quantity,
'company_id': self.dest_warehouse_id.company_id.id,
}})
# IMPORTANT: validate before sudo() — never trust user input
if self.quantity <= 0:
raise AccessError("Quantity must be positive.")
def action_portal_submit(self):
"""Portal user submits a request — needs elevated create."""
# Validate the user is the record owner BEFORE elevating
if self.partner_id != self.env.user.partner_id:
raise AccessError("You can only submit your own transfers.")
# with_user() — run as a specific user instead of superuser
system_user = self.env.ref('base.user_admin')
self.with_user(system_user).write({{
'state': 'submitted',
'submitted_date': fields.Datetime.now(),
}})Multi-Company Security Patterns
Multi-company setups introduce a second dimension of access control. A user might have access to Company A and Company B but should only see Company A's data when working in Company A's context. Odoo 19 handles this with the company_ids / company_id pattern in record rules, but cross-company operations (intercompany invoices, shared warehouses) require careful sudo() or with_company() usage.
The with_company() method switches the environment's active company without changing the user. This is essential when a scheduled action needs to process records across companies sequentially — the record rules re-evaluate for each company context, so the cron sees only the relevant records per iteration:
self.with_company(company_a).search([])— returns records wherecompany_id = company_a, respecting the user's existing group permissions.self.sudo().with_company(company_b).create(vals)— creates a record in company_b's context as superuser. Thecompany_idfield is automatically set to company_b.- Never use
sudo()alone for cross-company operations — sudo bypasses record rules but doesn't change the active company. The record will be created with the wrongcompany_id.
The golden rules for sudo():
- Validate before elevating. Check user permissions, ownership, and input sanity before calling
sudo(). Once you're in sudo mode, all security checks are bypassed. - Scope it narrowly. Don't
sudo()a whole method. Elevate only the specific ORM call that needs it, then drop back to the normal user context. - Never sudo() user-supplied domains. If a user can control the domain passed to
search()orfiltered(), callingsudo()on that search lets them read any record in the database. - Prefer
with_user()oversudo()when you need to act as a specific user rather than as superuser.sudo()disables all access checks;with_user()applies that user's actual permissions.
Portal users (base.group_portal) and public users (base.group_public) share the same ORM but have no internal group memberships by default. Every model your portal users need to access requires explicit ir.model.access entries for base.group_portal and dedicated record rules limiting them to their own data. Forgetting this is how customer A sees customer B's invoices on the portal.
4 Access Control Mistakes That Leak Data in Production Odoo Deployments
Missing Record Rules on Custom Models
You create a custom model, add ir.model.access.csv with proper CRUD permissions, and consider security "done." But without record rules, every user with read access sees every record. In a multi-company setup, Company A's employees see Company B's internal data. We've audited systems where custom CRM models exposed pipeline data across competing subsidiaries because nobody added the standard multi-company record rule.
Every custom model with a company_id field gets a global multi-company record rule as a minimum. We use a code review checklist: if the model has company_id, check for ir.rule with ('company_id', 'in', company_ids). If the model has user_id or partner_id, check for "own documents" rules per group.
Using sudo() to "Fix" AccessError Instead of Fixing the Root Cause
A developer hits an AccessError during testing. Instead of figuring out which group or record rule is missing, they wrap the call in sudo() and move on. The feature works in development. In production, that sudo() call is now a privilege escalation vulnerability — any user who triggers that code path operates with superuser access on that model. We've seen sudo() calls in invoice confirmation workflows that let warehouse users modify accounting journal entries.
We grep for sudo() in every code review and require a comment explaining why each one exists. Legitimate uses: cross-company stock moves, portal user creating internal records, scheduled actions running without a user context. Red flags: sudo() in form button methods, sudo() in search operations, any sudo() without input validation before it.
OR Logic Between Group Rules Creates Unintended Access Unions
You define a "Sales Team A" rule: [('team_id', '=', 1)] and a "Sales Team B" rule: [('team_id', '=', 2)]. You assign a user to both groups for cross-team visibility. Now that user sees teams A and B — as expected. But then someone adds a "Sales Manager" rule with [(1, '=', 1)] (see all). Because group rules are OR'd, any user with Manager group plus a team rule gets all records. The team restriction becomes meaningless the moment someone gets the Manager group through the implied hierarchy.
Design group hierarchies so that higher levels explicitly replace lower-level rules, not combine with them. Use the implied_ids chain correctly: if Manager implies Operator, the Manager record rule should be [(1, '=', 1)] and the Operator rule should handle the restriction. Document the intended access matrix and test it by actually logging in as users at each level.
Forgetting ir.model.access for Relational Fields
Your custom model references hr.employee via a Many2one field. Users can read your custom model but don't have access to hr.employee. When they open a form view, the Many2one field throws an AccessError because the ORM tries to name_get on the related employee record. The user sees a broken form. The developer adds read access to hr.employee for the group, which now exposes the entire employee list in dropdowns and search — including salary-related data on computed fields.
Use related fields to expose only the specific columns needed: employee_name = fields.Char(related='employee_id.name', string='Employee'). This way the user sees the employee name without needing direct read access to the hr.employee model. For the Many2one widget itself, use options="{{'no_open': True, 'no_create': True}}" in the view to prevent users from drilling into the related record.
What Proper Access Control Saves Your Business
Access control isn't a compliance checkbox — it's operational risk management. Here's the real-world impact:
Proper record rules prevent cross-company data leaks. In regulated industries, a single employee accessing unauthorized records can trigger fines starting at $10,000 under GDPR and SOX.
Read-only viewer groups and field-level restrictions eliminate "someone changed the confirmed PO" incidents that cost hours of investigation and data correction.
Documented group hierarchies, record rules, and field restrictions give auditors a clear access matrix. No more "can you show me who can see what?" fire drills.
Beyond compliance: well-designed access control simplifies the user experience. When a warehouse operator logs in and sees only their warehouse's transfers, the system feels fast and relevant. When they see 10,000 records from every department, the system feels overwhelming and slow. Security and usability are aligned here — restricting data improves both.
Odoo 19 Security Audit: Access Control Review Points
Before any Odoo deployment goes live, we run through this checklist. Every "no" is a finding that gets remediated before launch. We've condensed this from over 100 audits into the items that catch the most common vulnerabilities:
- Every custom model has
ir.model.access.csventries — no model should be accessible without explicit group permissions. - Every model with
company_idhas a global multi-company record rule — the('company_id', 'in', company_ids)pattern must be present. - Portal-accessible models have dedicated portal record rules — limiting portal users to
('partner_id', '=', user.partner_id.id)or equivalent. - Sensitive fields use model-level
groups— cost prices, margins, salaries, and internal notes restricted at the Python field definition, not just the view. - Every
sudo()call is documented and validated — input validation happens before elevation, and the scope is as narrow as possible. - Group hierarchies are tested by logging in as real users — not just checking group memberships in the admin panel, but actually navigating the system at each permission level.
- Deletion is restricted to manager-level groups — operational users archive records instead of deleting them.
- No empty
group_idinir.model.access.csvfor internal models — public access must be intentional, never accidental. - Relational fields don't expose restricted models — Many2one fields to
hr.employee,account.move, or other sensitive models use related fields to expose only the name, not full model access. - XML IDs for security records follow naming conventions — consistent naming like
group_{module}_{role}andrule_{model}_{scope}makes auditing possible without reading every file.
Debugging AccessError in Odoo 19: Where to Look When Users Can't Access Data
When a user reports "I can't see record X" or gets an AccessError, the investigation follows a consistent flow: model access → record rules → field groups → related model access. Here's the diagnostic sequence we use:
- Step 1: Check
ir.model.accessfor the target model — does the user's group have the required CRUD bit? Use Settings → Technical → Security → Access Controls List. - Step 2: Check
ir.rulefor the target model — is a record rule filtering out the record? Toggle developer mode and go to Settings → Technical → Security → Record Rules. Test the domain with the user's context. - Step 3: Check field-level
groups— is the field restricted to a group the user doesn't belong to? Inspect the Python model definition. - Step 4: Check related models — does the record reference a model (via Many2one) that the user can't read? The
name_getcall on the related record triggers the AccessError even though the main record is accessible. - Step 5: Check the Odoo server log with
--log-level=debug— the log shows exactly which security check failed and for which record ID.
The most confusing AccessErrors come from computed fields that traverse relational boundaries. A computed field on your model calls self.partner_id.property_payment_term_id. The payment term is on account.payment.term, which requires the Invoicing group. Your user has warehouse access but not invoicing access. The error message says "Access denied on account.payment.term" — which looks completely unrelated to the warehouse form they're trying to open.
In the Odoo shell (odoo-bin shell), you can test access as any user: env['custom.transfer'].with_user(user_id).search([]). This returns only the records that user can see after all access rules and record rules are applied. Compare the result count across users to verify your rules work as intended. For field-level issues, try env['custom.transfer'].with_user(user_id).read(['field_name']) to see if a specific field triggers the error.
For production systems, we recommend maintaining a security test suite as part of your CI pipeline. Each test logs in as a specific user type (viewer, operator, manager, portal) and asserts which records and fields are accessible. When someone modifies a record rule or group hierarchy, the test catches unintended access changes before deployment. This is especially critical after Odoo version upgrades, which can alter base group implied_ids chains.
Optimization Metadata
Complete guide to Odoo 19 access control. Covers res.groups, ir.model.access.csv, record rules (ir.rule), field-level groups, sudo() patterns, and multi-company security.
1. "Defining Security Groups in Odoo 19: res.groups, Categories, and Implied Groups"
2. "Record Rules (ir.rule): Domain-Based Row-Level Security in Odoo 19"
3. "4 Access Control Mistakes That Leak Data in Production Odoo Deployments"