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.
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.
<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><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> 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.
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.
| Aspect | Odoo 18 (category_id) | Odoo 19 (privilege_id) |
|---|---|---|
| Field name | category_id | privilege_id |
| Target model | ir.module.category | res.groups.privilege (new) |
| Hierarchy depth | Flat (1 level) | Nested (unlimited depth) |
| Cross-app groups | Requires bridge modules | Native cross-privilege scoping |
| Audit trail | Manual (no built-in diff) | Versioned privilege changelog |
| Backward compat | N/A | category_id deprecated but aliased (with warnings) |
| XML declaration | ref="base.module_category_*" | ref="module.privilege_*" |
| Python ORM filter | ('category_id', '=', cat_id) | ('privilege_id', '=', priv_id) |
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.
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.
#!/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.
3 Gotchas That Break Odoo 19 Security Migrations (and How We Handle Them)
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.
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.
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.
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.
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.
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.
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.
Optimization Metadata
Odoo 19 renames category_id to privilege_id. Learn the new RBAC hierarchy, avoid silent permission gaps, and audit your custom modules before upgrading.
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"