docs(plating): tablet PIN session redesign spec

Real per-tech Odoo sessions on PIN unlock (not just attribution).
Closes the audit-trail gap from Phase 1 permissions overhaul: today
the tablet runs as a persistent 'shopfloor service' user and the PIN
is just an OWL overlay — every action is attributed to whoever the
session user is, not the tech who tapped their tile.

Locked decisions:
1. Real per-tech sessions (impersonation, cookie swap)
2. Idle timeout 10min + manual lock + 8hr hard ceiling
3. Dedicated kiosk user (fp_tablet_kiosk, near-zero ACL)
4. No manager override — Mgr/Owner PIN in as themselves
5. Two-step deploy with 1-week overlap; OLD endpoint removed after
   successful rollout

Audit: fp.tablet.session.event append-only log captures unlock /
manual_lock / idle_lock / ceiling_lock / force_lock / failed_unlock
/ admin_reset events with ip, ua, session hash, duration.

Effort: ~4 dev days + 1 week observation. Plan via writing-plans
skill next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 11:42:00 -04:00
parent 7966f8d505
commit cfaf4657ce

View File

@@ -0,0 +1,484 @@
# Tablet PIN Session Redesign — Design Document
**Date:** 2026-05-24
**Status:** Approved for implementation
**Owner module:** `fusion_plating_shopfloor` (with minor changes in `fusion_plating` for ACL data)
**Brainstorm:** session with @gsinghpal, 2026-05-24
**Linked plan:** TBD (writing-plans skill, next step)
**Related:** `docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md` (Phase 1 permissions overhaul which surfaced this gap)
---
## Problem Statement
The current tablet PIN system on entech is **theatre**. It looks like a security/audit gate but doesn't actually enforce attribution:
- The shop-floor tablet PC logs in ONCE as a persistent "shopfloor service" Odoo user. That session never changes.
- When a tech taps their tile and enters the PIN, the OWL frontend stores a `current_tech_id` in an in-memory service (`fp_shopfloor_tech_store`). The underlying Odoo session cookie does NOT change.
- ~15 of the ~30 shop-floor write endpoints accept a `tablet_tech_id` kwarg and use `env_for_tablet_tech` to attribute the write to the tech via `env(user=tech_id)`. The OTHER ~15 endpoints write under the session user (the shopfloor service user). The audit trail is incomplete.
- There is no idle timeout, no re-lock on walk-away. Once unlocked, the `tech_id` sits in OWL state until someone manually swaps it. A tech walks away, the next person clicks a job card → Odoo records the work under the prior tech.
- A tech (or anyone with browser access) can type any URL — `/odoo/settings`, `/web/...` — and act under the shopfloor service user's full backend privileges. The "lock" is only an OWL overlay over the kanban; it doesn't gate URL navigation.
**The whole point of adding a PIN was to enforce "log who did what."** Today it doesn't. AS9100/Nadcap audit trails are unreliable because attribution can be wrong.
---
## Locked Design Decisions
| # | Question | Decision |
|---|---|---|
| Q1 | Session model | **Real per-tech sessions (impersonation).** After PIN unlock the backend creates a REAL Odoo session AS the tech (cookie swap, server-side session row). They literally ARE that user for the duration of the unlock. |
| Q2 | Lock-back trigger | **Idle timeout + manual lock + hard ceiling.** Default 10 min idle, 8 hr hard ceiling regardless of activity. Manual "Lock" / "Hand-Off" button always available. |
| Q3 | Kiosk identity | **Dedicated kiosk user.** New user `fp_tablet_kiosk@enplating.local` in a new `group_fp_tablet_kiosk` group with near-zero ACL (read `res.users` for tile grid + read `ir.config_parameter` for idle settings). |
| Q4 | Manager override / impersonation | **No special path.** Manager wanting to chip in must PIN in as themselves. Simpler, strongest audit. |
| Q5 | OLD endpoint lifecycle | **Remove after successful rollout.** Two-step deploy with 1-week overlap, then Step 3 cleanup commits remove the legacy `tablet_tech_id` plumbing. |
---
## Section 1 — Architecture Overview
### Three identities, two state transitions
```
┌──────────────────────────────────────────────────────────────────────┐
│ STATE: KIOSK │
│ ──────────── │
│ Browser cookie = kiosk session_id │
│ Session uid = fp_tablet_kiosk (new user, near-zero ACL) │
│ Visible = lock screen + tile grid only │
│ Idle timer = OFF │
└──────────────────────────────────────────────────────────────────────┘
│ Tech taps tile + enters correct PIN
│ POST /fp/tablet/unlock_session
│ → server: verify hash, mint new session
┌──────────────────────────────────────────────────────────────────────┐
│ STATE: TECH │
│ ─────────── │
│ Browser cookie = tech session_id (fresh Set-Cookie from unlock) │
│ Session uid = tech.id (real Odoo session as the tech) │
│ Visible = full Plating UI per tech's normal ACLs │
│ Idle timer = ON (10 min default) │
│ Hard ceiling = 8 hr from session_started_at │
│ │
│ EVERY Odoo write naturally attributed via session.uid: │
│ create_uid = tech.id │
│ write_uid = tech.id │
│ chatter authorship = tech.partner_id │
│ NO MORE tablet_tech_id plumbing needed │
└──────────────────────────────────────────────────────────────────────┘
│ Any of: manual Lock button / 10 min idle /
│ 8 hr ceiling
│ POST /fp/tablet/lock_session
│ → server: destroy session, re-auth as kiosk
back to KIOSK
```
### Why this is fundamentally different from today
Today the tablet has ONE persistent session. The PIN is an OWL overlay; the underlying Odoo session never changes. New design: every PIN unlock creates a NEW Odoo session AS the tech. Lock-back DESTROYS that session and creates a fresh kiosk session.
Result: Odoo's standard `create_uid` / `write_uid` / chatter authorship already attributes everything correctly. We drop the `tablet_tech_id` kwarg + `env_for_tablet_tech` helper entirely — they become dead code.
**Security gain:** If the tech navigates to ANY Odoo URL (Settings, Users, anything), they're acting under their own permissions, not the kiosk's. The kiosk user has near-zero ACLs so even if someone hits `/fp/...` URLs before PIN-ing in, they see nothing.
---
## Section 2 — Components & Files
### New files
| Path | Responsibility |
|---|---|
| `fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml` | Idempotent create of `fp_tablet_kiosk@enplating.local` + assignment to `group_fp_tablet_kiosk`. Default password = random secret stored in `ir.config_parameter['fp.tablet.kiosk_password']` (sysadmin can reset). |
| `fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml` | New `res.groups` `group_fp_tablet_kiosk`. NOT under the Fusion Plating privilege block — orthogonal to the 8-role hierarchy. `privilege_id` stays empty so it doesn't pollute the role dropdown. |
| `fusion_plating_shopfloor/security/ir.model.access.csv` (rows added) | 2 ACL rows for kiosk: READ `res.users` (tile grid) + READ `ir.config_parameter` (idle settings). Nothing else. No fp.job, no sale.order, no anything. |
| `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | New model `fp.tablet.session.event` — append-only audit log. Owner-only read; only base.group_system can ever write/unlink (we don't grant that). |
| `fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml` | List + form views. Owner-only menu under Plating → Configuration → Tablet Audit Log. Smart button on `res.users` form. |
| `fusion_plating_shopfloor/models/res_users.py` (existing — add auth check) | Override `_check_credentials` to handle `type='fp_tablet_pin'` (custom auth manager). Verifies PIN hash + recordset state; raises `AccessDenied` on any failure. |
| `fusion_plating_shopfloor/data/fp_tablet_cron.xml` | New cron `_cron_force_lock_stale_sessions` (every 5 min). Finds any active fp.tablet.session.event past 8hr ceiling with no `session_ended_at`, force-marks it ended. |
| `fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js` | OWL service. Tracks idle time via DOM event listeners. Fires lock-back at 10min idle or 8hr ceiling. Replaces `fp_shopfloor_tech_store`. |
### Modified files
| Path | Change |
|---|---|
| `controllers/tablet_controller.py` | Replace `/fp/tablet/unlock` with two new endpoints: `/fp/tablet/unlock_session` (verifies PIN → mints new Odoo session via `request.session.authenticate(db, {type:'fp_tablet_pin', ...})`), `/fp/tablet/lock_session` (destroys tech session → re-auths as kiosk). OLD `/fp/tablet/unlock` kept alive during the 1-week overlap. |
| `controllers/_tablet_audit.py` | `env_for_tablet_tech` becomes a one-line no-op pass-through. Marked deprecated; deleted in Step 3 cleanup. |
| All ~15 endpoints with `tablet_tech_id` kwarg | Step 3 (post-overlap) — remove the kwarg. Endpoints run under `request.env.user` which IS the tech (because session swap). |
| OWL `FpTabletLock` component | Use new `tablet_session_manager` service. On successful unlock, `window.location.reload()` so the entire app re-bootstraps with the tech's session cookie. (Cleanest — no half-state.) |
| `services/fp_rpc.js` | Stop auto-injecting `tablet_tech_id`. Becomes a thin wrapper, removable once endpoints drop the kwarg in Step 3. |
### Auth path — custom auth manager
**Method:** Register a new auth type `fp_tablet_pin` via `res.users._check_credentials`.
1. PIN unlock endpoint calls `request.session.authenticate(request.db, {'type': 'fp_tablet_pin', 'login': tech.login, 'pin': pin})`.
2. Odoo's standard auth flow takes over: `_check_credentials` is invoked, sees `type='fp_tablet_pin'`, calls our handler.
3. Our handler hashes the PIN and compares against `tech.x_fc_tablet_pin_hash`. Validates `tech.active` and that the tech holds any shop-branch group.
4. On success, Odoo issues the session, sets cookie, returns response — **same code path Odoo uses for password login**. We get session lifecycle hooks, validation chain, and security for free.
**Alternative considered:** direct `request.session.uid = tech.id` + manual cookie. Faster to implement but bypasses Odoo's `_check_credentials` validation chain (2FA, IP gating, future security modules). Picked the slower-to-implement but correct path.
### Idle-timer mechanics (OWL service)
```javascript
// tablet_session_manager.js (sketch)
class TabletSessionManager {
setup(env) {
this.idleMs = 10 * 60 * 1000; // 10 min, configurable via ir.config_parameter
this.ceilingMs = 8 * 60 * 60 * 1000; // 8 hr hard
this.lastActivity = Date.now();
this.sessionStartedAt = ...; // from server on bootstrap
['click', 'touchstart', 'keydown', 'mousemove'].forEach(ev =>
document.addEventListener(ev, () => this.touch(), { passive: true })
);
setInterval(() => this.tick(), 5000);
}
touch() { this.lastActivity = Date.now(); }
tick() {
const now = Date.now();
if (now - this.lastActivity > this.idleMs ||
now - this.sessionStartedAt > this.ceilingMs) {
this.lockBack(now - this.lastActivity > this.idleMs ? 'idle' : 'ceiling');
}
}
async lockBack(reason) {
await rpc("/fp/tablet/lock_session", { reason });
window.location.reload(); // fresh page → fresh kiosk session
}
}
```
Server-side belt-and-suspenders: cron `_cron_force_lock_stale_sessions` runs every 5 min and force-destroys any tablet session past the 8-hr ceiling — handles browser crashes, tablet reboots with stale cookie, etc.
---
## Section 3 — Session Lifecycle in Detail
### Unlock flow
```
Tablet OWL Server
────── ─── ──────
Tap tile ──────────────▶ PIN pad opens
Enter 4 digits ────────────▶ collect PIN
POST /fp/tablet/unlock_session
{ tech_id, pin }
cookie: kiosk_session_id
────────────────────▶
1. verify kiosk session active
2. browse(tech_id), exists+active
3. lockout check (failed_count,
locked_until)
4. verify_tablet_pin via hash
├─ FAIL → fp.tablet.session.event
│ (failed_unlock,
│ attempted_user_id=tech_id,
│ failure_reason='wrong_pin',
│ ip, ua)
│ + increment failed_count
│ + maybe set locked_until
│ + return {ok:false, error}
└─ PASS:
5. session.authenticate(db, {
type:'fp_tablet_pin',
login:tech.login,
pin:pin })
→ Odoo issues new sid,
uid=tech.id
→ response Set-Cookie
6. fp.tablet.session.event
(unlock, user_id=tech_id,
session_id_hash=sha256(sid),
session_started_at=now,
ip, ua)
7. reset failed_count=0
8. return {ok:true, tech_name}
◀────────────────────
window.location.reload()
```
### Lock-back flow
```
Trigger (any of):
- User taps Lock button
- 10 min no activity (idleMs)
- 8 hr since session_started_at (ceilingMs)
POST /fp/tablet/lock_session { reason: 'manual' | 'idle' | 'ceiling' }
cookie: tech_session_id
──────▶ server:
1. read session.uid (current tech)
2. fp.tablet.session.event
(event_type matches reason,
user_id=tech_id, session_id_hash,
session_ended_at=now,
duration_seconds=now - session_started_at)
3. request.session.logout()
4. response Set-Cookie: clear tech session
5. session.authenticate(db, {type:'password',
login:'fp_tablet_kiosk',
password:KIOSK_SECRET_from_ir_config})
→ response carries new kiosk Set-Cookie
6. return {ok:true, locked_at:now}
◀────── browser receives Set-Cookie (kiosk session)
window.location.reload()
App re-bootstraps as kiosk → lock screen renders
```
### Edge cases (must work)
| Scenario | Behavior |
|---|---|
| Tech walks away, browser crashes | Cron `_cron_force_lock_stale_sessions` (every 5 min) finds the stale session, writes `event_type='force_lock'`. Next tablet boot starts as kiosk regardless. |
| Two techs race-tap two tiles simultaneously | First unlock wins. Second tech's POST sent with the FIRST tech's NEW cookie (Set-Cookie applied) → backend sees them as tech 1, returns access-denied to mint another session. UI debounces with spinner. |
| Wrong PIN 5 times | `failed_count` → 5, `locked_until` set to now+5min. Subsequent PIN attempts return `{ok:false, locked_until}` → UI shows countdown. Audit event each attempt. |
| Network drops mid-unlock | OWL gets timeout → retry button. Session NOT created server-side if request never completed. Single DB transaction guarantees atomicity. |
| Browser pre-fills cached cookie from old session | Server validates session row; if invalid, returns 401, OWL forces page reload → re-authenticates as kiosk. |
| Manager force-resets a tech's PIN while tech is unlocked | Tech's current session keeps working (sessions independent of PIN hash). Next PIN entry requires the new PIN. Manager action logged via `admin_reset` event. |
| Tech navigates to `/odoo/settings` or other URLs | They're a real Odoo user with their own ACLs. Standard ACLs apply. Technician sees what a Technician would see (mostly nothing — Manager+ only menus). |
| Tablet PC reboots mid-shift | Boots to login page (kiosk session cookie may have expired). Stored kiosk credential auto-fills. Reaches lock screen, ready for PIN. |
### Concurrency / race protection
- `unlock_session` takes a DB row lock on `res.users(id=tech_id)` for the duration of `verify_tablet_pin` + `failed_count` write. Prevents double-counting failed attempts.
- `fp.tablet.session.event` writes are sudo'd and append-only. Race conditions produce two adjacent audit rows (sortable by `create_date`) — never lose data.
---
## Section 4 — Audit Log Model + UI
### Model: `fp.tablet.session.event`
```python
class FpTabletSessionEvent(models.Model):
_name = 'fp.tablet.session.event'
_description = 'Tablet Session Event (audit log)'
_order = 'create_date desc'
_rec_name = 'event_type'
event_type = fields.Selection([
('unlock', 'Unlock (PIN success)'),
('failed_unlock', 'Failed PIN attempt'),
('manual_lock', 'Manual lock (Hand-Off button)'),
('idle_lock', 'Idle timeout lock'),
('ceiling_lock', '8-hour ceiling lock'),
('force_lock', 'Force lock (cron, stale session)'),
('admin_reset', 'Admin force-reset PIN'),
], required=True, readonly=True, index=True)
user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict',
help='The tech whose session was unlocked/locked. NULL for failed '
'attempts where the tile was tapped but unlock never succeeded.')
attempted_user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict',
help='For failed_unlock: which tile was tapped. user_id stays empty.')
session_id_hash = fields.Char(readonly=True,
help='sha256 hash of the Odoo session sid. Lets us correlate '
'events for the same session without storing the raw token.')
session_started_at = fields.Datetime(readonly=True)
session_ended_at = fields.Datetime(readonly=True)
duration_seconds = fields.Integer(readonly=True)
ip_address = fields.Char(readonly=True)
user_agent = fields.Char(readonly=True, help='Trimmed to 256 chars.')
failure_reason = fields.Selection([
('wrong_pin', 'Wrong PIN'),
('locked_out', 'Locked out (too many failures)'),
('no_pin_set', 'No PIN configured'),
('user_inactive', 'User archived or disabled'),
('no_role', 'User has no shop-branch role'),
], readonly=True)
acting_uid = fields.Many2one('res.users', readonly=True,
help='The user the SERVER saw at request time. Usually fp_tablet_kiosk '
'for unlocks; the manager for admin_reset; base.user_root for cron.')
notes = fields.Text(readonly=True)
```
**Design rules:**
- Append-only. No `_inherit = 'mail.thread'`. No `write()` or `unlink()` ACL granted to any group except `base.group_system` (which we DON'T grant — root SQL access required to tamper).
- Hashed session sid, not raw — if DB leaks, attackers can't replay sessions.
- `attempted_user_id` cleanly distinguishes "Carlos's tile was tapped" from "Carlos was authenticated."
### Lifecycle: who writes what
| Trigger | Endpoint | event_type | user_id | attempted_user_id | acting_uid |
|---|---|---|---|---|---|
| Successful PIN unlock | `/fp/tablet/unlock_session` | unlock | tech | — | kiosk |
| Wrong PIN | `/fp/tablet/unlock_session` (fail) | failed_unlock | — | tech | kiosk |
| 5th wrong PIN → lockout | same | failed_unlock (`locked_out`) | — | tech | kiosk |
| Lock button | `/fp/tablet/lock_session` | manual_lock | tech | — | tech |
| 10 min idle | `/fp/tablet/lock_session` | idle_lock | tech | — | tech |
| 8 hr ceiling | `/fp/tablet/lock_session` or cron | ceiling_lock | tech | — | tech or cron |
| Cron force-lock | `_cron_force_lock_stale_sessions` | force_lock | tech | — | base.user_root |
| Manager resets PIN | `/fp/tablet/reset_pin_for` | admin_reset | tech | — | manager |
### UI: 3 surfaces
1. **Owner-only menu** — Plating → Configuration → Tablet Audit Log. List view with badges, filters (today/week/month, event_type, user), group-by, default 90-day window.
2. **Smart button on `res.users` form (Owner-only)** — "Tablet Events" with last-7-days count, opens audit list filtered to that user (`user_id` OR `attempted_user_id`).
3. **Chatter linkback (deferred to follow-up)** — tooltip on chatter messages linking to the unlock event. Phase 2, not blocking.
### Retention
- Default: indefinite. `_cron_purge_old_session_events` exists but is DISABLED. Configurable via `ir.config_parameter['fp.tablet.audit.retention_days']` (unset = forever).
- AS9100 retention typically 3-7 years. Safe default for small shops.
### What this audit log does NOT replace
- Standard `create_uid` / `write_uid` on every model — that's the primary audit.
- Chatter authorship — still primary on individual records.
- This log catches what `create_uid` can't: failed attempts, session lengths, idle vs manual locks, gaps between sessions.
---
## Section 5 — Migration & Rollout
### Two-step deploy with overlap window
To avoid downtime, OLD and NEW endpoints coexist for ~1 week. Tablets switch over individually as they reboot.
**Step 1 — Day 0 deploy (NEW code, OLD still alive)**
`-u` brings up:
- kiosk user + group + ACL
- new `/fp/tablet/unlock_session` and `/fp/tablet/lock_session`
- `fp.tablet.session.event` model + audit views
- new OWL `tablet_session_manager` service
- custom `fp_tablet_pin` auth manager
OLD endpoints stay:
- `/fp/tablet/unlock` returns `current_tech_id` (no session swap)
- `env_for_tablet_tech` still routes endpoints
- OWL `fp_shopfloor_tech_store` still bypassed when new manager is active
Frontend feature flag `ir.config_parameter['fp.shopfloor.tablet_session_mode']`:
- `'legacy'` (Day 0 default) → OWL uses old flow
- `'session_swap'` → OWL uses new flow
**Step 2 — Days 1-7 cutover, one tablet at a time**
- Flip flag to `'session_swap'` on entech
- Per tablet:
1. Reboot or hard-refresh browser
2. Sysadmin enters kiosk credential ONCE (stored in 1Password)
3. Bookmark `/odoo/action-fusion_plating_shopfloor.action_fp_plant_kanban`
4. Lock screen renders under kiosk user
5. Test: tech taps tile, enters PIN, full flow works as them
6. Track in spreadsheet (2-3 tablets total)
**Step 3 — Day 14 cleanup commits**
- Sweep `tablet_tech_id` kwargs out of all ~15 endpoints
- Delete `_tablet_audit.env_for_tablet_tech` (becomes import error → forces final cleanup)
- Remove OLD `/fp/tablet/unlock` endpoint
- Remove OLD `fp_shopfloor_tech_store` OWL service
- Strip auto-injection from `services/fp_rpc.js`
- Archive the legacy "shopfloor service" user
### Auto-login pattern for kiosk
Three options, cheapest first:
1. **Browser-stored cookie + long session_lifetime (recommended for entech)** — set `session_db.session_lifetime` to 90 days for kiosk. Sysadmin logs in once, cookie lasts 3 months. Cheap, manual.
2. **Kiosk browser extension** — KioWare or Chromium kiosk-mode auto-fills credential. Auto-recovers from reboots.
3. **Odoo SSO with stored token** — overkill for 2-3 tablets.
### Rollback plan
- Set `tablet_session_mode = 'legacy'` → all OWL switches back. No redeploy.
- If kiosk user is broken, legacy "shopfloor service" still has its permissions and the OLD endpoints — system keeps working.
### What WON'T be touched
- `res.users.x_fc_tablet_pin_hash` — same field, same hash format, same `verify_tablet_pin()`. PINs don't need to be reset.
- Lockout state (`failed_count`, `locked_until`) — preserved.
- All other shop-floor functionality (kanban, workspace, recipes) — unaffected.
### Timing on entech
| Day | Action |
|---|---|
| 0 (deploy day) | `-u fusion_plating_shopfloor` + 4 related modules. Verify new endpoints. `tablet_session_mode='legacy'`. |
| 0 (evening) | Flip flag on one test tablet. Manual test 5 unlock/lock cycles. |
| 1-2 | Roll to second + third tablets. Owner watches audit log. |
| 3-7 | Operators use new flow. Owner reviews audit log daily. |
| 7 | Decision: keep `'session_swap'` permanently OR roll back. |
| 14 | If kept: Step 3 cleanup commits. |
---
## Section 6 — Acceptance, Risks, Deferred
### Acceptance criteria
1. **Identity** — pick any `create_uid` on `fp.job.step` change in last 30 days. Cross-reference `fp.tablet.session.event` for active session at that timestamp under that user. Match rate target: 100% of tablet-originated writes.
2. **Failed attempts** — query "every wrong-PIN attempt for user X in last week" returns rows with `attempted_user_id=X`, `failure_reason='wrong_pin'`, IP, UA, timestamp.
3. **Session length** — longest session ≤ 8hr (within 5-min cron grace).
4. **Gap detection** — adjacent lock → next unlock delta computable; "was anyone on the floor at 2pm?" answerable.
5. **No silent attribution** — post-Step 3, action endpoint without `tablet_tech_id` runs under `request.env.user` which IS the tech. `create_uid = tech.id`.
6. **Kiosk privilege check** — log in as `fp_tablet_kiosk` in private browser. Try to navigate any plating URL. Result: access denied / blank menu.
7. **Browser navigation under tech** — as the unlocked tech, hit `/odoo` main menu. They see ONLY menus their role allows.
8. **Idle lockout fires** — PIN-unlock, wait 11 min touching nothing. Auto-lock. `event_type='idle_lock'` row appears.
9. **Hard ceiling fires** — backdate `session_started_at` past 8 hrs. Run cron. `session_ended_at` populates, `event_type='force_lock'`.
10. **Audit log append-only** — try `event.write({...})` as Owner. AccessError. Only root SQL access can tamper.
### Open risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Custom auth manager conflicts with Odoo's session_lifetime / 2FA / IP modules | Medium | Distinctive type name (`fp_tablet_pin`, not `tablet_pin`). Tests for pwd + 2FA paths unchanged. Document in CLAUDE.md. |
| Browser cookie misbehavior on rapid lock/unlock | Low | `window.location.reload()` after every transition kills half-state. |
| Tablet PC reboot mid-shift with stale cookie | Medium | Long-lived kiosk cookie (90-day session_lifetime). Sysadmin re-login if expired (~30s downtime). |
| Backend cron clock drift | Low | All timestamps `fields.Datetime.now()` (UTC, server). Server cron is source of truth for ceiling. |
| Network drop mid-unlock | Low | One DB transaction — atomic. Either session+audit row both commit, or neither. |
| Operator hits backend URLs | Expected behavior | They're real Odoo users with their own ACLs. Standard ACLs apply. Working as designed. |
| Brute-force PIN attempts as DoS | Low (insider only) | 5-attempt → 5-min lockout. Cron clears `failed_count` after 1hr of no failures. 10000 possible PINs + lockout → ~3.5 years to brute-force on average. |
### Deferred to follow-up
- **Chatter linkback** (Section 4, surface 3) — useful but not blocking. Phase 2.
- **2FA on lock screen** (badge tap, biometric) — out of scope.
- **Per-tablet identity** — currently every tablet uses same kiosk credential. If you ever want to track which physical tablet did what, add `tablet_device_id`. Deferred — small shop doesn't need it.
- **SAML/SSO integration** — out of scope.
- **Manager override mode** — explicitly killed in Q4. Manager wanting to chip in must PIN in as themselves.
- **Time-clock integration** — separate concern. The `session_started_at` could feed time-tracking but that integration is its own design.
- **Mobile (non-tablet) access** — Technician on phone uses standard Odoo login. PIN flow is tablet-only.
### Estimated effort
- Phase 1: server-side (kiosk user, auth manager, endpoints, audit model, cron) — **~1.5 days**
- Phase 2: OWL (session manager, lock-back UI, reload-on-transition) — **~0.5 days**
- Phase 3: audit views + Owner menu + smart button — **~0.5 days**
- Phase 4: entech rollout (deploy, feature-flag test, per-tablet cutover, validation week) — **~1 day spread over 7 days**
- Phase 5: Step 3 cleanup (rip out tablet_tech_id) — **~0.5 days**
**Total: ~4 development days + 1 calendar week observation.**
---
## Status
- ✅ Brainstorm complete (5 locked decisions)
- ✅ Design doc written
- ⏳ Self-review (next)
- ⏳ User reviews this spec
- ⏳ Invoke `writing-plans` to create the implementation plan
- ⏳ Execute the plan per `subagent-driven-development`
- ⏳ Deploy + validate on entech
---
*End of design document.*