The Problem
In a multi-company Odoo setup, users from Company A can see invoices, customers, or products belonging to Company B. This is a serious data leak that violates privacy, accounting rules, and potentially legal regulations like GDPR.
How Multi-Company Isolation Works in Odoo
Odoo uses three mechanisms for company isolation:
- company_id field: Each record is assigned to a company
- Record rules: Filter records based on the user's current company
- Allowed companies: Users can be granted access to multiple companies
When any of these break, data leaks across companies.
Common Causes and Fixes
1. Missing company_id on Custom Model
The most common cause — a custom model does not have a company_id field, so no company-based filtering applies.
Fix: Add the company field and record rule:
class CustomModel(models.Model):
_name = 'custom.model'
company_id = fields.Many2one(
'res.company', string='Company',
required=True,
default=lambda self: self.env.company
)
# Add record rule in security/ir_rule.xml:
<record id="custom_model_company_rule" model="ir.rule">
<field name="name">Custom Model: multi-company</field>
<field name="model_id" ref="model_custom_model"/>
<field name="domain_force">
['|', ('company_id', '=', False),
('company_id', 'in', company_ids)]
</field>
</record>2. Record Rule Disabled or Missing
Standard Odoo multi-company record rules can be accidentally disabled.
Check:
# Find disabled multi-company rules:
SELECT name, model_id, active, domain_force
FROM ir_rule
WHERE (name LIKE '%company%' OR name LIKE '%multi%')
AND active = false;Fix: Re-enable the rules in Settings > Technical > Security > Record Rules.
3. sudo() Bypassing Record Rules
Using sudo() in custom code bypasses ALL record rules, including company filters. This is the most dangerous pattern for data leaks.
# BAD: bypasses company isolation
all_orders = self.env['sale.order'].sudo().search([])
# GOOD: explicit company filter
all_orders = self.env['sale.order'].sudo().search([
('company_id', 'in', self.env.companies.ids)
])
# BETTER: use with_company() context
orders = self.env['sale.order'].with_company(company).search([])4. Wrong company_ids Domain
The record rule domain must use company_ids (the user's allowed companies), not company_id (singular).
# WRONG: only shows records from the current company
[('company_id', '=', company_id)]
# CORRECT: shows records from all allowed companies
['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]The ('company_id', '=', False) part allows shared records (no company set) to be visible.
5. Shared Records Without Proper Scoping
Some records are intentionally shared across companies (products, partners). But when a shared record references company-specific data, it can leak information.
Fix: Use company_dependent fields for data that varies per company:
# Shared product with company-specific pricing:
class ProductTemplate(models.Model):
_inherit = 'product.template'
# This field has different values per company:
standard_price = fields.Float(company_dependent=True)6. Reports Showing Cross-Company Data
Reports may not respect company context, especially custom reports that use direct SQL queries.
Fix:
# In report methods, always filter by company:
def _get_report_data(self):
return self.env['account.move'].search([
('company_id', '=', self.env.company.id),
('move_type', '=', 'out_invoice'),
])
# Never use raw SQL without company filter:
# BAD:
self.env.cr.execute("SELECT * FROM account_move WHERE move_type='out_invoice'")
# GOOD:
self.env.cr.execute(
"SELECT * FROM account_move WHERE move_type='out_invoice' AND company_id = %s",
[self.env.company.id]
)7. Inter-Company Transaction Exposure
Odoo's inter-company module creates linked transactions. Users should only see their company's side of the transaction.
Fix: Verify that inter-company record rules are properly configured. Each company's users should see their own PO/SO but not the other company's counterpart.
Audit Your Multi-Company Setup
# Find models without company_id that probably should have one:
SELECT model FROM ir_model
WHERE model LIKE 'custom.%'
AND model NOT IN (
SELECT model FROM ir_model_fields
WHERE name = 'company_id'
);
# Check which users have access to multiple companies:
SELECT u.login, array_agg(c.name) as companies
FROM res_users u
JOIN res_company_users_rel r ON r.user_id = u.id
JOIN res_company c ON c.id = r.cid
GROUP BY u.login
HAVING count(*) > 1;
# Verify record rules exist for sensitive models:
SELECT m.model, r.name, r.active, r.domain_force
FROM ir_model m
LEFT JOIN ir_rule r ON r.model_id = m.id
AND r.domain_force LIKE '%company%'
WHERE m.model IN ('account.move', 'sale.order', 'purchase.order', 'hr.employee')
ORDER BY m.model;Prevention Best Practices
- Always add
company_idto custom models that contain company-specific data - Always create a multi-company record rule when adding
company_id - Avoid
sudo()without explicit company filtering - Use
with_company()context manager for cross-company operations - Audit record rules after module updates
- Test with users from different companies before deploying
- Use
company_dependent=Truefor fields that vary per company on shared records