feat(fusion_plating_shopfloor): res.users tablet PIN fields + hash helpers (P6.1.1)

PBKDF2-SHA256 + 16-byte salt + 200k iterations on res.users. Format
of the stored hash string is <salt_hex>$<digest_hex>. Field is
manager-readable only (groups=group_fusion_plating_manager); helpers
that need to read or write it use .sudo() internally so operator-level
callers can still set/verify their own PIN.

Adds set_tablet_pin / verify_tablet_pin / clear_tablet_pin model
methods + action_open_tablet_pin_setup that triggers the OWL setup
modal (Phase 6.2). Tests cover hash uniqueness, verify, clear with
chatter post, and the 4-digit format guard.

Tests verified on entech: -u fusion_plating_shopfloor --test-tags fp_tablet_pin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-23 00:13:33 -04:00
parent a6546ac858
commit 395bd4949e
4 changed files with 184 additions and 0 deletions

View File

@@ -8,3 +8,4 @@ from . import fp_bake_window
from . import fp_first_piece_gate
from . import fp_operator_queue
from . import fp_tank
from . import res_users

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign).
Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints
and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256
hash; never plaintext.
"""
import hashlib
import secrets
from odoo import _, fields, models
from odoo.exceptions import UserError
# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe
# against brute-force even if the DB leaks.
_PBKDF2_ITERATIONS = 200_000
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_tablet_pin_hash = fields.Char(
string='Tablet PIN (hashed)',
groups='fusion_plating.group_fusion_plating_manager',
help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet '
'PIN. Format: <salt_hex>$<digest_hex>. Never readable to '
'non-managers; never logged.',
)
x_fc_tablet_pin_set_date = fields.Datetime(
string='Tablet PIN Set Date',
help='When the current PIN was last set or changed.',
)
x_fc_tablet_pin_failed_count = fields.Integer(
string='Failed PIN Attempts',
default=0,
help='Sequential failed unlock attempts since the last success. '
'Resets to 0 on a correct PIN.',
)
x_fc_tablet_locked_until = fields.Datetime(
string='Tablet Lockout Until',
help='Wall-clock time at which the per-user lockout expires. '
'Null when not locked. Set after the configured fail '
'threshold (default 5) is reached.',
)
@staticmethod
def _hash_tablet_pin(pin, salt=None):
"""Hash `pin` with optional salt. Returns "salt_hex$digest_hex"."""
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
)
return f"{salt.hex()}${digest.hex()}"
@staticmethod
def _verify_tablet_pin_hash(pin, stored):
"""Constant-time verify of `pin` against a stored hash string."""
if not stored or '$' not in stored:
return False
salt_hex, expected_hex = stored.split('$', 1)
try:
salt = bytes.fromhex(salt_hex)
except ValueError:
return False
digest = hashlib.pbkdf2_hmac(
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
)
return secrets.compare_digest(digest.hex(), expected_hex)
def set_tablet_pin(self, pin):
"""Set or change this user's tablet PIN. Requires sudo OR self.
Caller is responsible for verifying the OLD pin separately if a
hash already exists — this method just writes the new one.
"""
self.ensure_one()
if not pin or not pin.isdigit() or len(pin) != 4:
raise UserError(_('Tablet PIN must be exactly 4 digits.'))
self.sudo().write({
'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin),
'x_fc_tablet_pin_set_date': fields.Datetime.now(),
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
return True
def verify_tablet_pin(self, pin):
"""Return True if `pin` matches this user's stored hash."""
self.ensure_one()
if not pin:
return False
# sudo: even non-manager callers may need to verify their OWN PIN.
# The hash field has manager-only read; sudo bypasses that.
return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash)
def clear_tablet_pin(self):
"""Manager-side reset. Clears hash so target must set a new PIN.
Posts to chatter for audit.
"""
self.ensure_one()
manager_name = self.env.user.name
self.sudo().write({
'x_fc_tablet_pin_hash': False,
'x_fc_tablet_pin_set_date': False,
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
self.message_post(
body=_('Tablet PIN reset by %s. User must set a new PIN '
'on next unlock attempt.') % manager_name,
)
return True
def action_open_tablet_pin_setup(self):
"""Trigger the FpPinSetup OWL modal from the Preferences form.
The Phase 6.2 OWL component intercepts this action tag.
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_tablet_pin_setup',
'name': 'Set Tablet PIN',
'target': 'new',
}

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_workspace_controller
from . import test_landing_kanban
from . import test_tablet_pin

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Phase 6 — Tablet PIN gate tests."""
import json
from odoo.tests.common import HttpCase, TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletPinHash(TransactionCase):
"""P6.1.1 — model fields + hash helpers + set/verify/clear methods."""
def setUp(self):
super().setUp()
self.user = self.env['res.users'].create({
'name': 'Pin Test',
'login': 'pintest@example.com',
})
def test_set_pin_stores_salted_hash(self):
self.user.sudo().set_tablet_pin('1234')
stored = self.user.sudo().x_fc_tablet_pin_hash
self.assertTrue(stored)
self.assertIn('$', stored, 'hash must include salt separator')
# Hash is non-deterministic — setting same PIN twice gives different stored values
self.user.sudo().set_tablet_pin('1234')
self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash)
def test_verify_correct_pin(self):
self.user.sudo().set_tablet_pin('1234')
self.assertTrue(self.user.sudo().verify_tablet_pin('1234'))
def test_verify_wrong_pin(self):
self.user.sudo().set_tablet_pin('1234')
self.assertFalse(self.user.sudo().verify_tablet_pin('0000'))
def test_verify_pin_no_hash_set(self):
self.assertFalse(self.user.sudo().verify_tablet_pin('1234'))
def test_set_pin_rejects_invalid_format(self):
from odoo.exceptions import UserError
for bad in ('123', '12345', 'abcd', '', None):
with self.assertRaises(UserError):
self.user.sudo().set_tablet_pin(bad)
def test_clear_pin_wipes_hash_and_posts_chatter(self):
self.user.sudo().set_tablet_pin('1234')
self.user.clear_tablet_pin()
self.user.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertFalse(self.user.sudo().x_fc_tablet_pin_hash)
self.assertFalse(self.user.sudo().x_fc_tablet_pin_set_date)
# Check chatter
last_msg = self.user.message_ids[:1]
self.assertIn('reset by', (last_msg.body or '').lower())