From Access Groups to Roles: The Trobz Playbook
We published From Access Groups to Roles: A Playbook for a Coop for the client side of a roles migration: the sheet a client fills in, the diff they review, the go-live decision they make. Five steps, each with a clear input and output, written so a client doing this for the first time knows exactly what to expect from us and what we expect back from them.
This post covers the same five steps, same order, same names, but from the other side of that handoff: which skill or script we actually run to produce each input, and which parts stay a person’s call even on our side of the table. A PM reading both posts side by side should be able to point at any line in the client-facing one and know exactly what ran to produce it.
Before Step 1: Building the Catalog
The client-facing post assumes the roles already exist and moves straight to matching users against them. That assumption is doing a lot of work, and none of it is visible from the client’s side.
Odoo has no official list of its own access-rights groups. res.groups is data scattered across every module, community and enterprise alike, so before anyone can design a role, someone has to build the catalog that should have existed from the start. Two problems come with that: the native side (what Odoo itself ships) and the project side (what this specific client’s own custom modules added over the years).
Every command below is uv run some_script.py, not a bare python some_script.py. Each skill’s scripts/ directory carries its own pyproject.toml, declaring exactly what it needs, click, pyyaml, odooly, and so on. uv run finds that file, builds or reuses a virtual environment matching it, and runs the script inside it, no manual pip install, no activating anything by hand. Skip uv run and use the system Python instead and the first import fails outright: ModuleNotFoundError: No module named 'odooly', since that package only exists inside the environment uv manages, not in a plain system install. cd into the right scripts/ directory first, uv needs to be run from there to find that directory’s own pyproject.toml.
The native side. Read every res.groups record straight out of local Odoo checkouts, one directory per version, kept cloned and up to date by an in-house tool, tlc pull-repos:
cd plugins/odoo/skills/access-rights-groups/scripts
uv run extract_native_groups.py --refs 12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0 --known-list known_groups.yaml
A straight source read alone still leaves two gaps: Odoo sometimes renames a group’s XML ID across versions, and sometimes drops one entirely. Guessing either from name similarity was ruled out early, plenty of unrelated groups share a name like “Administrator” across different categories, so a heuristic would misfire. Both get filled from a real source instead: OCA OpenUpgrade’s own migration scripts for renames (the literal _xmlids_renames = [(old, new), ...] declarations Odoo/OCA run against production databases), and a real Odoo upgrade log for deletions (Odoo logs every res.groups record it deletes because a newer version no longer declares it):
uv run resolve_renames.py --refs 12.0,13.0,14.0,15.0,16.0,17.0,18.0,19.0 --known-list known_groups.yaml
uv run detect_removed_groups.py --log /path/to/upgrade.log --version 13.0 --known-list known_groups.yaml
Run against one real migration’s logs, that second command confirmed ten groups genuinely removed, not renamed, across two versions in the chain, sourced fact either way, never a guess about what a group is “for.”
A third case doesn’t come from source or logs at all, and doesn’t show up until two environments actually get diffed: the same group shown under two different display names or categories, “Point Of Sale” in one place, “Point of Sale” in another, XML ID unchanged, just the label. Nothing to guess there either: once a later diff (Step 3) surfaces one, it gets recorded straight from that evidence, not from a name-similarity guess:
uv run record_display_variants.py --csv /tmp/diff.csv --known-list known_groups.yaml
That keeps the catalog current as it gets used, not just accurate on the day it was first built.
The project side. None of the above touches this client’s own custom groups, they have no CE/EE source to read. For those, the catalog comes from the running instance itself, first the raw evidence, then a first-pass purpose sentence built only from what that evidence contains:
uv run discover_groups.py -c ~/odooly.ini --env staging --all --evidence --format yaml > /tmp/evidence.yaml
uv run write_project_group_purposes.py --evidence /tmp/evidence.yaml --known-list project_groups.yaml
Both commands above talk to a live instance through odooly, which needs one INI section per environment, the section name is what --env staging refers to:
[staging]
scheme = https
host = HTTP_AUTH_USER:HTTP_AUTH_PASSWORD@project-staging.trobz.com
port = 443
username = admin
password = ADMIN_PASSWORD
database = project_staging
protocol = jsonrpc
With both catalogs in hand, native and project-specific, we design the roles: bundling loose groups into a dozen or two named, business-facing roles. That is a judgment call about the business, not the database, and it is what turns the catalog into the sheet the client-facing post’s Step 1 opens with.
That bundling decision doesn’t start from a blank page. generate_roles_doc.py renders whichever slice of the catalog is relevant, the full known list, one Odoo series, or a project’s own live groups pulled straight from the instance, into a document a person can actually read and mark up:
uv run generate_roles_doc.py --known-list known_groups.yaml --env staging --include-project-groups --format md
What comes out is every group with its purpose sentence, one row each, not a set of roles. Collapsing that flat list into a dozen or two named, business-facing roles is still the human step; the script’s job stops at handing the person doing that job something better than a raw table to start from.
Step 1: Fill In the Role Sheet
On the client’s side, this step is entirely manual: match every existing user to a role. On ours, the sheet itself is generated, one row per user, pulled straight from the live instance rather than assembled by hand. What is not automated is the match itself. It would be possible to scan a user’s current groups and suggest a role automatically, we do not, on purpose: a plausible-looking wrong guess costs a client more rework than a blank sheet does.
Step 2: Apply the Sheet on Staging, Not Production
The filled-in sheet gets applied through the base_user_role module’s UI or a data import, on a staging copy, never straight to production. Nothing here is scripted, and that is consistent with Step 1 rather than a gap in it: a one-shot bulk-import script would trade away the exact moment gaps in the sheet get caught, a user matching no role cleanly, an API integration account that should not be role-managed at all, for a small amount of typing saved.
Step 3: Review the Diff
This is the one step in the entire process that is fully scripted, on our side, start to finish, and it is what produces the diff report the client-facing post’s Step 3 hands over.
cd ../../odooly/scripts
uv run compare_access_rights.py -c ~/odooly.ini --env-a production --env-b staging --format csv --output /tmp/diff.csv
uv run generate_html_report.py --input /tmp/diff.csv --output /tmp/report.html \
--env-a production --env-b staging \
--known-list ../../access-rights-groups/scripts/known_groups.yaml \
--known-list ../../access-rights-groups/scripts/project_groups.yaml
compare_access_rights.py diffs five kinds of access data between the two environments, ACL entries, record rules, the groups themselves, group membership, and role assignments, matching every record by its stable XML ID first, never by raw database ID, since the two environments are independent databases. generate_html_report.py turns that into a self-contained report, one section per user with a changed group membership. Passed the two --known-list files from the prep work above, it adds a purpose tooltip to every group and folds a group Odoo itself renamed or removed across versions into a labeled note instead of an unexplained access change, exactly the kind of thing that would otherwise cost the client a false alarm while reading the removed-access column. Nothing here waits on a person until the report is ready to read.
Step 4: Close the Loop With Feedback
Whatever the client flags in Step 3 sends the work back a step on our side too: a role gets a group added or removed, a new custom role gets defined, an account gets pulled out of the roles system. The same two commands from Step 3 run again, against a freshly corrected staging environment, as many times as the loop takes, deciding what each new diff means and when it is good enough to send back is still on us to judge, not the script.
Step 5: Choose How to Go Live
The client’s decision, cutover or progressive rollout, has no script behind the choice itself. What makes the progressive option executable is something introduced back in the prep work: the From and To date fields on a base_user_role assignment. A role can be added to a user’s record now and scheduled to take effect on a future date, which is the entire mechanism behind rolling out in batches instead of switching everyone on at once.
The Point of Splitting It This Way
Same five steps as the client-facing post, and the pattern holds on our side too: cataloging and comparing are fully scripted, because a computer checking a hundred-plus groups across two environments is faster and more reliable than a person doing it by hand. Deciding which groups belong in a role, matching a person to a job, and choosing when to flip the switch stay with a person, on both sides of the table, because those are not database problems.
That split is not a limitation of the tooling. It is the actual design: automate exactly the parts that benefit from automation, and leave the parts that need a human decision to a human. The same five steps, and the same scripts, run the same way for any client, not just one.