Skip to content

feat(spp_attendance): migrate from openspp-modules - #435

Open
gonzalesedwin1123 wants to merge 14 commits into
19.0from
migrate-spp-attendance
Open

feat(spp_attendance): migrate from openspp-modules#435
gonzalesedwin1123 wants to merge 14 commits into
19.0from
migrate-spp-attendance

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Summary

Migrates spp_attendance from openspp-modules (@ 5a1afb71b): a standalone, API-first attendance
service — own participant registry keyed by person_identifier, OAuth-secured REST endpoints for
external attendance submission, config-driven participant sync from a registry, CSV import wizard.

First commit is the verbatim import; every adaptation/fix is its own commit. Version goes
19.0.1.3.1 → 19.0.2.0.0 because the security fix ships a data migration (see below).

Self-containment (review goal): deps stay base + spp_oauth + spp_security; the module
consumes only the stable spp_oauth.tools helper API; no other module is touched anywhere in this
PR. Positioning vs spp_session_tracking (internal program-session attendance — different niche,
no shared models) is documented in the DESCRIPTION.

Security fix: client secrets (agreed in review)

Secrets were stored in plaintext forever — the "show once" button was a UI gate, bypassable via
RPC read; the token endpoint searched by plaintext; the Viewer group could read the credentials
table. Now, mirroring spp_api_v2's spp.api.client pattern in-module (no new dependency):

  • only a scrypt hash ($scrypt$salt$hash) is stored; authenticate() fetches by client_id and
    verifies in constant time
  • one-time display moves to a transient wizard; the record's plaintext is scrubbed before the
    dialog renders; action_regenerate_secret rotates without ever storing plaintext
  • Viewer group loses read access to the credential model
  • migrations/19.0.2.0.0/ hashes existing plaintext secrets in place (idempotent). Clients keep
    authenticating with unchanged secrets; only DB recoverability of the plaintext disappears.

Fixes found while building the test suite (each verified red first)

  1. The API was entirely broken on Odoo 19: date_utils.json_default no longer exists, so
    every response with a body crashed with AttributeError. Invisible until now — the module
    shipped with zero tests. → odoo.tools.json_default.
  2. Gender fabrication: four separate code paths stamped "Male" when gender was unknown
    (field default, related-field default, import fallback, subscriber-create fallback). All
    removed — gender is only what source data provides.
  3. Wizard error paths: missing-config message crashed with KeyError (looked up
    res.config.settings field names in ir.config_parameter._fields); token-scheme guard
    double-prefixed Basic tokens ('Basic Basic xyz', operator precedence).
  4. Hung-remote hardening: requests.post calls now carry timeout=30 (bandit B113).

Tests (module had none)

47 tests, hybrid strategy: controller logic + all write endpoints in TransactionCase against a
mocked request (writable env, every validation branch); a thin HttpCase layer smokes true
end-to-end paths (token mint round-trip, 401 rejection, read endpoints) since HTTP-served requests
run read-only against the test transaction. Plus credential hashing/show-once/rotation/migration,
subscriber model, and import wizard (mocked HTTP) coverage.

Verification

  • Config 1 (bare instance — the self-containment proof): -i spp_attendance only →
    47 tests, 0 failed, 0 errors.
  • Config 2 (co-install with spp_registry): field co-definitions on res.partner coexist
    cleanly (registry's uppercase name normalization applies; module needed no change).
  • Lint: pylint-odoo (optional + mandatory) and bandit pass on all module files.

Deferred (follow-up issue)

API v2 alignment (optional bridge module pattern — revisit after #114 lands), RFC 9457 error shape,
delete-endpoint authorization, token-endpoint rate limiting: see #434.

Notes for reviewers

  • readme/USAGE.md documents pointing the participant sync at an OpenSPP2 registry
    (spp_dci_server search + spp_api_v2 token endpoints); shipped defaults stay
    legacy-compatible.
  • README.rst/index.html are the imported renderings; if the readme-generator job complains, the
    CI-printed diff will be applied verbatim.

Verbatim copy of spp_attendance from openspp-modules @ 5a1afb71b.
Adaptation and fixes follow in separate commits.
Website URL, version 19.0.2.0.0 (above the released 19.0.1.3.1 so the
secret-hash migration runs on upgrades), HISTORY/USAGE fragments,
spp_session_tracking positioning in DESCRIPTION, drop README.rst.bak.
USAGE documents registry-sync configuration against an OpenSPP2 registry
(spp_dci_server search + spp_api_v2 token endpoints).
Three code paths stamped Male when gender was unknown: the res.partner
field default, the subscriber related-field default, and the registry
import fallback ('gender or "Male"'). All removed — gender is now only
what synced/imported data actually provides.
Secrets were stored in plaintext forever (the existing show-once button
was only a UI gate, bypassable via RPC read), searchable in plaintext by
the token endpoint, and readable by the Viewer group.

- client_secret_hash (scrypt, $scrypt$salt$hash — same construction as
  spp_api_v2's spp.api.client) is now the only stored form; authenticate()
  fetches by client_id and verifies in constant time
- the one-time display moves to a transient wizard; the record's plaintext
  is scrubbed at wizard-open, before the dialog renders
- action_regenerate_secret rotates without ever storing plaintext
- Viewer group loses read access to the credential model
- migrations/19.0.2.0.0: hashes existing plaintext secrets in place and
  scrubs the column (idempotent); clients keep authenticating with their
  unchanged secrets — only DB recoverability of the plaintext disappears
Hybrid strategy (option 3): controller logic + all write endpoints run
in TransactionCase against a mocked request (writable env, every
validation branch); a thin HttpCase layer smokes the true end-to-end
paths (token mint, 401 rejection, read endpoints) since HTTP-served
requests are read-only against the test transaction. Plus credential
hashing/show-once/migration tests, subscriber model tests, and import
wizard tests with mocked HTTP.

Two tests are intentional TDD reds reproducing wizard bugs found in
review (KeyError composing the missing-config message; Basic tokens
double-prefixed by an operator-precedence slip) — fixes follow.
date_utils.json_default no longer exists in Odoo 19 — every controller
response with a body crashed with AttributeError, which the new test
suite exposed on its first run (the module shipped without tests, so
this was invisible until now). Use odoo.tools.json_default.

Also: element_mapper short-circuits empty containers to None (test
expectation corrected) and re-add the ValidationError import the lint
hook stripped.
- check_required_fields crashed with KeyError composing the friendly
  missing-config message (looked the names up in ir.config_parameter's
  fields; they are res.config.settings fields)
- operator precedence in the token-scheme guard double-prefixed tokens
  that already carried Basic ('Basic Basic xyz')

Both were verified red before the fix (test_missing_config_raises_
friendly_error, test_basic_auth_token_not_double_prefixed).
… test fix

- requests.post calls get timeout=30 (bandit B113 / pylint E8106: a hung
  remote registry froze the worker)
- inverse method renamed _inverse_unique_fields (C8110)
- drop _() from field help strings (W8103) and redundant string= attrs
  (W8113); prettier XML formatting
- requirements.txt: pytz (generated from the manifest by the hook)
- test: partner-name assertion made case-insensitive — spp_registry
  normalizes partner names to uppercase when co-installed

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Semgrep OSS found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.16945% with 74 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.76%. Comparing base (0820667) to head (2e0aacf).

Files with missing lines Patch % Lines
spp_attendance/controllers/controllers.py 89.44% 40 Missing ⚠️
spp_attendance/models/registry_config.py 66.66% 13 Missing ⚠️
spp_attendance/wizard/import_attendance.py 94.44% 9 Missing ⚠️
spp_attendance/models/attendance_subscriber.py 89.04% 8 Missing ⚠️
...ndance/models/attendance_api_client_credentials.py 97.95% 2 Missing ⚠️
spp_attendance/models/attendance_list.py 95.34% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #435      +/-   ##
==========================================
+ Coverage   72.24%   72.76%   +0.52%     
==========================================
  Files         419      433      +14     
  Lines       29813    30656     +843     
==========================================
+ Hits        21539    22308     +769     
- Misses       8274     8348      +74     
Flag Coverage Δ
spp_attendance 91.16% <91.16%> (?)
spp_base_common 91.07% <ø> (ø)
spp_programs 65.27% <ø> (ø)
spp_registry 87.22% <ø> (+0.07%) ⬆️
spp_security 69.56% <ø> (ø)

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

Files with missing lines Coverage Δ
spp_attendance/__init__.py 100.00% <100.00%> (ø)
spp_attendance/controllers/__init__.py 100.00% <100.00%> (ø)
spp_attendance/models/__init__.py 100.00% <100.00%> (ø)
spp_attendance/models/attendance_location.py 100.00% <100.00%> (ø)
spp_attendance/models/attendance_type.py 100.00% <100.00%> (ø)
spp_attendance/models/res_partner.py 100.00% <100.00%> (ø)
spp_attendance/wizard/__init__.py 100.00% <100.00%> (ø)
spp_attendance/wizard/show_credential_wizard.py 100.00% <100.00%> (ø)
...ndance/models/attendance_api_client_credentials.py 97.95% <97.95%> (ø)
spp_attendance/models/attendance_list.py 95.34% <95.34%> (ø)
... and 4 more

... 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.

…ADME rendering

Every sudo() flagged by odoo-sudo-without-context is by design: the
auth=none API routes have no user context — the JWT bearer check is the
authentication and sudo is the access path — and the config-parameter
reads follow the standard Odoo pattern. Each site carries an own-line
nosemgrep with the reason. README.rst/index.html are CI's own rendering
applied verbatim.
The HISTORY fragment predated several fixes: the Odoo 19 json_default
API break, the wizard error-path bugs, and the request timeouts. Per
the version-bump/changelog rule, all in-PR fixes are now listed.
The previous round applied CI's README diff verbatim — but GitHub
Actions had masked 'Bearer <token>' in the log as a credential pattern,
so the literal '***' landed in the generated files, and every following
diff of that line was masked too (an unfixable-from-logs loop). Reword
the USAGE line so the rendered text contains nothing the masker
triggers on, and repair the two generated lines.

@emjay0921 emjay0921 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One change requested. Everything else I found is non-blocking and in a separate comment — and the security work here is solid, details there.

The /auth/token suppression's justification is not true

All 14 nosemgrep comments in controllers/controllers.py carry the same justification — auth=none route: JWT bearer verified upstream. I walked every route: 13 are accurate, because every endpoint except one calls validate_request_header_and_body()verify_auth_header() and returns 401 before touching data. The exception is line 152:

# nosemgrep: odoo-sudo-without-context — auth=none route: JWT bearer verified upstream
client = req.env["spp.attendance.api.client.credential"].sudo().authenticate(client_id, client_secret)

This is inside auth_get_access_token — the endpoint that mints the bearer. There is no JWT upstream of it; this line is the authentication step.

The sudo() itself is correct and necessary: an unauthenticated caller has to be able to have its credentials checked. Only the stated reason is wrong — and it is wrong on the most security-sensitive line in the module, where a future reader is most likely to take the comment's word for it instead of re-deriving the argument. Blanket-pasting one justification across every suppression site is what produced it.

Suggested replacement for that one line:

# nosemgrep: odoo-sudo-without-context — unauthenticated token request by design;
# secret is verified against the stored scrypt hash, not read back

Worth a scan of the other 13 too, since they came from the same paste — I believe they hold, but the author is better placed to confirm each route's ordering than I am from the outside.

@emjay0921

Copy link
Copy Markdown
Contributor

Non-blocking findings from the same pass. The scrypt migration is real security work and it holds up — verification notes at the bottom.

Security follow-ups

Plaintext lifetime is unbounded, and the changelog overclaims it. create() stores the generated plaintext in client_secret and it is only scrubbed when someone clicks Show (models/attendance_api_client_credentials.py:107-119). A credential that is created and never viewed keeps its plaintext in the column indefinitely. readme/HISTORY.md states secrets "can no longer be read back afterwards", which is true only after the one-time display has happened. Still a clear improvement on plaintext-forever-readable-by-viewers, so not a gate — but the wording should be narrowed, and the residue is worth removing properly: generate and hash without ever persisting the plaintext, handing it to the wizard through the returned action instead of a stored column.

The one-time wizard exposes the plaintext to every manager, not just its creator. spp.attendance.show.credential.wizard holds display_client_secret, and security/ir.model.access.csv grants group_attendance_manager full CRUD on it. Odoo 19 puts no per-user restriction on transient records — I checked odoo/orm/models_transient.py, which only implements vacuuming — so any attendance manager can search() that model and read a secret another manager just issued, until the vacuum runs (transient_age_limit, ~1 hour by default). An ir.rule of [('create_uid', '=', user.id)] on the wizard model closes it.

validate_entity enumerates the whole table on an unrecognised id. controllers/controllers.py:107-115 — a bad attendance type or location triggers an unbounded search([]) and returns every record's id and name in the 400 body. Authenticated-only and inherited from the import, but this PR places a security suppression directly on those two lines, which makes it the natural moment to cap the list or drop the enumeration.

Architectural question

models/res_partner.py declares gender_char = fields.Char() on res.partner, while spp_registry models gender as gender_id = fields.Many2one(...) (spp_registry/models/individual.py:32) and includes gender_id in the demographic_fields set used for eligibility. Nothing bridges the two, so a subscriber created through the attendance API ends up with gender_char set and gender_id empty — invisible to registry-side eligibility.

Removing the fabricated "Male" default was the right call and makes this pre-existing split easier to see rather than causing it. Worth an issue either way: should API-sourced gender map onto gender_id, or is gender_char deliberately a separate raw-source field?

Smaller

  • models/attendance_subscriber.py:128-133 uses a non-sudo search and a sudo().search_count against the same domain, so the paginated list and its reported total can disagree for a user with partial access.
  • expression.AND still works on Odoo 19 (odoo/osv/expression.py:240 — only expression() and prettify_domain carry deprecation warnings), but Domain is the Odoo 19 idiom if this is being touched anyway.
  • readme/HISTORY.md says "Initial migration from openspp-modules"; the convention elsewhere in the repo is "Initial migration to OpenSPP2".

Verified, for the record

The scrypt construction is sound, and the uniformity claim in the comment checks out: same constants and $scrypt$salt$hash format as spp_api_v2/models/api_client.py:15-284, a fresh 16-byte salt per secret, hmac.compare_digest for the comparison. secrets.token_urlsafe(32) is a genuine entropy upgrade over uuid4(), and dropping unique(client_secret) was both required by the hashing and a leak-surface removal in its own right.

The migration is reachable, which I initially doubted for a module new to this repo: the imported manifest was 19.0.1.3.1, so deployments upgrading across 19.0.2.0.0 do run it, and the in-place hashing is idempotent.

Three real pre-existing bugs, correctly fixed: the not a or bnot (a or b) precedence error that double-prefixed already-prefixed tokens; the KeyError from resolving settings labels against ir.config_parameter (and RegistryConfig._inherit = "res.config.settings" confirms the replacement lookup resolves); and date_utils.json_defaultjson_default, which I confirmed imports on Odoo 19. Gender fabrication is gone at all four sites, and the (vals.get(...) or "") form also removes a latent None.title() crash. Both requests.post calls carry timeout=30. Viewer read on the credential model is gone. No tree view_mode anywhere.

And the test suite is real work for a module that shipped none — malformed-hash rejection, one-time-display scrubbing, and secret rotation are all covered.

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