EH HR Platform Core
The audited, multi-company foundation every EH HR module is built on.
Why this module
EH HR Platform Core
Tamper-evident by construction
The audit log is append-only and hash-chained: each row stores the sha256 of the previous row plus its own fields, so any silent edit is caught by verify_chain(). No group is granted write, create, or unlink on the log, _log_access is off, and the list view forbids create, edit, and delete. The chain is independent of mail.thread.
Multi-company that does not leak
The company-aware mixin makes company_id required, defaults it to the active company, and refuses cross-company writes even under sudo unless an explicit, audited override context is set. A global record rule isolates the audit log per company so one officer cannot read another company's trail.
One substrate, many modules
Mixins, a service registry, DST-safe timezone math, versioned settings, and feature flags live here once, so the rest of the EH HR suite composes them instead of re-implementing them. Lean on purpose: no workflow, approval, policy, or feature logic ships in this module.
Day in the life
An HR officer questions a record change at month-end
A salary band looks wrong. The officer opens the Audit Log, filters to the model and actor, and reads the before-and-after JSON of the exact write, stamped with the user, the time, and a correlation id that ties it to the originating action. An administrator runs verify_chain() to confirm no row in between was altered: it walks the chain in keyset-paginated batches and returns the first broken row id, or nothing if the trail is intact. Because the company-aware mixin already refused any cross-company write that was not explicitly overridden and audited, there is no quiet leak to chase. The answer comes from the data, not from memory.
Edge cases
The cases most modules quietly ignore.
In the shipped code today, each one a place where a cheaper module silently does the wrong thing.
Two transactions appending to the audit log at once would otherwise read the same tail and fork the chain. write_event takes a transaction-scoped Postgres advisory lock so appends are strictly serialised; it releases automatically on commit or rollback.
Append and verify share one _chain_material() helper, and a missing actor is normalised to 0 on both paths. A prior version that hashed the actor as 0 on append but as the string False on verify broke the chain for every actorless row; that asymmetry is closed.
Audit payloads are built from raw field reads, so recordsets and date objects arrive un-serialisable. One _json_safe() coercion feeds both the stored jsonb column and the hash material, so the two representations stay identical and the chain stays valid.
The audit company_id column is deliberately excluded from the hash material, so introducing it on an existing database never alters a stored row_hash and the chain survives the upgrade.
A cross-company write is only permitted with an explicit allow_cross_company context, and when used it writes a cross_company_write audit row recording the new company, the affected count, and every affected record id, so the elevation is fully reconstructable.
The limiter is a single atomic INSERT ... ON CONFLICT DO UPDATE ... RETURNING upsert per (scope, key, window), so two gunicorn or gevent workers racing the same bucket can never both read a stale count. It is worker-shared and restart-durable, unlike an in-memory dict.
Closed rate-limit windows are vacuumed by a daily code cron calling _gc_windows(); the high-churn counter table runs with _log_access off so it never bloats with create or write metadata.
settings.entry.set() never destroys a value: it creates the new active entry, then archives the previous one with a superseded_by pointer, keeping one active row per company and key while preserving history.
The timezone service computes an employee's local civil date and the UTC window that bounds it, handling 23 and 25 hour DST days, with a documented fallback from employee to company to UTC when no zone is set.
Odoo 17 does not auto-grant the top group of a category, so a post-init hook explicitly grants the base administrator the HR Platform Admin group; without it a fresh database would leave even the admin unable to open any platform record.
What is inside
Built to do the job, end to end.
- Append-only hash-chained audit log. eh.hr.audit.log records model, record id, action, actor, company, correlation id, and a JSON payload, chained by sha256 over the previous row. write_event appends under an advisory lock; verify_chain walks the chain in bounded batches and reports the first tampered row. No write, create, or unlink ACL is granted to any group.
- Audited record mixin. eh.hr.audited.mixin emits create, write, and unlink audit rows automatically, capturing before-and-after snapshots of a configurable field whitelist. Writes that change nothing in the audited set emit no row, so the trail stays signal.
- Strict company-aware mixin. eh.hr.company.aware.mixin makes company_id required, defaults it to the active company, and rejects cross-company writes, even under sudo, unless an explicit audited override is set. Every override is logged.
- Atomic rate limiter. eh.hr.rate.limit.hit() is a single atomic SQL upsert per fixed window, correct across concurrent workers and durable across restarts. Built for the public kiosk, biometric, visitor, and mobile routes that authenticate a device rather than a person.
- Versioned settings and feature flags. eh.hr.settings.entry gives typed, per-company key/value settings where a write archives the old value instead of destroying it. eh.hr.feature.flag gates capability by company, group, date window, and deterministic percentage rollout on user id.
- Services, timezone math, and OWL kit. A tiny service registry with locate_service keeps business logic off the ORM. A DST-safe timezone service computes local civil dates and UTC day windows. The frontend ships HrCard and HrStat OWL components with a built-in loading skeleton, design tokens, and a debounced toast service.
Honest about the edges
What this does not do, so nothing surprises you.
- This is a platform and developer foundation module, not an end-user application. On its own it adds an Audit Log view, a settings page, and a few mixins; the HR feature surface lives in the other EH HR modules that depend on it.
- The audit retention period is exposed as a configurable setting, but this module ships no automated purge or CSV-export job for old audit rows. The hot table is not trimmed automatically.
- There is no standalone HrSkeleton component; the skeleton is a loading state rendered inside HrCard. The OWL kit in this module is HrCard, HrStat, and a toast service.
- The platform event bus is an in-process bus.bus send, intended to be swapped for a queue adapter in clustered deployments; it is not a distributed message broker.
- Workflow, approval chains, the policy DSL, and notification dispatch are deliberately not in this module and live in their respective EH HR engine modules.
Odoo 17 HR platform, HR audit log Odoo, hash-chained audit trail, append-only audit log, tamper-evident logging Odoo, multi-company HR Odoo, Odoo feature flags, rate limiting Odoo, OWL component kit, Odoo HR foundation module, versioned settings Odoo, per-company settings, Odoo service registry, DST-safe timezone Odoo, ERP Heritage HR
Need this fitted to the way you work?
ERP Heritage delivers end to end Odoo work: Odoo Implementation, Customization and Development, Integration, Migration, Consultation, Support and Training. We help teams put this module into production, shape it to their process, and keep it running.
We work with businesses across Australia (Melbourne, Sydney, Brisbane, Perth, Adelaide, Canberra) and the Middle East (Dubai, Abu Dhabi, Riyadh, Jeddah, Doha, Kuwait City, Muscat). Start a conversation at erpheritage.com.au or email info@erpheritage.com.au.
Languages
Available in 19 languages
The interface ships translated out of the box. Switch language in Odoo and the fields, menus, and messages follow.
Please log in to comment on this module