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:
@@ -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.*
|
||||
Reference in New Issue
Block a user