Odoo Security Skill
You are an expert Odoo security auditor with deep knowledge of Odoo's multi-layer security model spanning versions 14 through 19. You understand the complete attack surface of Odoo applications and can identify vulnerabilities, misconfigurations, and insecure coding patterns. You provide actionable remediation with correct, production-ready code.
When invoked, you analyze Odoo module codebases systematically, produce severity-graded reports, and guide developers toward secure-by-default implementations.
- Security Architecture — Odoo's 3-Layer Security Model
Odoo implements a defense-in-depth approach using three distinct security layers that work together. Understanding all three layers is mandatory before auditing any module.
Layer 1: User Groups (Authentication & Authorization)
User groups are the foundation. Every action in Odoo is gated by group membership.
<!-- security/group_my_module.xml --> <odoo> <data> <!-- Base group category --> <record id="module_category_my_module" model="ir.module.category"> <field name="name">My Module</field> <field name="sequence">50</field> </record>
<!-- User group (read + limited write) -->
<record id="group_my_module_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_my_module"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Manager group (full access, implies user group) -->
<record id="group_my_module_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_my_module"/>
<field name="implied_ids" eval="[(4, ref('group_my_module_user'))]"/>
<field name="users" eval="[(4, ref('base.user_root'))]"/>
</record>
</data>
</odoo>
Group Hierarchy Rules:
-
Every group should have a category_id for proper UI display in Settings
-
implied_ids creates transitive inheritance — users in Manager automatically get User permissions
-
Groups should follow the pattern: group_[module]_[role] (user, manager, admin)
-
Never grant permissions directly to base.group_public for internal data
Layer 2: Model-Level Access Control (ir.model.access)
Model access controls define CRUD permissions per group per model. Every model MUST have entries.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
Standard user: read, write, create — no delete
access_my_model_user,my.model user,model_my_model,group_my_module_user,1,1,1,0
Manager: full CRUD
access_my_model_manager,my.model manager,model_my_model,group_my_module_manager,1,1,1,1
Portal: read-only
access_my_model_portal,my.model portal,model_my_model,base.group_portal,1,0,0,0
Critical Rules:
-
A model with NO access rules is accessible to ALL authenticated users (default allow)
-
Transient models (wizards) need access rules too
-
Inherited models (_inherit ) that add sensitive fields need their own rules
-
The model_id:id uses the technical name converted: my.model → model_my_model
Layer 3: Record-Level Security (ir.rule)
Record rules filter which records a user can see/modify within an already-accessible model.
<!-- Multi-company isolation — MANDATORY for multi-company setups --> <record id="rule_my_model_company" model="ir.rule"> <field name="name">My Model: Company</field> <field name="model_id" ref="model_my_model"/> <field name="domain_force"> ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] </field> <field name="groups" eval="[(4, ref('base.group_user'))]"/> </record>
<!-- User can only see their own records --> <record id="rule_my_model_user_own" model="ir.rule"> <field name="name">My Model: Own Records</field> <field name="model_id" ref="model_my_model"/> <field name="domain_force">[('user_id', '=', user.id)]</field> <field name="groups" eval="[(4, ref('group_my_module_user'))]"/> <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>
<!-- Manager sees all records --> <record id="rule_my_model_manager_all" model="ir.rule"> <field name="name">My Model: Manager All</field> <field name="model_id" ref="model_my_model"/> <field name="domain_force">[(1, '=', 1)]</field> <field name="groups" eval="[(4, ref('group_my_module_manager'))]"/> </record>
- Model Access Rules — Complete Reference
ir.model.access.csv Column Definitions
Column Description Values
id
Unique XML ID for the access rule access_[model]_[group]
name
Human-readable description [model] [group]
model_id:id
Reference to ir.model
model_[model_technical_name]
group_id:id
Reference to res.groups
module.group_name or empty for all users
perm_read
Read permission 1 (granted) or 0 (denied)
perm_write
Write/update permission 1 or 0
perm_create
Create permission 1 or 0
perm_unlink
Delete permission 1 or 0
Deriving model_id:id from _name
Model _name → model_id:id
my.model → model_my_model
account.move.line → model_account_move_line
res.partner → model_res_partner
Rule: replace dots with underscores, prefix with "model_"
Complete Access Pattern Templates
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
Pattern 1: Standard internal model
access_my_model_user,my.model user,model_my_model,my_module.group_my_module_user,1,1,1,0 access_my_model_manager,my.model manager,model_my_model,my_module.group_my_module_manager,1,1,1,1
Pattern 2: Read-only reference data (all users can read, only admin writes)
access_ref_data_user,ref.data user,model_ref_data,base.group_user,1,0,0,0 access_ref_data_manager,ref.data manager,model_ref_data,base.group_system,1,1,1,1
Pattern 3: Transient model (wizard) — grant to same groups as main model
access_my_wizard_user,my.wizard user,model_my_wizard,my_module.group_my_module_user,1,1,1,1
Pattern 4: Portal access — read only
access_my_model_portal,my.model portal,model_my_model,base.group_portal,1,0,0,0
Pattern 5: Multi-company model — requires company record rules too
access_my_model_user,my.model user,model_my_model,my_module.group_my_module_user,1,1,1,0
Pattern 6: Inherited model with added sensitive fields
When using _inherit and adding fields that need access control,
create a NEW model with _name set and add separate access rules
Common Mistakes in Model Access
Mistake 1: Missing access for transient models
models/my_wizard.py
class MyWizard(models.TransientModel): _name = 'my.wizard' # NEEDS entry in ir.model.access.csv!
Mistake 2: Leaving group_id empty (grants access to ALL users)
DANGEROUS — grants access to every logged-in user
access_my_model_all,my.model all,model_my_model,,1,1,1,0
Mistake 3: Wrong model_id derivation
WRONG
access_x,x,my.model,,1,0,0,0
CORRECT
access_x,x,model_my_model,,1,0,0,0
- User Groups — Complete Patterns
Group Hierarchy Best Practices
<odoo> <data> <record id="module_category_my_app" model="ir.module.category"> <field name="name">My Application</field> <field name="description">Manage My Application access levels</field> <field name="sequence">100</field> <field name="exclusive_ids" eval="[ (4, ref('group_my_app_readonly')), (4, ref('group_my_app_user')), (4, ref('group_my_app_manager')) ]"/> </record>
<!-- Read-only: Can view but not create/edit -->
<record id="group_my_app_readonly" model="res.groups">
<field name="name">Read Only</field>
<field name="category_id" ref="module_category_my_app"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- User: Standard operational access -->
<record id="group_my_app_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_my_app"/>
<field name="implied_ids" eval="[
(4, ref('base.group_user')),
(4, ref('group_my_app_readonly'))
]"/>
</record>
<!-- Manager: Administrative access -->
<record id="group_my_app_manager" model="res.groups">
<field name="name">Administrator</field>
<field name="category_id" ref="module_category_my_app"/>
<field name="implied_ids" eval="[
(4, ref('group_my_app_user'))
]"/>
<field name="users" eval="[(4, ref('base.user_root'))]"/>
</record>
</data>
</odoo>
Field-Level Security with groups= Attribute
class MyModel(models.Model): _name = 'my.model'
# Public field — everyone can see
name = fields.Char(string='Name', required=True)
# Sensitive field — only managers can see/edit
salary = fields.Float(
string='Salary',
groups='my_module.group_my_app_manager'
)
# Internal note — hidden from portal users
internal_note = fields.Text(
string='Internal Note',
groups='base.group_user'
)
# Computed field with group restriction
margin_percent = fields.Float(
string='Margin %',
compute='_compute_margin',
groups='my_module.group_my_app_manager'
)
4. Record Rules — Domain-Based Row-Level Security
Standard Record Rule Patterns
<odoo> <data noupdate="1">
<!-- Pattern 1: Multi-company isolation (REQUIRED for company_id fields) -->
<record id="rule_my_model_multi_company" model="ir.rule">
<field name="name">My Model: Multi-Company</field>
<field name="model_id" ref="model_my_model"/>
<field name="global" eval="True"/>
<field name="domain_force">
['|',
('company_id', '=', False),
('company_id', 'in', company_ids)
]
</field>
</record>
<!-- Pattern 2: User can only access their own records -->
<record id="rule_my_model_user_own" model="ir.rule">
<field name="name">My Model: Own Records Only</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('my_module.group_my_app_user'))]"/>
<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>
<!-- Pattern 3: Manager sees everything (override user restriction) -->
<record id="rule_my_model_manager_all" model="ir.rule">
<field name="name">My Model: Manager All Access</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('my_module.group_my_app_manager'))]"/>
</record>
<!-- Pattern 4: Portal users see only published/confirmed records -->
<record id="rule_my_model_portal" model="ir.rule">
<field name="name">My Model: Portal Published Only</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">
[('state', 'in', ['published', 'confirmed'])]
</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<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>
<!-- Pattern 5: Salesperson sees only their team's records -->
<record id="rule_my_model_sales_team" model="ir.rule">
<field name="name">My Model: Sales Team</field>
<field name="model_id" ref="model_my_model"/>
<field name="domain_force">
[('team_id.member_ids', 'in', [user.id])]
</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
</data>
</odoo>
Record Rule Evaluation Logic
Record rules within the same group are evaluated with OR logic (any matching rule grants access). Rules from different groups are evaluated with AND logic (all matching rules must pass). This means:
-
Multiple rules for the same group = broader access (OR)
-
Rules for different groups = narrower access (AND intersection)
-
Global rules (<field name="global" eval="True"/> ) apply to ALL users
When Record Rules Are Missing (Vulnerability)
A model without record rules but with model access means ANY authenticated user with the group can see ALL records — including data from other companies, other users, or sensitive records. Always add:
-
Multi-company rule if model has company_id
-
User-scoping rule if records are user-specific
-
State-filtering rule for portal/public access
- HTTP Route Security — Authentication Patterns
Route Authentication Types
from odoo import http from odoo.http import request
class MyController(http.Controller):
# auth='user' — Requires login, redirects to /web/login if not authenticated
# Use for: Internal backend routes, authenticated user operations
@http.route('/my/internal/route', type='http', auth='user', methods=['GET'])
def internal_route(self):
# request.env.user is the authenticated user
return request.render('my_module.template', {})
# auth='public' — Works for both guests and logged-in users
# Use for: Public website pages, product listings, blog posts
# WARNING: Never expose sensitive data here
@http.route('/my/public/route', type='http', auth='public', methods=['GET'])
def public_route(self):
# request.env.user = public user if not logged in
# Check: if request.env.user._is_public(): handle guest case
if not request.env.user._is_public():
# Authenticated user
pass
return request.render('my_module.public_template', {})
# auth='none' — No authentication at all, raw HTTP
# Use for: Webhooks with their own auth, health checks, OAuth callbacks
# CRITICAL: Must implement own authentication if handling sensitive data
@http.route('/my/webhook', type='http', auth='none', methods=['POST'])
def webhook(self, **kwargs):
# Validate with HMAC signature, API key, or IP whitelist
api_key = request.httprequest.headers.get('X-API-Key')
if not self._validate_api_key(api_key):
return request.make_response('Unauthorized', status=401)
# Process webhook...
return request.make_response('OK', status=200)
CSRF Protection
POST routes that modify state MUST have csrf=False only for APIs/webhooks
By default, Odoo enforces CSRF for all POST routes (good!)
DANGEROUS — disables CSRF for a state-modifying route
@http.route('/my/action', type='http', auth='user', methods=['POST'], csrf=False) def bad_action(self): pass # This is vulnerable to CSRF attacks!
CORRECT — keep CSRF enabled (default) for web form actions
@http.route('/my/action', type='http', auth='user', methods=['POST']) def safe_action(self): pass
ACCEPTABLE — disable CSRF only for machine-to-machine API calls with API key auth
@http.route('/api/v1/webhook', type='json', auth='none', methods=['POST'], csrf=False) def webhook(self, **kwargs): # MUST validate API key here self._authenticate_request()
JSON API Routes
For JSON APIs, use type='json' — this handles serialization and error formatting
@http.route('/api/v1/data', type='json', auth='user', methods=['POST']) def api_data(self, **kwargs): # Return dict, Odoo serializes to JSON automatically return {'status': 'ok', 'data': []}
Public API with API key authentication
@http.route('/api/v1/public', type='json', auth='none', methods=['POST'], csrf=False) def public_api(self, api_key=None, **kwargs): if not self._validate_api_key(api_key): return {'error': 'Invalid API key', 'code': 401} # Safe to process
Sensitive Data in Public Routes
VULNERABLE — exposes partner email to public
@http.route('/partners', type='http', auth='public') def partners(self): partners = request.env['res.partner'].sudo().search([]) return request.render('template', {'partners': partners})
SECURE — only expose non-sensitive fields, respect visibility
@http.route('/partners', type='http', auth='public') def partners(self): # Only published partners, only safe fields partners = request.env['res.partner'].sudo().search([ ('website_published', '=', True) ]) # Never pass raw recordsets with sensitive fields to public templates partner_data = partners.read(['name', 'website', 'country_id']) return request.render('template', {'partners': partner_data})
- Sudo() Usage — Safe and Dangerous Patterns
When sudo() Is Appropriate
APPROPRIATE 1: Escalating privileges to read configuration data
User doesn't need access to system params, but the operation is safe
@api.model def get_public_config(self): param = self.env['ir.config_parameter'].sudo().get_param('my.public.setting') return param # Only returning a specific, safe setting
APPROPRIATE 2: Writing to audit log (user shouldn't control their own log)
def write(self, vals): result = super().write(vals) # Log change — user shouldn't have access to audit model self.env['my.audit.log'].sudo().create({ 'model': self._name, 'record_id': self.id, 'user_id': self.env.user.id, 'changes': str(vals), }) return result
APPROPRIATE 3: Sending notifications to users the current user can't access
def notify_admin(self): admin = self.env.ref('base.user_admin').sudo() admin.notify_warning('Alert', 'Something happened')
APPROPRIATE 4: Computing fields that aggregate data across companies
@api.depends('order_ids') def _compute_total_orders(self): for rec in self: rec.order_count = self.env['sale.order'].sudo().search_count([ ('partner_id', '=', rec.id) ])
When sudo() Is DANGEROUS
DANGEROUS 1: sudo() in a public/portal controller
Bypasses ALL access controls — user can access any record by ID
@http.route('/order/<int:order_id>', auth='public') def view_order(self, order_id): # VULNERABLE TO IDOR — anyone can access any order ID order = request.env['sale.order'].sudo().browse(order_id) return request.render('template', {'order': order})
SECURE VERSION — verify ownership before accessing
@http.route('/order/<int:order_id>', auth='user') def view_order(self, order_id): order = request.env['sale.order'].browse(order_id) # ORM access controls apply — user can only see their orders if not order.exists(): raise request.not_found() return request.render('template', {'order': order})
DANGEROUS 2: sudo() in a loop
def process_all(self): for record in self.search([]): # Could be thousands of records # sudo() call inside loop = performance issue + security concern sensitive = self.env['sensitive.model'].sudo().search([ ('ref_id', '=', record.id) ]) record.result = len(sensitive)
SECURE VERSION — batch the sudo() outside the loop
def process_all(self): all_records = self.search([]) # Single sudo() query outside loop sensitive_data = self.env['sensitive.model'].sudo().read_group( [('ref_id', 'in', all_records.ids)], ['ref_id'], ['ref_id'] ) count_map = {d['ref_id'][0]: d['ref_id_count'] for d in sensitive_data} for record in all_records: record.result = count_map.get(record.id, 0)
DANGEROUS 3: Passing sudo env to user-controlled operations
def dangerous_search(self, domain): # User provides domain — they could access ANY record sudo_env = self.env['my.model'].sudo() return sudo_env.search(domain) # User controls domain!
SECURE VERSION — always apply fixed safety constraints
def safe_search(self, user_domain): # Sanitize and constrain the domain safe_domain = [ ('partner_id', '=', self.env.user.partner_id.id), # Fixed constraint ] + user_domain # Append user's additional filters return self.env['my.model'].search(safe_domain) # No sudo()
DANGEROUS 4: sudo() in onchange (exposes data in UI)
@api.onchange('partner_id') def _onchange_partner_id(self): # sudo() in onchange means ANY user can see ALL partner data all_orders = self.env['sale.order'].sudo().search([ ('partner_id', '=', self.partner_id.id) ]) self.order_count = len(all_orders)
Scoped sudo() with Specific User
Instead of full sudo(), use a specific user's environment
This gives exactly the permissions of that user, not root
Get sudo environment for the admin user only
admin_user = self.env.ref('base.user_admin') admin_env = self.env['my.model'].with_user(admin_user)
Or use with_context to pass additional context
elevated_env = self.env['my.model'].with_context( no_check=True, # Only if the model respects this context key force_company=self.company_id.id )
- SQL Injection Prevention
Parameterized Queries (Always Required)
VULNERABLE — string concatenation with user input
def vulnerable_search(self, name): query = "SELECT id FROM res_partner WHERE name = '%s'" % name self.env.cr.execute(query) # SQL INJECTION RISK! return self.env.cr.fetchall()
SECURE — parameterized query (tuple format)
def safe_search(self, name): query = "SELECT id FROM res_partner WHERE name = %s" self.env.cr.execute(query, (name,)) # Parameters passed separately return self.env.cr.fetchall()
VULNERABLE — f-string in SQL
def vulnerable_f(self, table, field): query = f"SELECT {field} FROM {table}" # INJECTION RISK! self.env.cr.execute(query)
SECURE — use psycopg2.sql for dynamic identifiers
from psycopg2 import sql
def safe_dynamic(self, field_name): # Use sql.Identifier for table/column names (NOT string format) query = sql.SQL("SELECT {} FROM res_partner LIMIT 10").format( sql.Identifier(field_name) ) self.env.cr.execute(query) return self.env.cr.fetchall()
SECURE — ORM domain for user-provided filters (preferred over raw SQL)
def orm_search(self, partner_name): # ORM handles escaping automatically return self.env['res.partner'].search([('name', 'ilike', partner_name)])
Safe Raw SQL Patterns
SECURE — multiple parameters
def get_partner_orders(self, partner_id, state, date_from): query = """ SELECT so.id, so.name, so.amount_total FROM sale_order so WHERE so.partner_id = %s AND so.state = %s AND so.date_order >= %s ORDER BY so.date_order DESC LIMIT 100 """ self.env.cr.execute(query, (partner_id, state, date_from)) return self.env.cr.dictfetchall()
SECURE — IN clause with list (psycopg2 handles tuple expansion)
def get_orders_by_ids(self, order_ids): if not order_ids: return [] query = "SELECT id, name FROM sale_order WHERE id IN %s" self.env.cr.execute(query, (tuple(order_ids),)) return self.env.cr.fetchall()
SECURE — LIKE pattern with escaping
def search_by_prefix(self, prefix): # % must be escaped in parameterized queries using %% query = "SELECT id FROM res_partner WHERE name ILIKE %s" self.env.cr.execute(query, (prefix + '%',)) return self.env.cr.fetchall()
- Field-Level Security
groups= Attribute on Field Definitions
class Employee(models.Model): _inherit = 'hr.employee'
# Visible to all HR users
name = fields.Char()
# Only HR managers can see salary information
wage = fields.Float(
groups='hr.group_hr_manager'
)
# Only accessible via HR admin (payroll group)
ssnid = fields.Char(
string='SSN',
groups='hr.group_hr_user' # Still too broad — consider more restrictive
)
# Computed field with group restriction
contract_count = fields.Integer(
compute='_compute_contract_count',
groups='hr.group_hr_manager'
)
# Multiple groups (OR logic — either group grants access)
sensitive_field = fields.Text(
groups='my_module.group_hr_manager,base.group_system'
)
View-Level Field Hiding
<!-- Hide field in view for non-managers (defense in depth) --> <field name="salary" groups="my_module.group_my_app_manager"/>
<!-- Show different fields based on group --> <field name="public_notes"/> <field name="private_notes" groups="my_module.group_my_app_manager"/>
<!-- Entire section hidden from non-managers --> <group string="Financials" groups="account.group_account_manager"> <field name="revenue"/> <field name="cost"/> <field name="margin"/> </group>
- Portal Security Patterns
Secure Portal Controller
from odoo import http from odoo.http import request from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager from odoo.exceptions import AccessError, MissingError
class MyPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'my_model_count' in counters:
values['my_model_count'] = request.env['my.model'].search_count(
self._get_my_model_domain()
)
return values
def _get_my_model_domain(self):
"""Return domain that limits portal access to current user's records only."""
return [('partner_id', '=', request.env.user.partner_id.id)]
@http.route(['/my/orders', '/my/orders/page/<int:page>'],
type='http', auth='user', website=True)
def portal_my_orders(self, page=1, **kwargs):
# SECURE — domain always scoped to current user
domain = self._get_my_model_domain()
total = request.env['my.model'].search_count(domain)
pager = portal_pager(
url='/my/orders',
total=total,
page=page,
step=10
)
orders = request.env['my.model'].search(
domain,
limit=10,
offset=pager['offset']
)
return request.render('my_module.portal_orders', {
'orders': orders,
'pager': pager,
})
@http.route('/my/order/<int:order_id>', type='http', auth='user', website=True)
def portal_order_detail(self, order_id, **kwargs):
# SECURE — use _document_check_access to verify ownership
try:
order = self._document_check_access('my.model', order_id)
except (AccessError, MissingError):
return request.redirect('/my/orders')
return request.render('my_module.portal_order_detail', {'order': order})
_document_check_access Implementation
This method (from CustomerPortal) verifies:
1. Record exists
2. Current user has access rights to it
3. Optional: token-based access for share links
Always use this instead of sudo().browse() in portal controllers
- API Security Patterns
API Key Authentication
class ApiController(http.Controller):
def _get_api_key_user(self, api_key):
"""Validate API key and return associated user or None."""
if not api_key:
return None
# Store API keys in a secure model with hashed values
key_record = request.env['my.api.key'].sudo().search([
('key_hash', '=', hashlib.sha256(api_key.encode()).hexdigest()),
('active', '=', True),
], limit=1)
if not key_record or key_record.is_expired():
return None
return key_record.user_id
@http.route('/api/v1/resource', type='json', auth='none', csrf=False)
def api_resource(self, **kwargs):
api_key = request.httprequest.headers.get('X-Api-Key', '')
user = self._get_api_key_user(api_key)
if not user:
return {'error': 'Unauthorized', 'code': 401}
# Switch to the API key user's environment (respects their permissions)
env = request.env(user=user.id)
records = env['my.model'].search([])
return {'data': records.read(['name', 'state'])}
Rate Limiting Pattern
import time from collections import defaultdict
class RateLimiter: """Simple in-memory rate limiter — use Redis in production.""" _requests = defaultdict(list)
@classmethod
def is_allowed(cls, key, limit=60, window=60):
now = time.time()
cls._requests[key] = [t for t in cls._requests[key] if now - t < window]
if len(cls._requests[key]) >= limit:
return False
cls._requests[key].append(now)
return True
class ApiController(http.Controller): @http.route('/api/v1/data', type='json', auth='none', csrf=False) def api_data(self, **kwargs): client_ip = request.httprequest.remote_addr if not RateLimiter.is_allowed(client_ip, limit=60, window=60): return {'error': 'Rate limit exceeded', 'code': 429} # Process request...
-
Common Vulnerabilities — Top 10 Odoo Security Mistakes
-
Missing ir.model.access.csv (CRITICAL)
Risk: Every authenticated user can read/write ALL records of the model. Detection: Run access_checker.py — finds models with no access rules. Fix: Create complete security/ir.model.access.csv with entries for all defined models.
- auth='none' on Data Routes (CRITICAL)
Risk: Unauthenticated access to sensitive business data. Detection: Run route_auditor.py — flags auth='none' without justification. Fix: Use auth='user' for internal routes, implement API key auth for auth='none' webhooks.
- IDOR (Insecure Direct Object Reference) (HIGH)
Risk: Integer IDs are sequential — attackers enumerate IDs to access other users' records. Detection: Public routes accepting <int:id> parameters with sudo() access. Fix: Use _document_check_access() , verify partner_id == request.env.user.partner_id.id .
- SQL Injection via env.cr.execute() (HIGH)
Risk: String concatenation with user input leads to data theft or destruction. Detection: Run grep for env.cr.execute
- string formatting nearby. Fix: Always use parameterized queries: self.env.cr.execute(query, (param,)) .
- sudo() Privilege Escalation in Controllers (HIGH)
Risk: sudo() in public routes bypasses all access controls. Detection: Run sudo_finder.py — finds sudo() in public/portal methods. Fix: Use user-scoped environments, verify ownership before access.
- Missing Multi-Company Record Rules (HIGH)
Risk: Users in company A can see/modify data from company B. Detection: Models with company_id field but no multi-company record rule. Fix: Add ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] rule.
- Missing CSRF on State-Changing Routes (MEDIUM)
Risk: Malicious websites can trigger actions as the authenticated user. Detection: POST routes with csrf=False that modify data. Fix: Remove csrf=False from web routes (only use for machine-to-machine APIs).
- Sensitive Fields Without groups= (MEDIUM)
Risk: Salary, SSN, passwords visible to all users who can access the model. Detection: Look for fields named salary , password , token , ssn , secret without groups= . Fix: Add groups='my_module.group_manager' to sensitive field definitions.
- sudo() in Loops (MEDIUM)
Risk: Performance degradation + security — each iteration re-escalates privileges unnecessarily. Detection: Run sudo_finder.py — finds .sudo() inside for loops. Fix: Move sudo() call before loop, batch the query.
- Overly Permissive Record Rules (LOW)
Risk: Users can access records they shouldn't (e.g., other departments' data). Detection: Record rules with (1, '=', 1) domain for non-manager groups. Fix: Add appropriate filters — company, user, team, or state constraints.
- Audit Commands — How to Use Each Command
/odoo-security — Full Module Security Audit
Run complete security audit on a module
/odoo-security /path/to/odoo17/projects/my_project/my_module
What it checks:
- Model access rules completeness
- HTTP route authentication
- sudo() usage safety
- SQL injection risks
- Record rule coverage
- Field-level security on sensitive fields
Output format:
[CRITICAL] Model 'my.model' has no access rules in ir.model.access.csv
[HIGH] Route /api/data uses auth='none' without API key validation
[HIGH] sudo() found in public controller method view_order (line 45)
[MEDIUM] Field 'salary' has no groups= restriction
[LOW] Record rule allows all users to see all records
Exit codes:
0 = No issues found
1 = Issues found (check report for details)
/security-audit — Targeted Module Audit
Audit specific module with verbose output
/security-audit my_module_name
Useful for CI/CD integration — fails build on CRITICAL/HIGH issues
/check-access — Model Access Rule Checker
Check access rules for all models in a module
/check-access /path/to/module
Example output:
Scanning models in: /path/to/module/models/
Found 5 model definitions:
my.model -> [CRITICAL] NO ACCESS RULES DEFINED
my.wizard -> [HIGH] Wizard has no access rules
my.category -> [OK] 2 rules found
my.tag -> [OK] 2 rules found
my.config -> [MEDIUM] Only admin group, missing user group
/find-sudo — sudo() Usage Scanner
Find all sudo() calls and classify by risk
/find-sudo /path/to/module
Example output:
controllers/main.py:45 [CRITICAL] sudo() in auth='public' route
models/my_model.py:123 [HIGH] sudo() inside for loop
models/my_model.py:89 [OK] sudo() for audit log write (safe pattern)
wizards/my_wizard.py:34 [MEDIUM] Unscoped sudo() — consider with_user()
/check-routes — HTTP Route Security Scanner
Audit all HTTP routes in controllers/
/check-routes /path/to/module
Example output:
controllers/main.py:
GET /my/public auth='public' [OK]
POST /my/action auth='user' [OK]
POST /api/webhook auth='none' [CRITICAL] No API key validation found
GET /data auth='public' [HIGH] Reads sensitive model without filtering
- Remediation Patterns — How to Fix Each Issue
Fix: Missing Model Access Rules
1. Identify models without access rules
python scripts/access_checker.py /path/to/module
2. Create or update security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_my_model_user,my.model user,model_my_model,my_module.group_my_module_user,1,1,1,0 access_my_model_manager,my.model manager,model_my_model,my_module.group_my_module_manager,1,1,1,1
3. Add to manifest.py data list
'data': [ 'security/group_my_module.xml', 'security/ir.model.access.csv', # Must come AFTER group definitions 'security/rules_my_module.xml', ... ]
Fix: Insecure Route Authentication
BEFORE (CRITICAL)
@http.route('/sensitive/data', type='json', auth='none') def get_data(self): return request.env['sensitive.model'].sudo().search_read([])
AFTER (SECURE)
@http.route('/sensitive/data', type='json', auth='user') def get_data(self): # Now request.env automatically scoped to authenticated user return request.env['sensitive.model'].search_read([])
Fix: IDOR in Portal Routes
BEFORE (VULNERABLE)
@http.route('/order/<int:order_id>', auth='public') def view_order(self, order_id): order = request.env['sale.order'].sudo().browse(order_id) return request.render('template', {'order': order})
AFTER (SECURE)
@http.route('/order/<int:order_id>', auth='user', website=True) def view_order(self, order_id): from odoo.exceptions import AccessError, MissingError try: order = self._document_check_access('sale.order', order_id) except (AccessError, MissingError): return request.redirect('/my/orders') return request.render('template', {'order': order})
Fix: SQL Injection
BEFORE (VULNERABLE)
def search_orders(self, status): query = "SELECT id FROM sale_order WHERE state = '%s'" % status self.env.cr.execute(query) return self.env.cr.fetchall()
AFTER (SECURE)
def search_orders(self, status): query = "SELECT id FROM sale_order WHERE state = %s" self.env.cr.execute(query, (status,)) # Tuple with trailing comma! return self.env.cr.fetchall()
Fix: sudo() in Public Controller
BEFORE (CRITICAL)
@http.route('/products', auth='public') def products(self): products = request.env['product.template'].sudo().search([]) return request.render('template', {'products': products})
AFTER (SECURE)
@http.route('/products', auth='public') def products(self): # Only show published products, use sudo only for the published filter products = request.env['product.template'].sudo().search([ ('website_published', '=', True), ('sale_ok', '=', True), ]) # Restrict fields returned to non-sensitive data product_data = products.read(['name', 'description_sale', 'list_price', 'image_128']) return request.render('template', {'products': product_data})
Fix: sudo() in Loop
BEFORE (MEDIUM — performance + security)
def compute_totals(self): for record in self: related = self.env['related.model'].sudo().search([ ('record_id', '=', record.id) ]) record.total = sum(related.mapped('amount'))
AFTER (SECURE + PERFORMANT)
def compute_totals(self): # Single sudo() query outside loop related_data = self.env['related.model'].sudo().read_group( [('record_id', 'in', self.ids)], ['record_id', 'amount:sum'], ['record_id'] ) totals = {d['record_id'][0]: d['amount'] for d in related_data} for record in self: record.total = totals.get(record.id, 0.0)
Fix: Missing Multi-Company Record Rule
<!-- BEFORE: No record rule = all users in all companies can see all records --> <!-- AFTER: Add to security/rules_my_module.xml --> <record id="rule_my_model_multi_company" model="ir.rule"> <field name="name">My Model: Multi-Company Access</field> <field name="model_id" ref="model_my_model"/> <field name="global" eval="True"/> <field name="domain_force"> ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] </field> </record>
Fix: Sensitive Field Without Group Restriction
BEFORE (MEDIUM)
class Employee(models.Model): _inherit = 'hr.employee' salary_override = fields.Float(string='Special Salary')
AFTER (SECURE)
class Employee(models.Model): _inherit = 'hr.employee' salary_override = fields.Float( string='Special Salary', groups='hr.group_hr_manager' # Only HR managers can see/edit )
- Security Checklist for Code Review
Use this checklist when reviewing any Odoo module pull request:
Models Checklist
-
Every _name definition has entries in ir.model.access.csv
-
Transient models (wizards) have access rules
-
Models with company_id have multi-company record rules
-
Record rules scope access appropriately (not overly permissive)
-
Sensitive fields have groups= restriction
-
_sql_constraints used for uniqueness (not just Python constraints)
Controllers Checklist
-
Every route has explicit auth= parameter
-
No auth='none' routes without API key/HMAC validation
-
POST routes that modify state have CSRF enabled (no csrf=False )
-
Portal routes use _document_check_access() for record access
-
Public routes don't expose sensitive model data
-
User-provided IDs are validated against current user's accessible records
Python Code Checklist
-
No string formatting/f-strings in env.cr.execute() calls
-
All raw SQL uses parameterized queries
-
sudo() calls have documented justification in comment
-
No sudo() inside for loops
-
No sudo() in public/portal controllers without domain scoping
Security Files Checklist
-
security/ directory exists with required files
-
Group XML file defines category and group hierarchy
-
ir.model.access.csv has all required access rules
-
Record rules file exists for models needing row-level security
-
All security files listed in manifest.py data list in correct order
- Integration with CI/CD
GitHub Actions Security Gate
.github/workflows/security-check.yml
name: Odoo Security Audit
on: [push, pull_request]
jobs: security-audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Run Security Audit
run: |
python .claude-plugins/odoo-security/scripts/security_auditor.py \
./projects/my_project/my_module \
--min-severity HIGH \
--exit-on-issues
# Exit code 1 = issues found = build fails
- name: Upload Security Report
if: always()
uses: actions/upload-artifact@v3
with:
name: security-report
path: security-report.json
Pre-commit Hook Integration
#!/bin/bash
.git/hooks/pre-commit
Run security audit on changed modules
CHANGED_MODULES=$(git diff --cached --name-only | grep -E '^projects/' |
sed 's|/.*||' | sort -u)
for MODULE in $CHANGED_MODULES; do echo "Running security audit on $MODULE..." python /path/to/security_auditor.py "$MODULE" --min-severity CRITICAL if [ $? -ne 0 ]; then echo "CRITICAL security issues found in $MODULE. Commit blocked." exit 1 fi done
exit 0
- Odoo Version-Specific Security Notes
Odoo 14
-
auth_jwt module not available natively — use custom API key implementation
-
website.http_routing_map slower — cached routes need security review
-
mail.thread access rules inherited but worth verifying
Odoo 15
-
Introduction of website.auth_signup_token for secure registration links
-
Enhanced portal security with stronger token validation
-
Multi-website record rules became more important
Odoo 16
-
ir.http._auth_method_public() refactored — custom auth methods need updating
-
website.route.security group added for route-level security
-
JSON API routes improved error handling (don't expose stack traces in production)
Odoo 17
-
New _check_access() method replaces some manual access checks
-
Improved check_access_rights() and check_access_rule() APIs
-
env.user._is_internal() / env.user._is_public() / env.user._is_portal() helpers
-
Webhook security improvements with signature verification helpers
Odoo 18
-
OAuth 2.0 integration improvements — verify token validation in custom OAuth routes
-
Enhanced attachment security — check access_token handling
-
Two-factor authentication API expanded
Odoo 19
-
REST API framework introduced — use built-in auth decorators
-
Improved rate limiting primitives in core
-
Enhanced field-level encryption support for PII data