GuideOdoo SecurityMarch 13, 2026

Access Control in Odoo 19:
Groups, Record Rules & Field-Level Security

INTRODUCTION

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.

01

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."

XML — security/security_groups.xml
<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.

Python — __manifest__.py (security file ordering)
{
    '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',
}
Category as Selection in Settings

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.

02

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.

CSV — security/ir.model.access.csv
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
GroupReadWriteCreateDeleteUse Case
ViewerYesNoNoNoDashboard users, auditors, executives who only view data
OperatorYesYesYesNoDay-to-day users who create and edit but can't delete records
ManagerYesYesYesYesFull 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.

Empty group_id Means Public Access

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.

03

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.

XML — security/record_rules.xml
<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 TypeGroups FieldCombination LogicEffect
GlobalEmpty (no groups)AND with all other rulesRestricts everyone — cannot be bypassed by group rules. Used for multi-company isolation.
GroupOne or more groupsOR with other group rules for same modelIf 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)].

The company_ids Variable

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).

04

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.

Python — models/custom_transfer.py
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:

XML — views/custom_transfer_views.xml
<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 groups vs View groups

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.

05

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.

Python — Correct sudo() patterns
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 where company_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. The company_id field 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 wrong company_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() or filtered(), calling sudo() on that search lets them read any record in the database.
  • Prefer with_user() over sudo() 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 and Public User Security

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.

06

4 Access Control Mistakes That Leak Data in Production Odoo Deployments

1

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.

Our Fix

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.

2

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.

Our Fix

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.

3

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.

Our Fix

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.

4

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.

Our Fix

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.

BUSINESS ROI

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:

$0Data Breach Liability

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.

90%Fewer Accidental Edits

Read-only viewer groups and field-level restrictions eliminate "someone changed the confirmed PO" incidents that cost hours of investigation and data correction.

2xFaster Compliance Audits

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.

AUDIT CHECKLIST

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.csv entries — no model should be accessible without explicit group permissions.
  • Every model with company_id has 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_id in ir.model.access.csv for 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} and rule_{model}_{scope} makes auditing possible without reading every file.
DEBUGGING

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.access for the target model — does the user's group have the required CRUD bit? Use Settings → Technical → Security → Access Controls List.
  • Step 2: Check ir.rule for 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_get call 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.

Quick Debug Command

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.

SEO NOTES

Optimization Metadata

Meta Desc

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.

H2 Keywords

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"

Security Is Not a Feature You Add Later

Access control is the first thing to design and the last thing most teams think about. Every custom module that ships without record rules, every sudo() call that skips validation, and every model without proper CRUD restrictions is technical debt that compounds with every user you add and every company you onboard.

The cost of retrofitting security is exponential. Adding record rules to a module that's been live for 6 months means auditing every existing record, fixing data that was created in the wrong company context, and retraining users who've gotten used to seeing everything. Adding it at module creation time costs an extra 30 minutes per model.

If you're building custom Odoo modules or managing a multi-company deployment, we can audit your security configuration. We review every model's access controls, test record rules across user roles, identify sudo() misuse, and deliver an access matrix document that satisfies both your security team and external auditors. The audit typically takes 2-3 days and produces a prioritized remediation plan.

Book a Free Security Audit