Skip to content

[18.0][ADD] web_field_provenance: ORM dirty bit + SAP-style badge#2

Open
dnplkndll wants to merge 6 commits into
18.0from
18.0-add-web_field_provenance
Open

[18.0][ADD] web_field_provenance: ORM dirty bit + SAP-style badge#2
dnplkndll wants to merge 6 commits into
18.0from
18.0-add-web_field_provenance

Conversation

@dnplkndll
Copy link
Copy Markdown

Summary

New ledoent module that fixes the OCA-wide bug class where compute=..., store=True, readonly=False fields silently overwrite manual user overrides on every recompute pass. Ships:

  • record._user_set(fname) — combine a persisted provenance map with an _origin transactional fallback. Compute overrides call this to gate super() on real user intent.
  • record._stamp_provenance(keys, *, source, by, rule=None, when=None) — public API for cascade methods, EDI connectors, and import loaders to stamp rule/cascade provenance on writes they perform on behalf of users.
  • record._provenance_for(fname) — returns the badge-tooltip dict (also useful as golden-output target in tests).
  • OWL provenance_m2o widget — small badge next to opted-in fields with three states:
    • grey outline = default (no entry — value still at its initial state)
    • green cog = rule / cascade / EDI
    • pencil = user-set
      Hover reveals the writer and timestamp. Clicking promotes the value to user-anchored; the server sanitizes the payload so writer identity cannot be spoofed.
  • _provenance JSON column on every model — compact {s, b, t, r?} entries, only populated for fields opted in via ir.model.fields.track_provenance. Default state is implicit (no entry) so the column stays compact.

Why

OCA sale_order_type is one example: every time a partner or type changes, the seven mirror computes call super() and clobber manual user-set values for pricelist, payment term, warehouse, etc. PR OCA/sale-workflow#4273 patches one symptom; this module is the missing infrastructure.

First consumer: ledoent/sale-workflow#3_sot_resolve already has a soft-dependency hook (hasattr(self, "_user_set")) that integrates the moment this module is installed.

What's inside

File Purpose
models/base_model.py The whole API: _provenance field, _user_set, _stamp_provenance, _provenance_for, web_read injection, sanitization of client writes
models/ir_model_fields.py track_provenance opt-in flag + cache invalidation on toggle
static/src/provenance_badge.{esm.js,xml,scss} OWL widget for 3-state badge + hover tooltip
tests/test_provenance.py 23 tests covering all paths
readme/{DESCRIPTION,USAGE,ROADMAP}.md Documentation including the cascade-attribution API and the security note
views/ir_model_fields_views.xml Surfaces the opt-in checkbox

Tests (23/23 pass)

sale_order_type/_user_set integration:
  test_create_with_value_stamps_user
  test_create_without_value_does_not_stamp
  test_write_stamps_user
  test_user_write_flips_rule_to_user
  test_user_set_via_explicit_stamp

Rule / cascade public API:
  test_stamp_provenance_rule
  test_stamp_provenance_user_via_public_api
  test_stamp_provenance_rejects_unknown_source
  test_stamp_provenance_requires_by
  test_stamp_provenance_skips_untracked_keys
  test_explicit_when_is_persisted
  test_multirecord_stamp_writes_each

Badge tooltip:
  test_provenance_for_default
  test_provenance_for_user
  test_provenance_for_rule
  test_provenance_for_legacy_string_entry

Backward-compat:
  test_legacy_system_string_falls_back_to_default

Security (sanitization):
  test_client_provenance_write_forces_env_user
  test_client_cannot_claim_rule_provenance
  test_client_provenance_non_dict_payload_rejected

Field-tracking caching:
  test_untracked_field_not_stamped
  test_cache_invalidated_on_flag_toggle
  test_unlink_invalidates_track_cache

Web client integration:
  test_web_read_emits_provenance

Test plan

  • Install the module against a fresh 18.0 DB.
  • Run scoped suite: odoo -i web_field_provenance --test-enable --test-tags '/web_field_provenance' → 23/23 pass.
  • Manual UI walk in the doodba review env: install on top of ledoent/sale-workflow#3, opt-in sale.order.payment_term_id, verify the badge appears with the three icons.
  • Verify the cross-system claim: a malicious RPC client cannot write {_provenance: {payment_term_id: {s:"u", b:"admin", t:0}}} — server sanitizes to b=env.user.login.

Notes for reviewers

  • This is a review-only PR on the fork for now. Once we've shaken it out with a downstream consumer (sale-workflow#3) and a Doodba review env, intent is to file the equivalent PR on OCA/web.
  • Cross-cuts every model via _inherit = "base". The cost is gated behind ir.model.fields.track_provenance — for any field that hasn't opted in, the write-path overhead is one frozenset lookup and the read-path is untouched.
  • Base-field protection in ir.model.fields.write() blocks the UI from flipping track_provenance on fields declared in Python. ROADMAP lists a small admin wizard to do this via the documented SQL bypass; tests use it directly.
  • Sibling Huly tracker: OCA-23.

dnplkndll added 5 commits May 16, 2026 15:39
…ic stamping API

Schema change in `_provenance`:

  - Absence of an entry  → field still at its default (no stamp needed).
  - {"s":"u", "b":<login>, "t":<unix>}        → user-set.
  - {"s":"r", "b":<id>, "t":<unix>, "r":<lbl>} → rule / cascade / EDI.

Defaults are intentionally not stamped to keep the JSON column compact;
`_provenance_for` uses `record.create_date` as the "default since" proxy
without storing extra data.

Adds:

  - Public `_stamp_provenance(keys, *, source, by, rule=None, when=None)`
    for cascades, integrations, and EDI writers. Rejects unknown sources
    and empty `by` to keep tooltips informative.
  - `_provenance_for(fname)` returning the tooltip dict the OWL widget
    renders. Three states: default, user, rule.
  - OWL widget renders three icons (grey dot / green cog / pencil) and
    a hover tooltip with writer + timestamp.

Tests: 17 cases covering the user/rule/default paths, the public API
contract (rejected sources, empty `by`, untracked-key filter), the
override flow (rule -> user write flips state), legacy bare-string
entries (backward-compat read), and explicit-timestamp injection.

Test fixture uses SQL to flip `track_provenance` on a base field
because Odoo blocks `ir.model.fields.write()` on `state='base'`. The
production opt-in wizard is a documented ROADMAP item.

ROADMAP extended with the API / EDI integration sub-track and the
opt-in wizard.
…+ dead code

Self-review findings addressed before opening the PR:

* **Security**: the OWL `onBadgeClick` writes `_provenance` directly
  with a client-supplied `b` (login) and `t` (timestamp). The server
  accepted this verbatim, so a malicious client could spoof attribution.
  Adds `_sanitize_client_provenance` invoked from `write()` whenever
  `_provenance` appears in the incoming vals:
    - any entry the client provides is rewritten to
      `{s:"u", b:env.user.login, t:int(time.time())}`
    - any client-asserted `s="r"` entry is dropped — only server-side
      cascade callers (via `_stamp_provenance`) may stamp rule
      provenance
    - non-dict payloads sanitize to `{}` rather than persist junk
  Server-side `_stamp_provenance_keys` continues to bypass this path
  via the `_prov_skip` context.

* **SCSS**: template emits `o_provenance_default | _rule | _user` but
  the stylesheet only colored `_user` and the obsolete `_system`. Adds
  rules for `_default` (muted) and `_rule` (success/green), drops the
  obsolete `_system` selector.

* **Dead code**: `_logger` was imported but never used; `badge.visible`
  state + the `t-if=badge.visible` template branch never toggled. Both
  removed.

* **Docs**: `DESCRIPTION.md` and `USAGE.md` still described the v0.1
  two-state model (gear / pencil) and missed the new
  `_stamp_provenance` public API. Rewritten to describe the three
  states, the cascade API, and the sanitization guarantee.

No behavior change for the existing 17 tests; the follow-up commit adds
coverage for the security path and the surfaced gaps.
…record, cache

Six new tests filling the gaps surfaced in self-review.

Security path coverage:
* test_client_provenance_write_forces_env_user — a client write
  with a spoofed b="attacker" is rewritten to env.user.login by
  _sanitize_client_provenance; timestamp replaced with int(time.time()).
* test_client_cannot_claim_rule_provenance — a client payload with
  s="r" is dropped entirely. Only _stamp_provenance (server-side)
  may stamp rule provenance.
* test_client_provenance_non_dict_payload_rejected — a string,
  list, etc. sanitizes to empty {} rather than persisting junk.

Backward-compat:
* test_legacy_system_string_falls_back_to_default — v0.1 stored bare
  strings "u" and "s". The bare "s" mapped to "system assigned" in
  v0.1; in the 3-state schema it correctly resolves to the "default"
  badge state and _user_set() returns False.

Multi-record:
* test_multirecord_stamp_writes_each — (p1+p2)._stamp_provenance(...)
  writes the stamp on each record (one UPDATE per record by design;
  see _stamp_provenance_keys).

Cache invalidation:
* test_unlink_invalidates_track_cache — toggling track_provenance off
  via the documented SQL path invalidates the cached _field_track_set
  so subsequent create() does not stamp.

Combined suite: 23/23 pass.
@dnplkndll dnplkndll closed this May 17, 2026
@dnplkndll dnplkndll reopened this May 17, 2026
Two integration bugs surfaced when wiring the first consumer
(sale_order_type cascade via ledoent/sale-workflow#3):

* **Onchange diff leak**: stamping `_provenance` via `write()` during
  a compute leaked the field into Odoo's onchange diff. Form views
  that don't declare `_provenance` (most do not — the badge consumes
  it via `web_read` only) raised `KeyError: '_provenance'`. Switched
  `_stamp_provenance_keys` to raw SQL + `invalidate_recordset` so the
  stamp does not surface as an in-flight ORM change.

* **NewId records cannot be SQL-updated**: skip records whose `id` is
  not yet a database integer. This affects two paths:
    1. Form / onchange — stamping is the wrong thing anyway, see above.
    2. `create()` precompute — there is no DB row to UPDATE.
  Both paths fall back to the cascade attribution being applied on
  the next persistent-record write that touches the field, which
  matches typical user flows (the cascade re-fires when a user
  changes `type_id` on a saved SO). Documented in the docstring; a
  deferred-flush mechanism is the right long-term fix and stays on
  the ROADMAP.

Existing 23 web_field_provenance tests still pass; the sibling
sale-workflow PR's 60-test suite goes 60/60 once the SO cascade is
exercised on persistent records (separate commit there).
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.

1 participant