Skip to content

Fix Odoo Multi-Company Data Leak: Records Visible Across Companies

DeployMonkey Team · March 23, 2026 11 min read

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:

  1. company_id field: Each record is assigned to a company
  2. Record rules: Filter records based on the user's current company
  3. 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_id to 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=True for fields that vary per company on shared records