Security
Contents
Security#
For: developers
OpenSPP uses a three-tier group architecture for access control. This is the most important OpenSPP-specific pattern to understand — it differs significantly from typical Odoo module security.
The three-tier group architecture#
Every OpenSPP module defines security groups in three tiers:
Tier 3 — Technical groups#
These are internal groups used only in ACL (ir.model.access.csv) definitions. Users never see them. They represent individual CRUD permissions:
<!-- security/groups.xml -->
<record id="group_myfeature_read" model="res.groups">
<field name="name">My Feature: Read</field>
<field name="comment">Technical group for read access.</field>
</record>
<record id="group_myfeature_write" model="res.groups">
<field name="name">My Feature: Write</field>
<field name="comment">Technical group for write access.</field>
<field name="implied_ids" eval="[Command.link(ref('group_myfeature_read'))]" />
</record>
<record id="group_myfeature_create" model="res.groups">
<field name="name">My Feature: Create</field>
<field name="comment">Technical group for create access.</field>
<field name="implied_ids" eval="[Command.link(ref('group_myfeature_read'))]" />
</record>
Note the inheritance chain: both write and create are siblings that each imply read. The user-facing (Tier 2) groups explicitly compose the technical groups they need (e.g., an Officer gets both create and write via the Tier 2 record).
Tier 2 — User-facing groups#
These are the groups that administrators assign to users. Each is linked to a res.groups.privilege record (an Odoo 17+ feature) and composed from Tier 3 technical groups.
Note
By convention, OpenSPP modules split security records across two files: security/privileges.xml (the res.groups.privilege records) and security/groups.xml (the res.groups and admin linkage). privileges.xml is loaded before groups.xml so the groups can reference the privileges. Make sure your __manifest__.py lists them in that order.
Note
Pick a domain-specific category (from spp_security) rather than category_spp_admin. Examples: category_spp_registry, category_spp_programs, category_spp_grm. The category controls where your groups appear in the user permissions UI — putting everything under Administration buries them. See spp_security/README.rst for the full list.
<!-- security/privileges.xml -->
<record id="privilege_myfeature_viewer" model="res.groups.privilege">
<field name="name">Viewer</field>
<field name="category_id" ref="spp_security.category_spp_registry" />
<field name="sequence">200</field>
</record>
<record id="group_myfeature_viewer" model="res.groups">
<field name="name">My Feature Viewer</field>
<field name="privilege_id" ref="privilege_myfeature_viewer" />
<field name="comment">Can view but not modify.</field>
<field name="implied_ids" eval="[Command.link(ref('group_myfeature_read'))]" />
</record>
<!-- Officer: read + write + create -->
<record id="privilege_myfeature_officer" model="res.groups.privilege">
<field name="name">Officer</field>
<field name="category_id" ref="spp_security.category_spp_registry" />
<field name="sequence">210</field>
</record>
<record id="group_myfeature_officer" model="res.groups">
<field name="name">My Feature Officer</field>
<field name="privilege_id" ref="privilege_myfeature_officer" />
<field name="comment">Can create and modify records.</field>
<field name="implied_ids" eval="[
Command.link(ref('group_myfeature_read')),
Command.link(ref('group_myfeature_write')),
Command.link(ref('group_myfeature_create')),
]" />
</record>
<!-- Manager: full CRUD including delete -->
<record id="privilege_myfeature_manager" model="res.groups.privilege">
<field name="name">Manager</field>
<field name="category_id" ref="spp_security.category_spp_registry" />
<field name="sequence">220</field>
</record>
<record id="group_myfeature_manager" model="res.groups">
<field name="name">My Feature Manager</field>
<field name="privilege_id" ref="privilege_myfeature_manager" />
<field name="comment">Full access including delete and configuration.</field>
<field name="implied_ids" eval="[Command.link(ref('group_myfeature_officer'))]" />
</record>
Tier 1 — Admin linkage#
The SPP admin group automatically gets full access to your module:
<!-- Link Manager to SPP Admin -->
<record id="spp_security.group_spp_admin" model="res.groups">
<field name="implied_ids" eval="[Command.link(ref('group_myfeature_manager'))]" />
</record>
The complete inheritance chain#
spp_security.group_spp_admin
└── group_myfeature_manager (Tier 2: full CRUD)
└── group_myfeature_officer (Tier 2: read + write + create)
│ ├── group_myfeature_read (Tier 3)
│ ├── group_myfeature_write (Tier 3) → implies read
│ └── group_myfeature_create (Tier 3) → implies read
└── group_myfeature_viewer (Tier 2: read only)
└── group_myfeature_read (Tier 3)
Tier 3 groups write and create are siblings — both imply read directly. They do not imply each other. Tier 2 user-facing groups compose whichever Tier 3 groups they need.
Model access rules (ACL)#
The security/ir.model.access.csv file maps groups to CRUD permissions on each model:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_spp_my_feature_system,System Admin,model_spp_my_feature,base.group_system,1,1,1,1
access_spp_my_feature_spp_admin,SPP Admin,model_spp_my_feature,spp_security.group_spp_admin,1,1,1,1
access_spp_my_feature_read,Read,model_spp_my_feature,group_myfeature_read,1,0,0,0
access_spp_my_feature_write,Write,model_spp_my_feature,group_myfeature_write,1,1,0,0
access_spp_my_feature_create,Create,model_spp_my_feature,group_myfeature_create,1,1,1,0
access_spp_my_feature_manager,Manager,model_spp_my_feature,group_myfeature_manager,1,1,1,1
ACL naming convention#
The id column follows this pattern: access_{model_name}_{group_suffix}
model_spp_my_feature— dots in the model name are replaced with underscoresAlways include rows for
base.group_systemandspp_security.group_spp_admin
ACL file is mandatory#
The openspp-check-acl pre-commit hook verifies that every installable module has a security/ir.model.access.csv file.
Record rules#
Record rules filter which records a user can access. The most common pattern is multi-company isolation:
<!-- security/rules.xml -->
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="rule_my_feature_multi_company" model="ir.rule">
<field name="name">My Feature: Multi-Company</field>
<field name="model_id" ref="model_spp_my_feature" />
<field name="domain_force">[
'|',
('company_id', '=', False),
('company_id', 'in', company_ids)
]</field>
<field name="global" eval="True" />
</record>
</odoo>
Key points:
Use
noupdate="1"so the rules aren't overwritten on module upgradeglobal="True"means the rule applies to all users (not group-specific)The
company_idsvariable represents the user's allowed companiesAlways include the
('company_id', '=', False)fallback for records without a company
XML ID naming conventions#
The pre-commit hook openspp-check-xml-ids enforces these patterns:
Type |
Pattern |
Example |
|---|---|---|
Groups |
|
|
Privileges |
|
|
Record rules |
|
|
ACL IDs |
|
|
Compliance check#
Each OpenSPP module ships a security/compliance.yaml that declares the security posture of the module (which models are secured, which audit logs are produced, etc.). The openspp-compliance-check pre-commit hook validates modules against it. New custom modules should include a minimal compliance.yaml — see an existing module's copy for the format.
openspp.org