Skip to content

fix(starter_sp_mis): make registry access control persist and enforce - #411

Open
emjay0921 wants to merge 4 commits into
19.0from
fix/1142-spmis-registry-restriction
Open

fix(starter_sp_mis): make registry access control persist and enforce#411
emjay0921 wants to merge 4 commits into
19.0from
fix/1142-spmis-registry-restriction

Conversation

@emjay0921

Copy link
Copy Markdown
Contributor

Why is this change needed?

OP#1142 — SPMIS settings unable to toggle off. Test pass.

The "Restrict Registry Edits to Admin Only" setting could not be turned off, and did not restrict anything when it was on.

Odoo stores a False config_parameter by deleting the row, and default_get falls back to the field default when the row is missing — so default=True made "off" unrepresentable and the toggle sprang back on at every reload. Worse, the enforcement side read a missing row as False, so unticking the box in fact disabled the restriction while the form went on claiming the registry was locked.

Enforcement was client-side only: JavaScript hid buttons with display: none and nothing on the server refused the write, so RPC and import went straight through. The list "New" button was never hidden either, because the patch assigned a canCreate property that Odoo 19's ListController does not read — its template gates on activeActions.create.

How was the change implemented?

  • Persistenceset_values writes the value explicitly as a string, so "off" is a stored fact rather than an absence. The install default moved to data/config_parameters.xml, now noupdate so an upgrade stops silently re-locking a registry an administrator deliberately opened.
  • Enforcement — a _check_access override on res.partner. That is the single chokepoint behind check_access, has_access and the ORM's own create/write/unlink guards, so one override refuses the change on every path and removes New/Edit/Delete from registry views for free: ir.ui.view._postprocess_access_rights stamps create="false" onto an arch whenever has_access('create') is False.
  • Scoped to registrants, so the setting cannot lock the Contacts app.
  • registry_restriction.js (260 lines) and the controller it called are deleted — the DOM hiding is no longer needed and never worked on lists anyway.

New unit tests

14, in spp_starter_sp_mis/tests/test_registry_restriction.py, covering the toggle round-trip in both directions, that the form and the enforcement can never disagree, that non-admins are refused create/write/unlink on registrants, that admins are exempt, that plain contacts are unaffected, and that the registry arch drops create for a restricted user.

Unit tests executed by the author

Re-run after merging 19.0 up:

spp_starter_sp_mis    0 failed, 0 error(s) of 16 tests
spp_mis_demo_v2       0 failed, 0 error(s) of 273 tests

spp_mis_demo_v2 is the only dependent module. Four of its access-control tests asserted the plain role model and were only passing because the restriction did nothing; they now pin the switch off explicitly, since they measure ACLs rather than this setting.

./spp lint clean.

How to test manually

  1. Settings → SP-MIS Settings. Untick Restrict Registry Edits to Admin Only, save, navigate away and back — it must stay unticked. Tick it again and confirm that sticks too.
  2. Upgrade the module with it unticked; it must not be silently re-enabled.
  3. As a Global Registrar, with the restriction on: Individuals and Groups show no New, the form is read-only, and an API create of a registrant is refused.
  4. As an administrator, everything still works.
  5. With the restriction on, the Contacts app is unaffected.

Related links

https://openspp.openproject.com/work_packages/1142

Reviewer notes

This is a real behaviour change for the SP-MIS bundle. The setting ships enabled and now actually works, so registrars lose registrant create/edit/delete by default where the restriction was previously decorative.

One residual on upgrade: databases where an administrator had already unticked the setting have no parameter row, because the old code deleted it. The noupdate record is recreated as True once, then stays where it is put.

The "Restrict Registry Edits to Admin Only" setting could not be turned
off, and did not restrict anything when on.

Odoo stores a False config_parameter by deleting the row, and default_get
falls back to the field default when the row is missing, so default=True
made "off" unrepresentable: the toggle sprang back on at every reload.
The enforcement side read a missing row as False, so unticking the box in
fact disabled the restriction while the form went on claiming the registry
was locked. Persist the value explicitly as a string and take the install
default from the data file, which is now noupdate so an upgrade stops
re-locking a registry an administrator deliberately opened.

Enforcement was client-side only: JavaScript hid buttons with display:none
and nothing on the server refused the write, so RPC and import went
straight through. The list "New" button was never hidden either, because
the patch assigned a canCreate property that Odoo 19's ListController does
not read - its template gates on activeActions.create.

Replace all of it with a _check_access override on res.partner. That is
the single chokepoint behind check_access, has_access and the ORM's own
create/write/unlink guards, so one override refuses the change on every
path and removes New/Edit/Delete from registry views for free:
ir.ui.view._postprocess_access_rights stamps create="false" onto an arch
whenever has_access('create') is False. Scoped to registrants so the
setting cannot lock the Contacts app.

The demo access-control tests assert the plain role model and were only
passing because the restriction did nothing, so they now pin the switch
off explicitly.
@emjay0921
emjay0921 marked this pull request as ready for review August 12, 2026 02:32
Comment thread spp_starter_sp_mis/models/res_config_settings.py Fixed
Comment thread spp_starter_sp_mis/models/res_partner.py Fixed
Comment thread spp_starter_sp_mis/models/res_partner.py Fixed
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.17949% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.52%. Comparing base (c33d3cb) to head (9a3a9a3).
⚠️ Report is 3 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_starter_sp_mis/models/res_partner.py 85.29% 5 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #411      +/-   ##
==========================================
+ Coverage   71.49%   71.52%   +0.03%     
==========================================
  Files         243      242       -1     
  Lines       20785    20820      +35     
==========================================
+ Hits        14860    14892      +32     
- Misses       5925     5928       +3     
Flag Coverage Δ
spp_base_common 91.07% <ø> (ø)
spp_dci_demo 94.28% <ø> (ø)
spp_mis_demo_v2 70.38% <ø> (ø)
spp_programs 65.27% <ø> (ø)
spp_registry 87.22% <ø> (+0.07%) ⬆️
spp_security 69.56% <ø> (ø)
spp_starter_sp_mis 88.88% <87.17%> (+2.22%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_starter_sp_mis/__init__.py 100.00% <ø> (ø)
spp_starter_sp_mis/models/__init__.py 100.00% <100.00%> (ø)
spp_starter_sp_mis/models/res_config_settings.py 100.00% <100.00%> (ø)
spp_starter_sp_mis/models/res_partner.py 85.29% <85.29%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

CI's semgrep flags odoo-sudo-without-context on all three sudo() calls.
Each is deliberate, so record why next to it:

- writing the config parameter is a Settings-manager operation, and the
  settings form is already gated on that group;
- reading it is how the guard decides whether to withhold access, so every
  user has to be able to read it;
- filtering on is_registrant as the acting user would recurse straight back
  into the access check being evaluated.

The pragma has to sit on the line immediately above the match, not at the
head of the comment block, or semgrep does not associate the two.

Also applies ruff's preferred spacing on a docstring that opens with a
quoted word.

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the design here is right, and the root-cause analysis in the PR body is accurate. I verified the load-bearing claims against the Odoo 19 source: _check_access is the single chokepoint the docstring says it is, create() re-runs check_access('create') on the populated records (odoo/orm/models.py:4948), so a bare RPC create of a registrant with no registry context really is refused; both registry actions carry default_is_registrant, so the arch gating works; and the ListController/activeActions.create point about the old JS is correct. The test suite is thorough.

Two blockers, one major gap, one coordination item, and some nits.

1. Blocker — the noupdate move has no effect on any existing database

Verified in odoo/addons/base/models/ir_model.py (_build_update_xmlids_query): the xml_id upsert is ON CONFLICT … DO UPDATE SET (model, res_id, write_date) — the stored noupdate flag of an existing xml_id is never updated on upgrade. Every database that already has spp_starter_sp_mis.config_registry_admin_only_crud keeps noupdate = False, so each module upgrade re-applies value=True — silently re-locking a registry the administrator deliberately opened. That is the exact re-lock bug this PR sets out to fix; as written, the fix only takes effect on fresh installs, and the reviewer note "recreated as True once, then stays where it is put" doesn't hold for existing DBs.

Ask: add a migration that sets ir_model_data.noupdate = true for ('spp_starter_sp_mis', 'config_registry_admin_only_crud').

2. Blocker — no version bump

spp_starter_sp_mis stays at 19.0.2.0.0, and the module shipped in release 2026.08. The asset removal, deleted controller, new model file and data-file change all need an upgrade to take effect — without a bump, deployments keep referencing the deleted JS asset and never get the enforcement. This folds naturally into the migration from #1.

3. Major — two-step bypass: create a contact, then flag it as a registrant

is_registrant is a plain Boolean with no field-level groups (spp_registry/models/registrant.py:36), and write() checks access against the record's current values only — there is no post-write pass. So with the restriction on, a non-admin can:

  1. create({'name': X}) — allowed, plain contact;
  2. write({'is_registrant': True, …}) — allowed, because _check_access('write') filters on the current value (False) — and the partner is now a registrant.

The same move promotes any existing plain contact into the registry. Not a regression (the old JS enforced nothing), but it defeats the PR's stated purpose over RPC/import.

Ask: guard the flag flip — e.g. a small write override refusing a truthy is_registrant in vals for restricted users. (Unflagging is already blocked, since the record is a registrant at check time.)

4. Coordination — open PR #299 modifies the file this PR deletes

#299 (feat/registry-restriction-context-bypass) adds a per-action context opt-out to registry_restriction.js, which this PR removes. If this lands first, #299 is obsolete and the opt-out has no server-side equivalent — and note that honoring a context key inside _check_access would not be a safe port, since context is client-supplied on RPC and would let any caller waive the restriction. Whatever replaces the per-action opt-out (an exemption group, or simply running with the setting off) should be decided before one of these merges; flagging so the two PRs don't land past each other.

5. Nits

  • readme/DESCRIPTION.md (and the generated README.rst) still document the deleted mechanism: "JavaScript-based restriction", "Patches FormController and ListController", and the removed /spp_starter_sp_mis/registry_restriction endpoint. Worth rewriting for the server-side model — as it stands the docs advertise client-side enforcement.
  • The empty-recordset probe refuses any create in a context carrying default_is_registrant, even if the vals wouldn't create a registrant (a wizard inheriting registry context but creating a plain contact would be refused early, where the post-create pass alone would have allowed it). Marginal — a code comment accepting this explicitly would do.
  • Confirming the intended behaviour change extends to spp_mis_demo_v2: it ships the restriction on, so demo registrar walkthroughs lose registrant editing after this PR.

…ion bypass

Marking the config parameter noupdate only governs xml_ids created from then
on: _build_update_xmlids_query upserts with DO UPDATE SET (model, res_id,
write_date) and never rewrites an existing row's noupdate flag. Every database
that already carries this xml_id therefore kept re-applying value=True on each
upgrade — silently re-locking a registry an administrator had deliberately
opened, which is the bug the change set out to fix. A migration flips the flag
on the row itself, and a test asserts it is set.

The access check filters on a record's current values and there is no
post-write pass, so a restricted user could create a plain contact and then set
is_registrant on it — two allowed steps adding up to a registrant they were
never allowed to create, and the same move promotes any existing contact.
write() now refuses that flip while the restriction is on. Unflagging needs no
guard: the record is already a registrant when the check runs.

Version bump with its changelog entry. This one is load-bearing rather than
convention: without an upgrade a deployment keeps referencing the deleted JS
asset and never gains the server-side enforcement.

DESCRIPTION.md described the mechanism this branch removes — a JavaScript
patch, a JSON-RPC endpoint and a MutationObserver — so it advertised client-side
enforcement that no longer exists. Rewritten for what the module now does.
@emjay0921

Copy link
Copy Markdown
Contributor Author

Thanks — the two blockers and the major gap are fixed, the nits are done, and the coordination item needs a decision that is not mine alone. Pushed as 9a3a9a3e.

1. The noupdate move having no effect — fixed, and thank you for catching it. You are right that _build_update_xmlids_query upserts (model, res_id, write_date) and never rewrites an existing row's noupdate, so the declaration only ever governed fresh installs while every existing database kept re-locking itself on upgrade — the exact bug this PR exists to fix, left in place by the fix. There is now a migration flipping the flag on the row, and a test asserting it is set, so the claim in my reviewer note is pinned rather than asserted.

2. Version bump — 19.0.2.1.0, which the migration folder needs anyway. Load-bearing here rather than convention, as you say: without it a deployment keeps the deleted JS asset referenced and never gets the enforcement.

3. The two-step bypass — closed. write() now refuses setting is_registrant truthy on a record that is not one, while the restriction is on. Unflagging needed no guard, as you noted, since the record is a registrant when the check runs. Four tests: promotion refused, promotion allowed with the setting off, admin unaffected, unflagging still refused.

4. Coordination with #299 — flagging rather than deciding. I agree the context-key opt-out cannot be ported into _check_access: context is client-supplied on RPC, so honouring it would let any caller waive the restriction — that is not a safe equivalent, it is an unlocked door with a sign on it. So if this lands first, #299's mechanism has no server-side successor and the choice is between an exemption group and simply running with the setting off. That is a product decision about who may edit the registry, so I would rather it be made deliberately than fall out of merge order. @gonzalesedwin1123 — which way do you want it, and should #299 be closed or reworked?

5. Nits:

  • DESCRIPTION.md rewritten. It described a JavaScript patch, a JSON-RPC endpoint and a MutationObserver, none of which exist after this branch — it was advertising client-side enforcement that had been deleted. It now describes the access check, the promotion guard, the noupdate behaviour and the registrant-only scope.
  • The empty-recordset probe: accepted deliberately and now commented as such. Refusing early in a context carrying default_is_registrant can turn away a create the post-create pass would have allowed; the alternative is a view that offers a New button leading to a refusal, which is worse for the person using it.
  • spp_mis_demo_v2 ships the restriction on, so demo registrar walkthroughs do lose registrant editing. That is the setting behaving as documented rather than a regression, but it is a real change to the demo experience — noted on OP#1142 so QA can decide whether the demo should ship with it off.

spp_starter_sp_mis suite: 21 tests, 0 failed. pre-commit clean.

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — both blockers and the bypass are fixed correctly, and the fixes carry their own regression pins.

What I verified on 9a3a9a3e:

  • The noupdate migration is exactly right. Literal SQL (semgrep-safe per the repo convention), idempotent (AND noupdate IS NOT TRUE), fresh-install guarded, and aimed at the correct row — the xml_id name matches what yesterday's check of _build_update_xmlids_query established would otherwise never be rewritten. The accompanying test asserting the row's flag is a nice touch: on fresh databases it pins the data-file declaration, on upgraded ones the migration satisfies it — one assertion guarding both paths.
  • The promotion bypass is closed with the correct scoping. The write() guard fires only on a truthy is_registrant in vals, only for restricted users, and only for records currently outside the registry — and the reasoning that unflagging needs no guard (the record is already a registrant when _check_access runs) is proven by the test keeping that direction refused. Promote-when-off and admin-exempt are both covered.
  • Version bump is load-bearing here, as your commit message says: without the upgrade a deployment keeps referencing the deleted JS asset and never gains the enforcement. 19.0.2.1.0 + HISTORY entry present.
  • The docs now describe the module that exists — DESCRIPTION/README rewritten off the JavaScript-patch mechanism.

CI fully green.

One gate before merge, and it sits with Edwin rather than with this diff: the fate of #299's per-action opt-out. Merging this PR deletes the file #299 modifies, and a context-key bypass cannot be ported into _check_access safely (context is client-supplied on RPC). The replacement — an exemption group, or the affected deployment running with the setting off — needs deciding before this lands so the two PRs don't merge past each other. That decision is tracked on our side; nothing further is needed from you on this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants