Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -102,14 +102,27 @@ def caller_holds(permission_name: str) -> bool:
|
||||
Guests are included on purpose: a signed-out visitor holds whatever the
|
||||
guest role was granted, so the answer must come from the same place
|
||||
require_permission gets it.
|
||||
|
||||
Scoped personal tokens are included for the same reason, and this is the
|
||||
half that was missing: the answer has to be about the CALLER, and a scoped
|
||||
token is a narrower caller than its owner. Reading the owner's permissions
|
||||
let a token scoped to endpoints.command queue a remote restart, because the
|
||||
elevated check asked the owner rather than the token - the one bypass
|
||||
require_permission exists to prevent, reintroduced by a route that did its
|
||||
own check.
|
||||
"""
|
||||
user = currentuser()
|
||||
if user is None:
|
||||
return guestholds(permission_name)
|
||||
try:
|
||||
return bool(user.haspermission(permission_name))
|
||||
if not user.haspermission(permission_name):
|
||||
return False
|
||||
patscopes = get_jwt().get('patscopes')
|
||||
except Exception:
|
||||
return False
|
||||
if patscopes is not None:
|
||||
return permission_name in patscopes
|
||||
return True
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
|
||||
70
shopdb/utils/badges.py
Normal file
70
shopdb/utils/badges.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""What a scanned badge looks like, in one place.
|
||||
|
||||
Two kiosks read the same physical badges and disagreed about them. The parts
|
||||
kiosk understood both shapes a scanner emits; the USB kiosk took the string
|
||||
raw and compared it to an SSO, so the wrapped form was simply "not recognized".
|
||||
An operator whose keyboard-wedge scanner emits `0270015376BZ` could take a
|
||||
3D-printed part and could not check out a drive - with the same badge, at two
|
||||
cabinets a few metres apart.
|
||||
|
||||
The shapes:
|
||||
|
||||
270015376 the SSO as printed, what a keypad entry looks like
|
||||
0270015376BZ the PayNo wrapper some scanners emit around it
|
||||
|
||||
Both mean the same person. Nothing else is a badge - and in particular a string
|
||||
of NON-ASCII digits is not, however much it looks like one: Arabic-Indic digits
|
||||
pass Python's str.isdigit(), and a badge written in them was accepted, stored as
|
||||
the employee's SSO, and joined to nothing afterwards.
|
||||
|
||||
This lives in core rather than in either plugin because a plugin may not import
|
||||
another plugin, and because a badge is a property of the site, not of a
|
||||
cabinet.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# The PayNo wrapper: a leading zero, the digits, and a BZ suffix.
|
||||
PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
|
||||
|
||||
# ASCII digits only. str.isdigit() is true for Arabic-Indic and other decimal
|
||||
# forms, and int() happily parses them, so a badge in those characters resolved
|
||||
# to a real employee and was then written back in a form nothing else matches.
|
||||
ASCII_DIGITS = re.compile(r'^[0-9]+$')
|
||||
|
||||
|
||||
class BadgeShapeError(ValueError):
|
||||
"""The scanned string is not a badge this site recognises."""
|
||||
|
||||
|
||||
def normalise_badge(raw):
|
||||
"""Return the plain SSO digits for a scanned badge.
|
||||
|
||||
Accepts the bare number and the PayNo wrapper, and raises BadgeShapeError
|
||||
for anything else. The caller decides what to do about a badge that is
|
||||
well-formed but matches no employee - that is a directory question, not a
|
||||
shape one.
|
||||
"""
|
||||
badge = (raw or '').strip()
|
||||
if not badge:
|
||||
raise BadgeShapeError('Scan or enter a badge')
|
||||
if ASCII_DIGITS.match(badge):
|
||||
return badge
|
||||
match = PAYNO_BADGE.match(badge)
|
||||
if match and ASCII_DIGITS.match(match.group(1)):
|
||||
return match.group(1)
|
||||
raise BadgeShapeError('Unrecognized badge format')
|
||||
|
||||
|
||||
def badge_kind(raw):
|
||||
"""'sso' or 'payno' for a recognised badge; raises for anything else.
|
||||
|
||||
The external HR lookup searches a different column for each, so the shape
|
||||
survives normalisation for that one caller.
|
||||
"""
|
||||
badge = (raw or '').strip()
|
||||
if ASCII_DIGITS.match(badge):
|
||||
return 'sso'
|
||||
if PAYNO_BADGE.match(badge):
|
||||
return 'payno'
|
||||
raise BadgeShapeError('Unrecognized badge format')
|
||||
82
shopdb/utils/datevalues.py
Normal file
82
shopdb/utils/datevalues.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Parsing a date out of a request body, strictly.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
Two plugins carried an identical private `_parse_date` that swallowed the
|
||||
error:
|
||||
|
||||
try:
|
||||
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
So an unparseable date and a deliberately cleared one produced the same
|
||||
result, and the write went ahead. `PUT /api/warranty/<id>` with enddate
|
||||
'not-a-date' answered 200 "Warranty updated" and stored NULL - the warranty
|
||||
then vanished from every expiry surface, silently, on the strength of a typo.
|
||||
The measuring-tool calibration date did the same thing and dropped the tool out
|
||||
of the calibration report.
|
||||
|
||||
The rule here: an EXPLICIT empty value clears the field, and anything that is
|
||||
not a date is refused. Those are different intents and they get different
|
||||
answers. Callers turn `DateValueError` into a 400 naming the field, so the
|
||||
person who mistyped it finds out at the point of the mistake rather than at the
|
||||
next audit.
|
||||
|
||||
Accepted forms are deliberately narrow - 'YYYY-MM-DD', and the ISO-8601
|
||||
datetime forms whose leading ten characters are a date, since that is what the
|
||||
frontend's date inputs and every vendor API in the tree emit. Anything looser
|
||||
is a guess about what somebody meant.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
class DateValueError(ValueError):
|
||||
"""A supplied value was neither empty nor a date this accepts.
|
||||
|
||||
Carries the field name so the error message can say which one, which is the
|
||||
whole point of refusing rather than nulling.
|
||||
"""
|
||||
|
||||
def __init__(self, field, value):
|
||||
self.field = field
|
||||
self.value = value
|
||||
super().__init__(
|
||||
'%s must be a date as YYYY-MM-DD (got %r)' % (field, value))
|
||||
|
||||
|
||||
def parse_date(value, field='date'):
|
||||
"""Return a date, or None for an explicitly empty value.
|
||||
|
||||
Raises DateValueError for anything else. None and '' mean "clear it";
|
||||
they are the only values that produce None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
# A number or a list is not a mistyped date, it is the wrong type.
|
||||
raise DateValueError(field, value)
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(text[:10], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
raise DateValueError(field, value)
|
||||
|
||||
|
||||
def parse_date_fields(data, fields):
|
||||
"""Parse several date fields out of a payload at once.
|
||||
|
||||
Only keys PRESENT in the payload are returned, so a caller can tell "not
|
||||
mentioned" (leave it alone) from "sent as empty" (clear it) - a distinction
|
||||
a plain `data.get(...)` throws away, and the reason the alias-clearing bug
|
||||
existed on the PC form.
|
||||
"""
|
||||
return {field: parse_date(data[field], field)
|
||||
for field in fields if field in data}
|
||||
64
shopdb/utils/queryargs.py
Normal file
64
shopdb/utils/queryargs.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Reading a typed value out of the query string, without a 500.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
List endpoints filtered like this:
|
||||
|
||||
if type_id := request.args.get('typeid'):
|
||||
query = query.filter(Machine.machinetypeid == int(type_id))
|
||||
|
||||
`int('abc')` raises, nothing catches it, and the caller gets
|
||||
500 INTERNAL_ERROR with a traceback in the log. A wrong query argument is the
|
||||
caller's mistake and deserves a 400 saying which argument; a 500 says the server
|
||||
is broken and sends whoever is reading the log looking for a fault that is not
|
||||
there. The feature test run found the same shape in six places across five
|
||||
plugins, which makes it a property of how list routes are written rather than of
|
||||
any one route.
|
||||
|
||||
The helpers raise QueryArgError. Routes turn it into a 400 - or, more usually,
|
||||
nothing does anything at all, because the app-level handler in
|
||||
shopdb/__init__.py answers it. That is the point: a route should not have to
|
||||
remember.
|
||||
|
||||
Deliberately NOT the same policy as get_pagination_params, which falls back to
|
||||
a default for a bad page number. A nonsense page is harmless and the caller
|
||||
still wants their list; a nonsense FILTER is not, because silently ignoring it
|
||||
returns rows the caller believes they excluded.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
|
||||
|
||||
class QueryArgError(ValueError):
|
||||
"""A query argument was present but not of the type the route needs."""
|
||||
|
||||
def __init__(self, name, value, expected='a whole number'):
|
||||
self.name = name
|
||||
self.value = value
|
||||
super().__init__('%s must be %s (got %r)' % (name, expected, value))
|
||||
|
||||
|
||||
def intarg(name, default=None, request_obj=None):
|
||||
"""Read an integer query argument.
|
||||
|
||||
Absent or empty -> default. Present but not an integer -> QueryArgError,
|
||||
which the app answers as a 400 naming the argument.
|
||||
"""
|
||||
source = request_obj if request_obj is not None else request
|
||||
raw = source.args.get(name)
|
||||
if raw is None or raw == '':
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise QueryArgError(name, raw)
|
||||
|
||||
|
||||
def intargs(*names, request_obj=None):
|
||||
"""Read several integer arguments at once; returns a dict of those present."""
|
||||
found = {}
|
||||
for name in names:
|
||||
value = intarg(name, request_obj=request_obj)
|
||||
if value is not None:
|
||||
found[name] = value
|
||||
return found
|
||||
Reference in New Issue
Block a user