From cfaf4657cea973b7af7256d99547242e7b0aadd6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 11:42:00 -0400 Subject: [PATCH] docs(plating): tablet PIN session redesign spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...5-24-tablet-pin-session-redesign-design.md | 484 ++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 fusion_plating/docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md diff --git a/fusion_plating/docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md b/fusion_plating/docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md new file mode 100644 index 00000000..f8146dd8 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md @@ -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.*