- The portal security model: who is a portal user and what they can do
- How access tokens work and when to use them
- Record rules scoped to
base.group_portal - How to use
_document_check_accesssafely - Common mistakes that cause data leaks
Portal Security Model
Portal users belong to the base.group_portal security group. This group has no backend access — portal users cannot access /web/. Their permissions are limited to what you explicitly grant via:
- Access rights (
ir.model.access.csv) — what models they can read/write/create/delete - Record rules (
ir.rule) — which rows within a model they can access
Without a record rule, a portal user with read access to your model could potentially read all records. Always add a record rule.
Access Tokens
Access tokens are UUID-4 strings stored in the access_token field (added by portal.mixin). They allow unauthenticated access to a single record — useful for sharing a link with a third party who doesn't have a portal account.
Tokens are:
- Generated automatically when first needed via
_portal_ensure_token() - Passed as a URL query parameter:
/my/loans/42?access_token=abc123... - Resettable by the user via the "Regenerate Token" button in portal
Never expose access tokens in templates except inside the share URL. Don't render them as plain text.
Record Rules for Portal Users
A proper record rule for your model restricts portal users to records they own:
<!-- Grant read access to portal users -->
<!-- in security/ir.model.access.csv -->
<!-- access_library_loan_portal,library.loan portal,model_library_loan,base.group_portal,1,0,0,0 -->
<!-- Record rule: restrict to own loans -->
<record id="rule_library_loan_portal_user" model="ir.rule">
<field name="name">Library Loan: portal users see own records</field>
<field name="model_id" ref="model_library_loan"/>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="domain_force">
[('member_id.user_ids', 'in', [user.id])]
</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
The domain [('member_id.user_ids', 'in', [user.id])] resolves to: "the loan's member partner must have the current user in their user_ids list" — i.e., the member and the logged-in portal user are the same person.
_document_check_access
Use this helper in your detail page controller. It tries two things in order:
- Check if the current user owns the record (via normal ORM access check)
- If that fails, validate the
access_tokenparameter against the record's stored token
If both fail, it raises AccessError. If either succeeds, it returns a sudoed recordset:
from odoo.exceptions import AccessError, MissingError
try:
loan_sudo = self._document_check_access(
'library.loan', # model name
loan_id, # record ID from URL
access_token, # from request query string (None if not provided)
)
except (AccessError, MissingError):
return request.redirect('/my')
# loan_sudo is a sudo() recordset — safe to read any field
return request.render('my_module.portal_loan_detail', {'loan': loan_sudo})
Preventing Data Leaks
Common mistakes that expose data unintentionally:
| Mistake | Risk | Fix |
|---|---|---|
Using sudo() on listing page without domain | All records exposed | Always pass ownership domain to search() |
No record rule for group_portal | Portal users can enumerate all records via ORM | Add ir.rule scoped to base.group_portal |
| Returning full record JSON in API route | Sensitive fields exposed | Whitelist fields explicitly when returning JSON |
Not validating loan_id in URL | IDOR — guess any ID to access any record | Always use _document_check_access |
Field-Level Security
Sensitive fields (internal notes, cost price, employee salaries) should use the groups attribute so portal users can't read them even with sudo:
internal_notes = fields.Text(
string='Internal Notes',
groups='base.group_user', # internal users only
)
cost_price = fields.Float(
string='Cost Price',
groups='account.group_account_user',
)
- Portal users are in
base.group_portal— no backend access - Always add a record rule scoped to
base.group_portal— without it, access rights alone allow reading all records - Use
_document_check_accesson detail pages — never browse a record by ID without checking ownership first - Sensitive fields should use
groups='base.group_user'to prevent portal users from seeing them even via sudo
Frequently Asked Questions
Can a portal user reset their own access token?
Yes — the portal mixin adds a "Regenerate Access Token" button to the portal detail page. This invalidates the old token immediately. Use this to revoke shared links.
What happens if I forget the record rule for group_portal?
Without a record rule, the ORM applies no row-level filter for that group. Any portal user with read access to your model can potentially call search([]) and retrieve all records — a serious data exposure risk.
Is it safe to use sudo() in portal controllers?
Yes, with care. Use sudo() only after validating ownership (via _document_check_access or manual check). Never call sudo().search([]) on the listing page without an ownership domain — this would expose all records.
How do I share a portal link that works without login?
Generate the URL as {record.access_url}?access_token={record.access_token}. The detail page controller must use auth='public' and validate with _document_check_access passing the token.