Security Notes
What Restrict Delete & Export can read and write, what it stores, and what leaves your instance.
This module refuses operations. Anything that refuses operations can, if it goes wrong, refuse the wrong ones — or refuse everything. This document sets out what prevents that.
You cannot lock yourself out
Losing access to your own data would be worse than any deletion this module prevents. Four guarantees, in order of how much they matter:
1. The superuser always bypasses
env.su or uid == 1 returns before any rule is consulted. This is not configurable and has no setting. It is the reason a bad rule can always be undone — from the shell if necessary.
2. Critical models can never be restricted
res.users, res.groups, res.company, ir.model, ir.model.access, ir.rule, ir.module.module, ir.config_parameter, ir.cron, ir.ui.view, ir.actions.* and others are refused by the rule form with an explanation.
This is a Python frozenset, not a configuration table — so it cannot be edited from the interface, cannot be widened by a data file, and cannot be bypassed by an administrator having a bad day. Restricting ir.rule or ir.model.access would let someone remove the ability to remove the restriction.
3. The module's own records can never be restricted
access.restriction.rule and access.restriction.attempt are in the same frozenset. A rule can always be edited or deleted.
4. It fails open
If the configuration cannot be read — a corrupt cache, a partially upgraded database, a bug — _ar_check logs a warning and returns, allowing the operation.
This is a deliberate choice, and it is the opposite of what a security product usually does. The reasoning: this module's job is to prevent mistakes and casual misuse, not to defend against a determined attacker who has already got an Odoo login. Failing closed would mean a bug in this module stops a business from operating. Failing open means a bug in this module means a rule is not enforced for a while, which is recoverable.
If you need enforcement that fails closed, Odoo's own access rights and record rules are the right tool — they are part of the ORM and cannot be bypassed by a module fault.
The Settings-group bypass
By default anyone in base.group_system ignores every restriction. This is on because the alternative — an administrator who cannot delete a test record on their own database — is how people end up uninstalling the module.
You can turn it off in Settings → Access Restrictions. Before you do:
- make sure your own account is on the allowed list of every rule you might need, or
- accept that you will need the superuser (a shell, or --load-language-style access) to undo a mistake.
The superuser bypass remains regardless, so the database is never unrecoverable.
What this module does not do
It is not a substitute for access rights. A user who cannot read a record cannot delete it either, and Odoo already handles that. This module addresses the narrower case: someone who legitimately has write access, but should not be able to delete, export or print.
It does not stop raw SQL. Nothing at the ORM level can.
It does not stop every export path. Odoo's standard CSV and Excel exports go through one funnel and are covered. Exports through custom controllers, the XML-RPC/JSON-RPC API, or third-party report engines do not pass through it.
It does not restrict reading. Odoo emits no hook on read, and this module makes no claim to.
It does not defend against an administrator. Anyone in the Settings group can disable the module, edit the rules, or uninstall it. Blocked attempts are logged, but the log lives in the same database.
The blocked-attempt log
Why it is written on a separate cursor
When an operation is refused, the AccessError propagates and Odoo rolls the transaction back. A log record written in that transaction would be rolled back with it — the refusals would vanish exactly when you wanted them.
So the log is written through a dedicated cursor that commits independently. This is the same technique Odoo core uses for failed-login records.
A consequence worth knowing: the log survives even if the refused operation was part of a larger transaction that would have failed anyway.
What it records
User, timestamp, operation, model, record count, a sample of record ids, the display names when five or fewer records were involved, and the client IP.
No field values are recorded. The log tells you that someone tried to delete four contacts, not what was in them.
Retention
Purged daily past a configurable horizon (default 180 days), in bounded batches so a large backlog never takes a long lock. Set the horizon to 0 to keep indefinitely.
It is not editable
access.restriction.attempt.write refuses anything that is not the module itself, including a Restriction Manager. Deletion is permitted to Managers so that retention and data-subject requests can be honoured.
Implementation notes for reviewers
- No eval, no exec, no raw SQL.
- No new HTTP routes. The module subclasses Odoo's two existing export controllers and wraps one method.
- _inherit = "base" for the ORM hooks — the documented Odoo mechanism, used by web, website and sms, not a monkey patch.
- The check runs on unlink, copy, and writes that set active to false. Not on reads, and not on ordinary writes.
- sudo() is used only to read the configuration and to write the log. It is never used to widen what a user can see.
- The refusal message is interpolated defensively. A custom message with a bad placeholder falls back to the raw template rather than raising, so a typo in a message cannot turn a refusal into a traceback.
Privacy
The blocked-attempt log records who attempted what. In some jurisdictions that is employee monitoring and must be disclosed to staff. The module does not do that for you. Logging can be disabled globally or per rule.
Reporting a vulnerability
Email info@technovision.dev with "SECURITY" in the subject. Please include the Odoo version, the module version, and reproduction steps.