Skip to content

When and How to Use sudo() Safely in Odoo: Security Guide

DeployMonkey Team · March 23, 2026 12 min read

What Is sudo()?

The sudo() method creates a new recordset that bypasses access rights and record rules. It is Odoo's equivalent of Unix sudo: it elevates permissions for a specific operation. Used correctly, it enables legitimate cross-security-boundary operations. Used carelessly, it creates security vulnerabilities.

# Normal access (respects ACL + record rules)
partner = self.env['res.partner'].browse(partner_id)

# Elevated access (bypasses ACL + record rules)
partner = self.env['res.partner'].sudo().browse(partner_id)

# sudo() returns a new recordset, does not modify self
elevated = self.sudo()
self.env.su  # False
elevated.env.su  # True

When to Use sudo()

1. Cross-Model Access in Business Logic

When a user action in one model legitimately needs to modify another model they do not have direct access to:

def action_confirm(self):
    # User confirms their own order
    self.write({'state': 'confirmed'})

    # Create accounting entries (user has no accounting access)
    self.sudo()._create_account_move()

    # Update inventory (user has no warehouse access)
    self.sudo()._update_stock_move()

2. Portal/Public User Operations

Portal users have minimal ORM access. sudo() is needed for any database operation beyond basic reads:

@http.route('/portal/submit', type='http', auth='public')
def portal_submit(self, **kwargs):
    # Portal user cannot create records via ORM
    ticket = request.env['support.ticket'].sudo().create({
        'name': kwargs.get('subject'),
        'partner_id': request.env.user.partner_id.id,
        'description': kwargs.get('description'),
    })
    return request.redirect(f'/portal/ticket/{ticket.id}')

3. System Operations (Cron, Automation)

Cron jobs often need unrestricted access to process all records:

def _cron_send_reminders(self):
    # Cron runs as OdooBot, needs access to all records
    overdue = self.sudo().search([
        ('date_deadline', '<', fields.Date.today()),
        ('state', '!=', 'done'),
    ])
    for record in overdue:
        record._send_reminder_email()

4. Reading Configuration

System parameters and settings often require elevated access:

def _get_config_value(self):
    return self.env['ir.config_parameter'].sudo().get_param(
        'my_module.api_url', default='https://api.example.com')

When NOT to Use sudo()

1. Bypassing Access Errors You Do Not Understand

# BAD: hiding a real permissions problem
def action_view_invoices(self):
    invoices = self.sudo().mapped('invoice_ids')  # WHY sudo?
    # The user should have invoice access!
    # Fix: grant proper access rights instead

2. In Compute Methods

# BAD: sudo in compute bypasses security for all users
@api.depends('partner_id')
def _compute_partner_info(self):
    for record in self:
        # This exposes data from ANY partner to ANY user
        partner = record.partner_id.sudo()
        record.partner_phone = partner.phone  # security bypass!

3. Returning Elevated Recordsets to the UI

# BAD: returning sudo recordset to web client
def action_view_records(self):
    records = self.env['secret.model'].sudo().search([])
    return {
        'type': 'ir.actions.act_window',
        'res_model': 'secret.model',
        'domain': [('id', 'in', records.ids)],
        # The user still cannot read these in the UI!
        # sudo does NOT persist across requests
    }

SUPERUSER_ID

SUPERUSER_ID (value: 1) is the special user ID that bypasses all access checks. It is used differently from sudo():

from odoo import SUPERUSER_ID

# Using SUPERUSER_ID for environment creation
env = api.Environment(cr, SUPERUSER_ID, {})
record = env['model'].create(vals)

# sudo() vs SUPERUSER_ID
self.sudo()  # same user, elevated privileges
self.with_user(SUPERUSER_ID)  # switch to superuser

The practical difference: sudo() keeps the current user context (for tracking, defaults), while with_user(SUPERUSER_ID) switches the user entirely.

sudo() Scope Best Practices

Minimize the Scope

# BAD: entire method runs elevated
def action_process(self):
    records = self.sudo()
    for record in records:
        record.compute_something()     # unnecessary sudo
        record.validate()               # unnecessary sudo
        record.partner_id.write({...})   # unnecessary sudo

# GOOD: elevate only the specific operation
def action_process(self):
    for record in self:
        record.compute_something()     # normal access
        record.validate()               # normal access
        # Only this specific write needs elevation
        record.sudo()._create_system_log()

Validate Before Elevating

def action_approve(self):
    # Validate with normal permissions first
    for record in self:
        if record.state != 'submitted':
            raise UserError('Cannot approve.')
        if not self.env.user.has_group('module.group_approver'):
            raise UserError('Not authorized.')

    # Then elevate for the system operation
    self.sudo().write({
        'state': 'approved',
        'approved_by': self.env.uid,
    })

Drop sudo() as Soon as Possible

def _get_partner_email(self):
    # Read with sudo, but return plain value (not recordset)
    partner = self.partner_id.sudo()
    email = partner.email  # plain string
    # email is not elevated, safe to return
    return email

Alternatives to sudo()

  • Proper access rights: grant users the correct groups and ACLs
  • Record rules: use domain-based restrictions instead of blanket deny + sudo
  • Server actions: run automated code as admin without exposing sudo in module code
  • with_user(): switch to a specific user instead of superuser
  • with_company(): switch company context for multi-company access

Security Audit Checklist

  • Search your codebase for all .sudo() calls
  • For each call, document WHY sudo is needed
  • Verify that input validation happens BEFORE sudo
  • Ensure sudo recordsets are not returned to the UI
  • Check that sudo is not used in compute methods that expose data
  • Confirm that the minimal scope of sudo is used