6. Permissions & Security¶
Right now anyone with a valid token can read, update, and delete every blog.post — the API is wide open. This chapter closes that gate. You'll declare roles, attach permissions to each role, assign roles to users, and add a record rule so that authors only see their own drafts. By the end, the same blog.post model behaves differently for an admin, a regular author, and a portal user, with no extra code in the model itself.
# A role binding — the foundation of every access decision.
env.models["ir.rbac.role.binding"].create({
"user_id": alice.id,
"role_id": blog_author_role.id,
})
# Inside a command handler, the engine has already gated this call.
@api.on_command("blog.post.publish")
def publish(self, cmd):
# If Alice lacks the `blog.post.publish` permission, this method
# never runs — `PermissionDeniedError` is raised upstream.
self.write({"status": "published"})
Four moving parts:
| Concept | Model | Purpose |
|---|---|---|
| Role | ir.rbac.role |
A bundle of permissions. Hierarchical — child roles inherit. |
| Permission | ir.rbac.permission |
A single resource × action grant tied to one role. |
| Binding | ir.rbac.role.binding |
Assigns a user to a role. |
| Record rule | ir.rbac.record.rule |
A per-row visibility / write filter scoped to a role. |
The platform ships three default roles you can inherit from: portal_user, internal_user, system_admin.
What you're going to build¶
- Declare a
blog_authorrole that inherits from the built-ininternal_user. - Declare permissions for read / create / update / delete / publish of
blog.post. - Assign the role to a user via a
ir.rbac.role.binding. - Add a record rule so authors only see drafts they own; published posts are visible to everyone.
- Optionally opt
blog.postinto company scope with one decorator argument so every post is automatically owned by the user's active organization.
1. The shape of an access decision¶
Two engines compose to answer "can this user do this on that row?":
- The permission engine answers the coarse question — can this role perform action X on resource M at all? Resources are model keys; actions are
read/create/update/delete/execute(where execute covers domain commands likeblog.post.publish). - The record-rule engine answers the fine question — given that this role is allowed action X on M, which subset of rows can it touch? Rules are stored as domain-filter expressions with
$principal.*variable substitution.
Both engines run before every command handler. A request needs to pass both gates to proceed.
For reads, the rules layer composes into the ORM filter automatically — search() returns only rows the user is allowed to see, no extra code in the handler.
2. Declare a custom role¶
Roles live under ir.rbac.role. The platform ships three default ones already, ranked from least to most powerful:
| Role | Purpose | Inherits |
|---|---|---|
portal_user |
Customer / vendor self-service | — |
internal_user |
Day-to-day operational staff | portal_user |
system_admin |
Full platform access | internal_user |
A child role automatically gets every permission its parent has, and the parent's parent, and so on. So system_admin has every permission of internal_user and portal_user too.
Create a custom role for the blog app. Add a new data file src/domains/blog/post/data/blog_roles.xml:
<ede>
<data noupdate="1">
<record id="blog.role_author" model="ir.rbac.role">
<field name="code">blog_author</field>
<field name="name">Blog Author</field>
<field name="role_type">JOB</field>
<field name="parent_id" ref="rbac.role_internal_user"/>
<field name="description">Writes, edits, and publishes blog posts.</field>
<field name="is_active">true</field>
</record>
</data>
</ede>
role_type is a label (JOB, ADMIN, FUNCTIONAL — used for grouping in admin UI). parent_id references the role this one inherits from.
noupdate="1" because roles are seed data — created once on first install, never overwritten on subsequent upgrades (so admin tweaks survive).
Register the file in the manifest's data list.
3. Declare permissions¶
Permissions are stored in ir.rbac.permission. They're typically loaded from a CSV — one row per resource × action × role. Create src/domains/blog/post/data/blog_permissions.csv:
id,name,code,resource,action,role_id/id,domain
blog.p_blog_post_read,Read Blog Posts,blog.post.read,blog.post,read,rbac.role_portal_user,
blog.p_blog_post_create,Create Blog Posts,blog.post.create,blog.post,create,blog.role_author,
blog.p_blog_post_update,Update Blog Posts,blog.post.update,blog.post,update,blog.role_author,
blog.p_blog_post_publish,Publish Blog Posts,blog.post.publish,blog.post,execute,blog.role_author,
blog.p_blog_post_delete,Delete Blog Posts,blog.post.delete,blog.post,delete,rbac.role_system_admin,
blog.p_blog_comment_read,Read Comments,blog.comment.read,blog.comment,read,rbac.role_portal_user,
blog.p_blog_comment_create,Post Comments,blog.comment.create,blog.comment,create,rbac.role_portal_user,
blog.p_blog_tag_read,Read Tags,blog.tag.read,blog.tag,read,rbac.role_portal_user,
The CSV columns:
| Column | Purpose |
|---|---|
id |
External ID — <your_module>.<unique_suffix>. |
name |
Human label. |
code |
A globally unique short code; pattern is <resource>.<action>. |
resource |
The model key (blog.post). |
action |
One of read / create / update / delete / execute. The execute action is the one custom commands like blog.post.publish check. |
role_id/id |
External ID of the role granted this permission. Leave empty for a global permission everyone has. |
domain |
Optional domain-filter JSON literal. When set, the permission is row-scoped. Supports $principal.* variables. |
For example, base.p_res_user_read_self is a real ship-default permission with a row-scoped domain:
That grants every portal user read access to their own user record only.
Register the CSV in the manifest's data list. The loader picks up CSV files automatically — filename must match the target model key (blog.tag.csv → blog.tag).
4. Available action verbs¶
The framework recognises these actions on every model:
| Action | Triggered by |
|---|---|
read |
ede.read_one, ede.search, ede.count, ede.read_group. |
create |
ede.create. |
update |
ede.update. |
delete |
ede.delete. |
execute |
Any domain command like blog.post.publish. The command name is the permission code. |
You don't have to enumerate every execute permission you ever add — the framework matches by command name. So a permission with code: blog.post.publish gates the blog.post.publish command.
5. Assign the role to a user¶
A ir.rbac.role.binding row links a user to a role:
env.models["ir.rbac.role.binding"].create({
"user_id": alice.id,
"role_id": blog_author_role.id,
"organization_id": acme.id, # optional — scopes the binding to one org
})
organization_id is optional. When set, the binding is scoped — Alice has the blog_author role only when her active organization is acme. When omitted, the binding is global to Alice across every organization she belongs to.
For testing, you can also create bindings declaratively in an XML data file:
<record id="blog.binding_alice_author" model="ir.rbac.role.binding">
<field name="user_id" ref="base.user_alice"/>
<field name="role_id" ref="blog.role_author"/>
</record>
6. The $principal.* ABAC variables¶
Permission domains and record-rule domains both support a small set of variables that the engine substitutes per request:
| Variable | Resolved from |
|---|---|
$principal.user_id |
The current user's record_uuid. |
$principal.active_organization_id |
The org currently bound to the request (from the JWT). |
$principal.allowed_organization_ids |
The set of organization IDs the user can switch into. |
$principal.org_ids / $principal.branch_ids / $principal.department_ids |
Split by org-unit kind. Useful for finer-grained scoping. |
A typical domain leaf using these:
The engine substitutes the variable before evaluating the filter. The result is identical to writing [["owner_id", "=", "<alice's uuid>"]] by hand — but it works for every user without per-user permissions.
7. Record rules — row-level filters¶
A record rule narrows what a role can see (or write) on a model. Add a rule that says "authors only see their own drafts; published posts are visible to everyone":
<ede>
<data noupdate="1">
<!-- GLOBAL rule — applies to every user, AND-merged with all other globals.
Says "draft posts are filtered to the owner; published posts are wide-open". -->
<record id="blog.rr_post_visibility" model="ir.rbac.record.rule">
<field name="name">Author sees own drafts; published is public</field>
<field name="model_id" ref="ir.model_blog_post"/>
<field name="domain">[
"|",
["status", "=", "published"],
["author_id", "=", "$principal.user_id"]
]</field>
<field name="perm_read">true</field>
<field name="perm_create">true</field>
<field name="perm_update">true</field>
<field name="perm_delete">true</field>
</record>
</data>
</ede>
Anatomy of a record rule:
| Field | Purpose |
|---|---|
name |
Human label. |
model_id |
Reference to the ir.model registry row for the target model. Always present once a model is registered. |
domain |
Domain-filter expression. Same syntax as env.models[...].search([...]). $principal.* variables resolved per request. |
role_ids |
Many-to-many to roles. Empty = the rule is GLOBAL (applies to every user, AND-merged with other globals). Non-empty = the rule is role-scoped (OR-merged within each role bucket; OR'd across role buckets for multi-role users). |
perm_read / perm_create / perm_update / perm_delete |
Booleans selecting which operations the rule gates. A perm_read=true rule narrows reads; a perm_update=true rule blocks updates on rows outside the filter. |
sequence |
Display order. |
active |
Standard soft-archive flag. |
Composition algorithm¶
The engine composes every active rule on a (model, action) into one final domain:
The final composition is AND-merged with the model's active filter (Chapter 2) and any company_scope filter (next section), then applied to every read.
Boundary cases worth knowing:
- No rules at all → no narrowing; the user sees every row the permission engine allows.
- Globals only, no role rules → the AND of every global is the universal floor.
- A user with NO matching role bucket on a model that has role-scoped rules → fail-closed empty set. Once any role rule exists for a model, only matching roles can see rows.
- System admin (role code
system_admin) → bypasses record rules entirely. Admins can't lock themselves out.
8. Company scope — automatic per-org isolation¶
For multi-organization deployments, every blog.post should be owned by one organization and visible only to users currently scoped to that org. You get this for free by opting the model in:
@api.model(
"blog.post",
record_name="title",
company_scope="strict", # ← one keyword
)
class BlogPost(DomainModel):
title = fields.Char(required=True)
...
What company_scope="strict" does:
- Auto-injects a required
organization_idReference field pointing atres.organization. - Filters every read to the user's active organization. Posts owned by other orgs are simply not returned.
- Stamps the field automatically on every create from
env.active_organization_id. - Rejects writes that target an organization the user doesn't have in
$principal.allowed_organization_ids.
Three modes:
| Mode | Behaviour |
|---|---|
"strict" |
Required organization_id. Reads filter to the active org only. Create stamps from the active org. |
"optional" |
Nullable organization_id. Reads include the active org's rows or rows where organization_id is None (tenant-global). Useful for catalogue-like models. |
"multi" |
Auto-injects an organization_ids Many2Many. Reads return rows whose set includes the active org. Use for records shared across multiple orgs. |
The migration adds the FK column for you. Re-run ede migrate generate after adding company_scope=... and the diff shows the new column with the right index.
9. Switching the active organization at runtime¶
A user with access to multiple organizations can switch between them. The HTTP endpoint:
The endpoint re-issues the JWT with a new active_organization_id claim, provided the target is in the user's allowed_organization_ids. Subsequent requests are scoped to the new org automatically — every read filters to it, every create stamps it.
10. Inspecting "why was I denied"¶
Every denial writes a row to ir.rbac.decision.log with a structured reason code:
reason |
Meaning |
|---|---|
permission_missing |
No role the user holds grants the requested action on the resource. |
record_rule_violation |
The user has the permission but the row isn't in any visible bucket. |
wrong_organization |
A write targets an org outside the user's allowed_organization_ids. |
Open Settings → Roles & Permissions → Decision Log in the web client and filter by reason = "record_rule_violation" when debugging an "I can't see this row" issue. The log also captures the active organization at the time of the request — useful for diagnosing scope mismatches.
11. Apply and verify¶
Wire the new files in src/domains/blog/post/__manifest__.py:
"data": [
"views/blog_post_views.xml",
"data/blog_post_actions.xml",
"data/blog_post_menus.xml",
"data/blog_roles.xml",
"data/blog_permissions.csv",
"data/blog_record_rules.xml",
],
Apply:
If you added company_scope=..., run a migrate generate first to produce the column migration, then upgrade.
Verify in the web client:
- Log in as Alice. The Blog app appears. She sees only her own drafts and every published post.
- Log in as a portal-only user. The Blog app still appears (portal can read posts), but the Create button is hidden — no
blog.post.createpermission. - Look at Settings → Roles & Permissions → Permissions. Filter
resource = blog.post. Every row you authored should appear, tied to its role.
What you just did¶
- Declared a
blog_authorrole inheriting frominternal_user. - Authored a CSV of permissions covering read / create / update / delete / publish for the blog models.
- Bound a user to the role.
- Wrote a global record rule that combines public-published with private-drafts in one composed domain filter.
- Saw how
$principal.*variables let one rule serve every user without per-user duplication. - Learned the difference between permissions (coarse, role × resource × action) and record rules (fine, row-level filter with composition).
- Optionally enabled company scope with one decorator argument and let the framework do per-org isolation for you.