Both kiosks admit a check-in or check-out with no login when the caller sits in a configured network. The subtraction that makes that safe was missing. A reverse proxy normally sits ON the plant network a site enters in the allowlist. When the proxy forwards nothing - misconfigured, or something bypassed it - the request arrives wearing the proxy's own address, which matches the allowlist, while carrying no evidence whatever about who sent it. That is the one case that has to refuse, and it was being admitted. The previous fail-closed test only looked closed because the proxy in it happened to sit outside the range. Addresses that belong to the path are now subtracted before the allowlist is consulted: loopback always, since no site's kiosk is the server itself, plus whatever the site declares as its proxies. USB and printedparts get the same treatment - they had diverged for no reason anybody chose. Three more, same surface: A range that admits everybody can no longer be saved by accident. 0.0.0.0/0 and ::/0 are refused outright, anything wider than a site plausibly needs is refused unless an explicit override is set, and the refusal says why. The kiosk token no longer travels in a URL. The provisioning link carries a single-use setup code redeemed on first open, so the token stays out of browser history, referrers and proxy logs. The route is declared public deliberately: the secret is the code, the way a password-reset link is the secret. The badge-to-name lookup is bounded. It answers without a login and is now reachable from a whole network rather than one screen, so a source that keeps missing gets refused rather than being allowed to walk the directory. On by default, budget and kill switch are settings. All four new settings carry controls on their settings pages. A setting that can stop the kiosk, or widen it, must not be reachable only by hand-editing rows. The installer's client-IP advice is corrected: it described the opposite of what the shipped waitress flags do, and named only GE-Enforce as the thing that breaks when the rule is absent, when the two kiosk allowlists depend on it too.
246 lines
10 KiB
Python
246 lines
10 KiB
Python
"""Printedparts plugin main class.
|
|
|
|
3D-printed parts inventory + kiosk checkout. Quantity-based consumables:
|
|
one row is a KIND of part with a count, not an individually tracked asset,
|
|
so unlike most plugins this one seeds NO AssetType (ADR-001 assets are
|
|
one-row-per-physical-thing). See docs/proposals/printedparts-plugin.md.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Optional, Type
|
|
|
|
from flask import Flask, Blueprint
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
|
|
from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
|
|
from .api import printedparts_bp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PrintedpartsPlugin(BasePlugin):
|
|
"""3D-printed parts inventory + kiosk checkout."""
|
|
|
|
def __init__(self):
|
|
manifest_path = Path(__file__).parent / 'manifest.json'
|
|
with open(manifest_path) as f:
|
|
self._manifest = json.load(f)
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
return PluginMeta(
|
|
name=self._manifest['name'],
|
|
version=self._manifest['version'],
|
|
description=self._manifest['description'],
|
|
author=self._manifest.get('author', ''),
|
|
dependencies=self._manifest.get('dependencies', []),
|
|
core_version=self._manifest.get('core_version', '>=0.1.0'),
|
|
api_prefix=self._manifest.get('api_prefix'),
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
return printedparts_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
return [PrintedItem, PrintedItemTransaction, PrintedItemFile]
|
|
|
|
def init_app(self, app: Flask, db_instance) -> None:
|
|
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
|
|
|
def get_permissions(self) -> List:
|
|
"""RBAC permissions this plugin owns (seeded on install/enable)."""
|
|
return [
|
|
('printedparts.view', 'View 3D printed parts', 'printedparts'),
|
|
('printedparts.create', 'Create printed parts', 'printedparts'),
|
|
('printedparts.edit', 'Edit printed parts', 'printedparts'),
|
|
('printedparts.delete', 'Retire printed parts', 'printedparts'),
|
|
('printedparts.restock', 'Restock and adjust stock counts',
|
|
'printedparts'),
|
|
]
|
|
|
|
def get_settings_cards(self) -> List[dict]:
|
|
return [
|
|
{
|
|
'group': '3D Printed Parts',
|
|
'to': '/settings/printedparts',
|
|
'icon': 'box',
|
|
'title': '3D Parts Settings',
|
|
'description': 'Item code prefix, default threshold, kiosk '
|
|
'badge policy, low-stock alert recipients',
|
|
'position': 47,
|
|
},
|
|
]
|
|
|
|
def get_reports(self) -> List[dict]:
|
|
return [
|
|
{
|
|
'id': 'printedparts-stock',
|
|
'name': '3D Parts Stock',
|
|
'description': 'Stock levels with low-stock flags and the '
|
|
'cache-vs-ledger reconcile check',
|
|
'category': 'inventory',
|
|
'endpoint': '/api/printedparts/reports/stock',
|
|
},
|
|
{
|
|
'id': 'printedparts-consumption',
|
|
'name': '3D Parts Consumption',
|
|
'description': 'Takes per item over a date range',
|
|
'category': 'usage',
|
|
'endpoint': '/api/printedparts/reports/consumption',
|
|
},
|
|
{
|
|
'id': 'printedparts-by-person',
|
|
'name': '3D Parts by Person',
|
|
'description': 'Takes grouped by employee',
|
|
'category': 'usage',
|
|
'endpoint': '/api/printedparts/reports/by-person',
|
|
},
|
|
]
|
|
|
|
def get_navigation_items(self) -> List[dict]:
|
|
return [
|
|
{
|
|
'name': '3D Parts',
|
|
'icon': 'box',
|
|
'route': '/printedparts',
|
|
'position': 27,
|
|
# Named so the nav can hide it from anybody - a signed-out
|
|
# visitor included - who does not hold it.
|
|
'permission': 'printedparts.view',
|
|
'section': 'assets',
|
|
},
|
|
]
|
|
|
|
def on_install(self, app: Flask) -> None:
|
|
logger.info('Printedparts plugin installed')
|
|
|
|
def get_settings_defaults(self) -> List[dict]:
|
|
# The framework seeds these at install, at enable, and on every
|
|
# `flask plugin upgrade-all`, so a key added in a later version reaches
|
|
# a site that installed an earlier one.
|
|
return [
|
|
{
|
|
'key': 'printedparts_code_prefix',
|
|
'value': '3DP',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Prefix for generated item codes',
|
|
},
|
|
{
|
|
'key': 'printedparts_label_prefix',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
# Public: the parts kiosk runs logged out, so the anonymous
|
|
# read has to be allowed or the prefix never renders there.
|
|
'public': True,
|
|
'description': 'Leading text on the physical gage-lab labels, '
|
|
'shown at the kiosk before the number box so '
|
|
'operators type only the digits. Empty shows no '
|
|
'prefix. Site-specific: set it to whatever this '
|
|
"site's labels are printed with, e.g. WJ",
|
|
},
|
|
{
|
|
'key': 'printedparts_default_threshold',
|
|
'value': '5',
|
|
'valuetype': 'integer',
|
|
'category': 'printedparts',
|
|
'description': 'Default low-stock threshold for new items',
|
|
},
|
|
{
|
|
'key': 'printedparts_kiosk_enabled',
|
|
'value': 'false',
|
|
'valuetype': 'boolean',
|
|
'category': 'printedparts',
|
|
'description': 'Serve the parts kiosk routes. Off by default: '
|
|
'they are the only unauthenticated write in the '
|
|
'product, so a site opts in',
|
|
},
|
|
{
|
|
'key': 'printedparts_kiosk_token',
|
|
'value': '',
|
|
# string, not 'secret': the column documents string/boolean/
|
|
# integer/json only. Masking is keyed on the NAME containing
|
|
# 'token' (settings.py::_is_secret), so this is still hidden in
|
|
# the API and the audit log.
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
|
|
'Generate one, then open the kiosk once as '
|
|
'/parts-kiosk?token=<value> on each screen',
|
|
},
|
|
{
|
|
'key': 'printedparts_kiosk_trusted_proxies',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Addresses or CIDRs of the reverse proxies and '
|
|
'load balancers in front of this server, comma '
|
|
'separated. SUBTRACTED from the kiosk allowlist '
|
|
'below: a request wearing one of these addresses '
|
|
'is the proxy talking about itself, so it is '
|
|
'refused rather than admitted by a range that '
|
|
'happens to contain the proxy. Loopback is '
|
|
'always subtracted and needs no entry. Leave '
|
|
'empty when browsers reach this server directly.',
|
|
},
|
|
{
|
|
'key': 'printedparts_kiosk_allowed_cidrs',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Networks allowed to use the kiosk WITHOUT the '
|
|
'device token, as comma-separated CIDRs (for '
|
|
'example 192.0.2.0/24). For people who open '
|
|
'the kiosk on their own PC and never log in. '
|
|
'Empty = token only. Separate from the USB '
|
|
'kiosk allowlist on purpose. Requires the IIS '
|
|
'X-Forwarded-For rewrite rule to be live, or the '
|
|
'app sees the proxy address and this matches '
|
|
'nothing.',
|
|
},
|
|
{
|
|
'key': 'printedparts_unknown_badge',
|
|
'value': 'deny',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Kiosk policy when a badge resolves to no '
|
|
'employee: allow or deny',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_email',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Comma-separated low-stock alert recipients; '
|
|
'empty uses the site alert_recipients',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_userids',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Comma-separated shopdb user ids whose account '
|
|
'emails receive low-stock alerts',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_roleids',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Comma-separated role ids; every active member '
|
|
'of these roles receives low-stock alerts',
|
|
},
|
|
{
|
|
'key': 'printedparts_alert_supportteamid',
|
|
'value': '',
|
|
'valuetype': 'string',
|
|
'category': 'printedparts',
|
|
'description': 'Support team whose webhook receives low-stock '
|
|
'alerts; empty uses the site alert_webhook_url',
|
|
},
|
|
]
|