In this piece
Odoo 20 permissions migration: test company isolation
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 defines ir.access with a group, operation and domain. Group entries grant access; groupless entries restrict it. The 19.0 reference 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 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 restrictionOur 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 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 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 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 and recorded results. With Python 3.10 or later, run:
python3 permission-model.pyThe 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 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 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 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 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 places this evidence within the broader launch decision. The existing Odoo API architecture guide 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, 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.
