---
title: "Odoo 20 Permissions: Test Company Isolation"
canonical: https://wavect.io/blog/odoo-20-company-access-migration-tests/
language: en
description: "Test Odoo 20 permission changes with a role and company matrix. Compare sixteen local policy cases, inspect conversion warnings, and verify native access."
image: "https://wavect.io/img/blog/headers/header_odoo-20-company-access-migration-tests.png"
author: ["Kevin Riedl"]
published: "2026-11-05"
updated: "2026-11-05"
reviewed: "2026-11-05"
---

# Odoo 20 permissions migration: test company isolation

[**Back**](/blog/overview/)

[![Kevin Riedl](/img/team/kevin.webp)](/team/kevin-riedl/)

[Kevin Riedl](/team/kevin-riedl/) https://linkedin.com/in/wsdt

11 min read · 5 Nov 2026 Last reviewed November 5, 2026

[**Next**](/blog/odoo-email-pdf-quotation-review/)

TL;DR

Accept an Odoo 20 permissions migration against an approved role and company matrix, with both allowed and denied operations. Inspect grants and global restrictions, record the activated and current companies, and verify company-dependent values separately. Sixteen local Python cases illustrate failures in a synthetic policy. The actual Odoo acceptance matrix remains NOT_RUN until tested on the upgraded build.

In the synthetic example below, the same user and record move from denied to allowed after a naive permissions rewrite. The intended business policy has not changed. The way its grants combine has.

**Accept an Odoo 20 permissions migration only after the approved role and company matrix passes both allowed and denied operations on the upgraded database.** Verify the converted grants and global restrictions, then test activated company context separately from current-company values. A successful install or administrator walkthrough does not supply that evidence.

**Research checked: 5 November 2026.** We inspected official Odoo 19 and 20 documentation, the 20.0 access implementation and its source-conversion script, then ran sixteen synthetic Python policy cases. Odoo, a database upgrade and the official converter were not run. The downloadable native acceptance matrix remains `NOT_RUN`.

## What changed in Odoo 20 access control?

Odoo 20 brings operations and record domains into the same access model. That changes what a permissions migration must review: the combined meaning of the resulting grants, not whether a renamed security file loads. Preserve an approved policy reference and compare actual outcomes for each affected role.

The official [20.0 security reference](https://github.com/odoo/documentation/blob/20.0/content/developer/reference/backend/security.rst) defines `ir.access` with a group, operation and domain. Group entries grant access; groupless entries restrict it. The [19.0 reference](https://github.com/odoo/documentation/blob/19.0/content/developer/reference/backend/security.rst) describes separate model-level ACL grants followed by record rules.

For your migration, inventory the custom security records and the business methods that depend on them. Record which policy each entry implements: for example, “a case owner can read their own company's cases” or “a reviewer can read all cases in the activated companies.” Those are different policies even if both users can open the same menu.

The broader [Odoo migration acceptance guide](/blog/odoo-migration-acceptance-reconciliation/) covers records and workflow continuation. This article owns the narrower permission decision. An accurate migrated order is still unacceptable if the wrong identity can retrieve it.

## How do group grants and global restrictions combine?

For an ordinary access check, a record needs at least one matching group grant and must satisfy every applicable global restriction. In our fixture, adding a broad grant can expand a user's readable records; a global company restriction still bounds that expansion. A restriction alone supplies no grant. Test those combinations instead of reviewing each row in isolation.

The model expresses its decision as:

```
some matching group grant
AND
every applicable global restriction
```

Our invented `own_reader` group grants access only to records owned by user 7. A separate global restriction limits records to the activated companies. User 7 can read `A-OWN` and cannot read `A-OTHER`. Adding `broad_reader` makes the second record readable because that group supplies another matching grant.

Now keep company B inactive. The broad group still cannot read `B-OWN` while the global company restriction applies. Remove that restriction in the deliberately bad fixture configuration and the same record becomes readable. The test exposes the missing boundary; it does not establish that Odoo omits such a boundary by default.

Also test a legitimate allowed record. Our two contradictory global restrictions, one requiring A and one requiring B, deny it. A suite that checks only prohibited access could celebrate this broken configuration. Every important denial needs an allowed control that shows the user can still do their intended job.

## Which company context should the test record?

Record company membership, activated companies and the current company separately. They answer different questions: which companies the user is authorized to select, which company scope this operation uses, and which company supplies context-dependent values. An acceptance result without that context cannot explain why a record was visible or hidden.

The [20.0 access-domain evaluator](https://github.com/odoo/odoo/blob/20.0/odoo/addons/base/models/ir_access.py) sets `company_ids` from `env.companies.ids`, the activated companies, and `company_id` from `env.company.id`. Do not substitute every company assigned to the user when recording the tested scope.

| Dimension | Example | Acceptance question |
| --- | --- | --- |
| Authorized membership | User 7 belongs to A and B. | May this identity select company B at all? |
| Activated companies | Only A is active for this call. | Does a company-scoped operation exclude B records? |
| Current company | A is current while A and B are active. | Which company's value does the operation use? |
| Record ownership | `B-OWN` belongs to B. | Does the resulting record belong to the intended company? |

The [environment implementation](https://github.com/odoo/odoo/blob/20.0/odoo/orm/environments.py) validates selected companies for ordinary users, falls back when the company list is empty, and skips the noted sanity checks in `sudo` mode. An empty list is therefore not a deny-all scope. Elevated test execution cannot establish ordinary-user isolation.

Our fixture makes the distinction visible. With memberships A/B and only A activated, reading the user's B record is denied. Activate A/B and it is allowed. A user assigned only A cannot activate B in the model. Keep these as separate cases rather than describing all three as “multi-company works.”

## Why can a naive ACL conversion widen access?

A naive conversion can widen access when it preserves an old broad model grant as a broad new record grant alongside a narrower grant. Our counterexample gives the user both groups. The old layered policy denies another owner's record; the new union accepts it. This illustrates a faulty rewrite, not a result from Odoo's official converter.

| Stage | Fixture policy | Other owner's A record |
| --- | --- | --- |
| Old layered example | A model ACL permits reading; an applicable own-record rule filters the result. | Denied |
| Naive new rewrite | A broad record grant is combined with an own-record grant. | Allowed |
| Approved target policy | The business owner still requires own-record access for this role. | Must remain denied |

The official [access conversion source](https://github.com/odoo/odoo/blob/20.0/odoo/upgrade_code/19.4-00-ir-access.py) performs group-aware inference and warns about broad ACL groups interacting with narrower rules. Its warning guidance calls for review. We inspected that source but did not execute it.

Keep the converter's output and logs with the migration evidence. For each warning, identify the affected effective group combination and operation, then prove the approved outcome on the upgraded database. A clean conversion log still needs regression tests for your policy.

Do not turn the example into a universal instruction to delete broad grants. Another role may legitimately require them. Compare the whole approved matrix before changing a shared group, including users who inherit it through another role.

## What did the sixteen local cases show?

The sixteen local cases matched their expected observations. They demonstrate how an access test can isolate a missing restriction, an extra grant or the wrong company context. The fixture also separates visibility from value selection and relation consistency. These are observations about a small Python model, not a security assessment of an Odoo database.

| Case | Expected | Observed |
| --- | --- | --- |
| Own record in active A | Allow | Allowed |
| Other owner's record in active A | Deny | Denied |
| Authorized B is not activated | Deny B record | Denied |
| Authorized B is activated | Allow own B record | Allowed |
| Activate B without membership | Reject context | Context error |
| Add broad group grant | Other owner's A record becomes readable | Allowed |
| Broad group with global company restriction | Inactive B stays denied | Denied |
| Omit global company restriction | Bad configuration exposes inactive B | Allowed |
| Conflicting A-only and B-only global restrictions | Legitimate A record is blocked | Denied |
| Shared record without a company | Strict policy denies; explicit shared policy allows | Denied / allowed |
| Global restriction without any group grant | Deny | Denied |
| Read-only fixture role | Allow read; deny write and delete | Allowed / denied / denied |
| Naive conversion of the old layered policy | Old denial becomes an unintended allowance | Denied / allowed |
| B record visible with A or B current | Company value differs: 100.00 versus 120.00 | 100.00 / 120.00 |
| Visible A record linked to a B record | Access passes; example relation check fails | Allowed / inconsistent |
| Empty activated-company input | Fall back to memberships A/B | B record readable |

Download the [runnable permission model](/downloads/odoo-20-company-access-migration-tests/permission-model.py) and [recorded results](/downloads/odoo-20-company-access-migration-tests/model-results.json). With Python 3.10 or later, run:

```
python3 permission-model.py
```

The model takes effective groups as already-expanded inputs. It does not implement group inheritance, Odoo domains, field permissions, caches or native create/write hooks. The relation check is deliberately a small equality example. It cannot establish the behavior of every shareable or company-dependent field in a real module.

## What belongs in the native role and company matrix?

The native matrix needs an approved expected result for each relevant identity, company context and operation, followed by evidence from the upgraded system. Pair ordinary allowed work with denied access. For denied writes, inspect the resulting state as well as the error. A screenshot of a missing menu is insufficient evidence of the underlying operation.

The [proposed native acceptance matrix](/downloads/odoo-20-company-access-migration-tests/native-acceptance.json) starts entirely as `NOT_RUN`. Its fixture expectations are examples to approve or replace, not a universal Odoo access policy. Fill the actual user and effective groups, build revision, entry point, record IDs, company context and evidence before using it for a release decision.

| Dimension | Required contrast | Evidence |
| --- | --- | --- |
| Role | Ordinary owner versus approved broader reviewer | Effective groups and expected record set for each identity |
| Company scope | A only, A/B activated, and unauthorized B selection | Exact context and target record ownership |
| Operation | Permitted read or write versus prohibited change | Returned records, result state and absence of forbidden changes |
| Entry point | User action and each relevant integration or job | Actual execution identity and called operation |
| Policy migration | Old approved expectation versus intended new expectation | Converter warning, reviewed change and observed native result |
| Business consistency | Correct company value and permitted related record | Selected value and relation evidence, separately from visibility |

Use direct record identities as well as the normal navigation path. Define the expected observation for each entry point, including whether a prohibited search should return no foreign records or a requested operation should be rejected. Do not accept a broad result merely because the interface hides the extra rows.

Run destructive-operation cases only on controlled test records in the rehearsal environment. Include allowed changes so a configuration that blocks everyone cannot pass. The [agent business-state test guide](/blog/odoo-ai-agent-business-state-tests/) extends the same evidence principle to an agent's complete task, where a plausible response may conceal a wrong record.

## Why test values and related records after access passes?

Access passing means the identity can perform the tested operation under that policy. It does not by itself show that the operation used the intended company's value or chose a compatible related record. Our fixture can read the B record while selecting A's value. It can also allow a record whose proposed relation fails a separate company check.

The official [multi-company guide](https://github.com/odoo/documentation/blob/20.0/content/developer/howtos/company.rst) ties company-dependent values to the current company and describes separately configured relational checks through `_check_company_auto` and `check_company`. Verify those mechanisms on the specific models and fields in your module.

In the example, both companies are readable. Choosing A as current yields the invented value 100.00; choosing B yields 120.00. If the task concerns B, a successful read of 100.00 can still be the wrong business result. Record the expected company alongside the expected value.

For relations, choose a representative allowed link and a prohibited cross-company link. Inspect the actual operation that assigns them. Do not assume that a visible company field or an access domain proves every custom relationship is checked. The [custom-module audit guide](/blog/ai-generated-odoo-module-audit/) covers the broader implementation review around these contracts.

## What evidence should block or release the migration?

Block the permissions migration when an unexpected identity can access a protected record, an intended user loses required work, or the evidence cannot establish the tested company context. Release only after the approved matrix passes on the intended build, with deliberate policy changes signed off and unresolved conversion warnings assigned a concrete disposition.

Retain the expected matrix, effective group snapshot and exact upgraded module revision with the observations. An intentional broader reviewer role can be valid, but it needs an explicit policy change. An old denial is not automatically the correct future rule, and an old allowance is not automatically safe to preserve.

The [release QA checklist](/software-development-guide/software-qa-checklist-before-launch/) places this evidence within the broader launch decision. The existing [Odoo API architecture guide](/blog/odoo-erp-api-integration-limits-2026/) retains ownership of hosting and protocol constraints; changing an API route does not replace the native permission checks.

For Wavect's [software quality assurance service](/services/software-quality-assurance/), bring one affected role, two company contexts and the custom module's conversion output. The first useful deliverable is an agreed allowed/denied matrix with reproducible native evidence for that scope.

## Odoo 20 permission migration FAQ

### Does a hidden menu prove the underlying access is denied?

No. The acceptance question concerns the record and operation the identity may access. Test the normal user path and the relevant direct entry point with controlled records. Capture returned data and the resulting state. A menu screenshot can document the interface, but it cannot replace evidence that the protected operation obeyed the approved policy.

### Can an empty allowed_company_ids list force access to no companies?

No. In the inspected 20.0 environment implementation, an empty list falls back to the user's companies. Define an explicit permitted context and test its result. If your application needs a no-access state, specify that as a policy outcome and verify it; do not rely on an empty context list to create it.

### How should records with no company be accepted?

Decide which records are intentionally shared and which must have an owner. Our fixture tests both a strict company policy and an explicitly shared-record policy; they produce different outcomes. Use representative records for each approved category, including related-record assignments. A missing company value should not silently become an instruction to share sensitive business data.

### How should inherited and extra groups be handled?

Review the effective group set for the tested identity, including groups obtained through another role. The downloadable model receives that set as input and does not calculate inheritance. Run the actual combinations your users hold, especially a narrow role combined with a broader reviewer role. A test of either group alone can miss their combined result.

### Can the sixteen model cases prove the real database is safe?

No. They demonstrate named policy and context outcomes in a small standard-library Python program. They do not execute Odoo, parse native domains or test custom methods. Use the counterexamples to improve your acceptance design, then run the separate NOT_RUN matrix against the exact upgraded build and approved business policy.

### What changes for service accounts and scheduled jobs?

Record the identity and company context each automation uses, then test its approved operations. Do not substitute a human administrator's walkthrough for that execution path. Include the records it should process and a controlled record it must not touch, and inspect resulting changes. A broad credential deserves an explicit business justification and acceptance scope.

### Should every difference from Odoo 19 block the release?

An unexplained difference should block the affected scope until it is understood. An intentional policy correction can be approved, including fixing an old overbroad grant. Record the old expectation, the desired new rule and the owner who approved the change. Compare the upgraded system with that approved target, not with old behavior alone.

QA and production readiness

## Read next

Testing, audits, maintenance and hardening practices for reliable production software.

[Start here**QA for AI-Generated Code**](/blog/qa-for-ai-generated-code/)

- [README ZIP malware: review the actual AI tool install](/blog/readme-zip-malware-install-review/)
- [AI-generated Odoo modules: audit the business contract](/blog/ai-generated-odoo-module-audit/)
- [Odoo migration acceptance: reconcile records and balances](/blog/odoo-migration-acceptance-reconciliation/)
- [Odoo AI agent tests: validate saved activities](/blog/odoo-ai-agent-business-state-tests/)
- [Odoo 19 to 20 AI: what must you reaccept?](/blog/odoo-19-20-ai-upgrade-readiness/)

[Previous article**Odoo AI agent tests: validate saved activities**](/blog/odoo-ai-agent-business-state-tests/) [Next article**Odoo 19 to 20 AI: what must you reaccept?**](/blog/odoo-19-20-ai-upgrade-readiness/)

[**Back**](/blog/overview/)

[![Kevin Riedl](/img/team/kevin.webp)](/team/kevin-riedl/)

[Kevin Riedl](/team/kevin-riedl/) https://linkedin.com/in/wsdt

11 min read · 5 Nov 2026 Last reviewed November 5, 2026

[**Next**](/blog/odoo-email-pdf-quotation-review/)
