BlogMarch 11, 2026 · Updated March 13, 2026

Odoo 19 Privilege System:
Migrating from category_id to privilege_id

INTRODUCTION

Your Security XMLs Compiled Fine Yesterday. Then You Upgraded to Odoo 19.

If you manage custom Odoo modules with security groups, you've likely hit this wall already: Odoo 19 renames category_id to privilege_id across the entire res.groups model. Every custom security XML that references the old field now throws a ValueError on module upgrade. Every automated test that checks group membership silently passes with empty results—because the ORM resolves the deprecated field to null.

This isn't a cosmetic rename. It signals Odoo's shift from flat "application categories" to a hierarchical Privilege-Based Access Control model. The implications cascade through your security architecture: group inheritance chains, record rules, ir.model.access entries, and any Python code that filters on category_id.

Get it wrong and you face silent privilege escalation—users gaining access to records they shouldn't see—or the opposite: locked-out teams filing urgent tickets during go-live week. Both scenarios cost real money and erode trust in the platform.

01

How Odoo 19 Restructures Security Group Hierarchy

In Odoo 18 and earlier, security groups were organized under "Application Categories" via the category_id field on res.groups. This was a flat, single-level taxonomy. A group belonged to one category (e.g., "Sales," "Inventory"), and that was the extent of the hierarchy.

Odoo 19 replaces this with the Privilege concept. The field is now privilege_id, pointing to a new model structure that supports:

  • Multi-level nesting — privileges can contain sub-privileges, enabling granular permission trees
  • Explicit inheritance declarations — a privilege can declare which parent privileges it extends
  • Cross-application scoping — a single privilege can span multiple Odoo apps without needing bridge groups

The practical effect: instead of saying "User X belongs to the Sales / Manager group," you now say "User X holds the sales.privilege_manager privilege, which inherits sales.privilege_user and crm.privilege_reader." The chain is explicit, auditable, and version-controlled in XML.

XML — Odoo 18 (Old Way)
<record id="group_sale_manager" model="res.groups">
    <field name="name">Manager</field>
    <field name="category_id" ref="base.module_category_sales"/>
    <field name="implied_ids" eval="[(4, ref('group_sale_user'))]"/>
</record>
XML — Odoo 19 (New Way)
<record id="group_sale_manager" model="res.groups">
    <field name="name">Manager</field>
    <field name="privilege_id" ref="sales.privilege_sales"/>
    <field name="implied_ids" eval="[(4, ref('group_sale_user'))]"/>
</record>
Key Insight

The implied_ids mechanism still works, but Odoo 19 now validates that every group in the chain references a valid privilege_id. If even one group in the inheritance tree still uses category_id, the entire chain may fail to resolve correctly at install time.

02

Odoo 18 category_id vs Odoo 19 privilege_id: Complete Migration Map

Below is a side-by-side comparison of how security group architecture differs between versions. Use this as your migration checklist.

AspectOdoo 18 (category_id)Odoo 19 (privilege_id)
Field namecategory_idprivilege_id
Target modelir.module.categoryres.groups.privilege (new)
Hierarchy depthFlat (1 level)Nested (unlimited depth)
Cross-app groupsRequires bridge modulesNative cross-privilege scoping
Audit trailManual (no built-in diff)Versioned privilege changelog
Backward compatN/Acategory_id deprecated but aliased (with warnings)
XML declarationref="base.module_category_*"ref="module.privilege_*"
Python ORM filter('category_id', '=', cat_id)('privilege_id', '=', priv_id)
Migration Note

Odoo 19 provides a backward-compatibility alias: reads from category_id will redirect to privilege_id with a deprecation warning in the server log. Do not rely on this. The alias is scheduled for removal in Odoo 20 and will not work in --test-enable mode, causing CI pipelines to fail.

03

Permission Audit Script: Find Gaps in Your Custom Security XMLs

The script below scans all installed custom modules for security XML files that still reference category_id and identifies groups missing a valid privilege_id assignment. Run this before upgrading to Odoo 19 on staging.

Python — privilege_audit.py
#!/usr/bin/env python3
"""
Odoo 19 Privilege Migration Audit Script
Scans custom modules for deprecated category_id references
and groups missing privilege_id assignments.

Usage:
    python privilege_audit.py /path/to/custom/addons
"""
import os
import sys
import re
from lxml import etree

DEPRECATED_PATTERNS = [
    re.compile(r'name=["\']category_id["\']'),
    re.compile(r'base\.module_category_'),
    re.compile(r"\.category_id\s*="),
    re.compile(r"\bcategory_id\b.*\bref="),
]

SECURITY_FILE_PATTERNS = [
    "**/security/*.xml",
    "**/data/*security*.xml",
    "**/data/*groups*.xml",
]


def scan_xml_files(addons_path):
    """Scan all XML files for deprecated category_id refs."""
    issues = []
    for root, dirs, files in os.walk(addons_path):
        # Skip non-custom / OCA standard paths
        if '.git' in root or '__pycache__' in root:
            continue
        for fname in files:
            if not fname.endswith('.xml'):
                continue
            fpath = os.path.join(root, fname)
            with open(fpath, 'r', encoding='utf-8',
                      errors='ignore') as f:
                content = f.read()

            for i, pattern in enumerate(DEPRECATED_PATTERNS):
                for match in pattern.finditer(content):
                    line_no = (content[:match.start()]
                               .count('\n') + 1)
                    issues.append({
                        'file': os.path.relpath(fpath,
                                                addons_path),
                        'line': line_no,
                        'match': match.group(),
                        'type': 'xml' if i < 2 else 'python',
                    })
    return issues


def scan_python_files(addons_path):
    """Scan Python files for ORM category_id usage."""
    issues = []
    py_patterns = [
        re.compile(
            r"""['"]category_id['"]\s*[,\)]"""
        ),
        re.compile(r"\.category_id\b"),
    ]
    for root, dirs, files in os.walk(addons_path):
        if '.git' in root or '__pycache__' in root:
            continue
        for fname in files:
            if not fname.endswith('.py'):
                continue
            fpath = os.path.join(root, fname)
            with open(fpath, 'r', encoding='utf-8',
                      errors='ignore') as f:
                for line_no, line in enumerate(f, 1):
                    for pat in py_patterns:
                        if pat.search(line):
                            issues.append({
                                'file': os.path.relpath(
                                    fpath, addons_path),
                                'line': line_no,
                                'match': line.strip()[:80],
                                'type': 'python',
                            })
    return issues


def scan_groups_without_privilege(addons_path):
    """Find res.groups records missing privilege_id."""
    missing = []
    for root, dirs, files in os.walk(addons_path):
        if '.git' in root:
            continue
        for fname in files:
            if not fname.endswith('.xml'):
                continue
            fpath = os.path.join(root, fname)
            try:
                tree = etree.parse(fpath)
            except etree.XMLSyntaxError:
                continue
            for record in tree.xpath(
                "//record[@model='res.groups']"
            ):
                rec_id = record.get('id', '(no id)')
                has_privilege = any(
                    f.get('name') == 'privilege_id'
                    for f in record.findall('field')
                )
                has_category = any(
                    f.get('name') == 'category_id'
                    for f in record.findall('field')
                )
                if not has_privilege:
                    missing.append({
                        'file': os.path.relpath(
                            fpath, addons_path),
                        'record_id': rec_id,
                        'has_old_category': has_category,
                    })
    return missing


def main():
    if len(sys.argv) < 2:
        print("Usage: python privilege_audit.py "
              "/path/to/addons")
        sys.exit(1)

    addons_path = sys.argv[1]
    print(f"Scanning: {addons_path}\n")

    # Phase 1: Deprecated references
    xml_issues = scan_xml_files(addons_path)
    py_issues = scan_python_files(addons_path)
    all_issues = xml_issues + py_issues

    print(f"{'='*60}")
    print(f" DEPRECATED category_id REFERENCES")
    print(f"{'='*60}")
    if all_issues:
        for issue in all_issues:
            print(f"  [{issue['type'].upper():6s}] "
                  f"{issue['file']}:{issue['line']}")
            print(f"           {issue['match']}")
        print(f"\n  Total: {len(all_issues)} reference(s) "
              f"to migrate.\n")
    else:
        print("  No deprecated references found.\n")

    # Phase 2: Missing privilege_id
    missing = scan_groups_without_privilege(addons_path)

    print(f"{'='*60}")
    print(f" GROUPS MISSING privilege_id")
    print(f"{'='*60}")
    if missing:
        for m in missing:
            status = (" (has category_id → MIGRATE)"
                      if m['has_old_category']
                      else " (no category at all → ADD)")
            print(f"  {m['file']}")
            print(f"    record: {m['record_id']}{status}")
        print(f"\n  Total: {len(missing)} group(s) need "
              f"privilege_id.\n")
    else:
        print("  All groups have privilege_id.\n")

    # Summary
    total = len(all_issues) + len(missing)
    if total == 0:
        print("All clear — your modules are Odoo 19 "
              "privilege-ready.")
    else:
        print(f"ACTION REQUIRED: {total} issue(s) found. "
              f"Fix before upgrading.")
    return 1 if total else 0


if __name__ == '__main__':
    sys.exit(main())

How to use it: Point it at your custom addons directory. It produces a line-by-line report of every deprecated reference and every res.groups record that needs a privilege_id field. Pipe the output to your CI as a pre-upgrade gate.

04

3 Gotchas That Break Odoo 19 Security Migrations (and How We Handle Them)

1

Silent Alias Resolution in Staging, Hard Failure in Production

Odoo 19's compatibility alias for category_id works fine during casual testing. Developers run -u all, see no errors, and ship to production. But production servers typically run with --log-level=warn, hiding the deprecation messages. Worse: if --test-enable is on in CI (as it should be), tests that touch group membership will fail because the alias doesn't populate privilege_id during test transactions.

Our Fix

We add the audit script above as a CI pipeline stage that runs before -u all. If it finds any issues, the pipeline fails with a clear report. No deprecated reference ever reaches staging.

2

Implied Group Chains That Cross Module Boundaries

In Odoo 18, a custom module could define a group under base.module_category_sales and inherit from sale.group_sale_user. In Odoo 19, if the sale module's groups have been migrated to the new privilege system but your custom module still references the old category, the implied_ids chain has a "privilege gap." The ORM resolves the chain partially, leading to users who have the custom group but silently lose the inherited permissions.

Our Fix

We map every implied_ids chain to a directed graph and validate that all nodes use privilege_id. One mixed node = one broken chain. This is part of our standard pre-migration audit for every client.

3

Record Rules Bound to Dissolved Categories

Some custom ir.rule records use domain filters like [('groups_id.category_id.name', '=', 'Sales')] to scope access. In Odoo 19, this domain silently evaluates to an empty set because category_id is no longer populated on the model. The record rule effectively blocks all access—or, if the rule uses !=, grants access to everyone.

Our Fix

We grep all ir.rule definitions and Python domain expressions for any path traversing category_id. Each one is rewritten to use privilege_id with the correct new reference. We then run a permission matrix test: for every role, assert that record-level access matches the pre-migration baseline.

BUSINESS ROI

What This Means for Your Bottom Line

Security architecture changes don't show up on a CEO's dashboard—until they go wrong. Here's the business translation:

  • Audit compliance: The new privilege system produces a deterministic, auditable permission tree. For companies in regulated industries (finance, healthcare, food), this can reduce SOC 2 / ISO 27001 audit preparation time by 30-40% because the access model is self-documenting.
  • Reduced support tickets: Flat category-based groups lead to "why can't I see this record?" tickets. Hierarchical privileges make access logic transparent to admins, cutting internal IT tickets related to access issues by an estimated 25%.
  • Faster onboarding: Instead of assigning 8 different groups across 4 apps, an admin assigns one privilege that cascades correctly. User provisioning drops from a 15-minute, error-prone process to a 2-minute, deterministic one.
  • Migration insurance: Doing this properly now avoids emergency security patches post-go-live. We've seen emergency privilege fixes cost 5-10x more than proactive migration—because they happen under production pressure with real users locked out.
SEO NOTES

Optimization Metadata

Meta Desc

Odoo 19 renames category_id to privilege_id. Learn the new RBAC hierarchy, avoid silent permission gaps, and audit your custom modules before upgrading.

H2 Keywords

1. "How Odoo 19 Restructures Security Group Hierarchy"
2. "Odoo 18 category_id vs Odoo 19 privilege_id: Complete Migration Map"
3. "3 Gotchas That Break Odoo 19 Security Migrations"

Don't Let a Field Rename Become a Security Incident

The category_idprivilege_id shift is more than a rename—it's a rethink of how Odoo handles role-based access. Handled proactively, it gives you a cleaner, more auditable security model. Handled reactively, it gives you locked-out users and privilege escalation bugs in production.

If you're planning an Odoo 19 upgrade with custom security groups, we should talk. We run a full privilege migration audit—covering XML declarations, Python ORM references, record rules, and implied_ids chains—before a single line of code is upgraded. The audit is fast, the report is actionable, and it pays for itself the first time it catches a silent permission gap.

Book a Free Privilege Migration Audit