The people who actually take USB drives are standing at the cabinet, and most of them have no account here. Check-out and check-in were gated behind a login that was not doing much anyway: both modes already attribute the action to a scanned BADGE rather than to the signed-in user, so the login was gating the screen, not the record. /usb-kiosk is a full-screen touch page modelled on the parts kiosk, off unless a site sets usb_kiosk_enabled - every route 404s until then, rather than 403, so a disabled kiosk does not advertise that there is a door here. It works against the self-hosted tables or an existing cmmc_usb database. THE LIST IS THE TRADE. These drives carry no barcode, so unlike the parts kiosk there is nothing to present and the screen has to offer a list - which exposes more than a scan-first design, where you had to already hold a drive to learn anything. So the list is not anonymous: an SSO comes first and must resolve to a real person before any drive is named, which is the same bar taking a drive already had to clear. It is a POST so the badge stays out of proxy access logs. The main screen shows the caller's own drives, what is free, and a COUNT of everything else; holders of other people's drives appear only behind a second deliberate tap, so nobody reads the whole cabinet off a wall panel in passing. A badge that resolves to no employee is REFUSED, unlike the signed-in route which creates the user row. A checkout nobody can trace to a person defeats the tracking, and at a kiosk there is no operator standing there to vouch for it. Returning somebody else's drive asks first and then records the badge that was SCANNED, so the log says who actually handed it back. That is the honest record and it needs no change to a schema this app does not own. Refusing a mismatch outright is a setting but not the default: a drive left behind by somebody on leave would otherwise be unreturnable at the kiosk, which just moves the problem to a person with a login. The authz sweep caught these writes, twice - once for the two mutations and again for the two list endpoints. Both are exempted with the reasoning written out beside the parts-kiosk entry rather than silenced. The list endpoints are POSTs that change nothing. Verified by driving the real page: SSO on the keypad, tap Take, database moves.
159 lines
6.6 KiB
Python
159 lines
6.6 KiB
Python
"""USB plugin main class."""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Type
|
|
|
|
from flask import Flask, Blueprint
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
|
|
from .models import USBDevice, USBDeviceType, USBCheckout
|
|
from .api import usb_bp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class USBPlugin(BasePlugin):
|
|
"""
|
|
USB plugin - manages USB device tracking and checkouts.
|
|
|
|
Standalone plugin for tracking USB flash drives, external drives,
|
|
and other portable storage devices with checkout/checkin functionality.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._manifest = self._load_manifest()
|
|
|
|
def _load_manifest(self) -> Dict:
|
|
"""Load plugin manifest from JSON file."""
|
|
manifest_path = Path(__file__).parent / 'manifest.json'
|
|
if manifest_path.exists():
|
|
with open(manifest_path, 'r') as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
"""Return plugin metadata."""
|
|
return PluginMeta(
|
|
name=self._manifest.get('name', 'usb'),
|
|
version=self._manifest.get('version', '1.0.0'),
|
|
description=self._manifest.get('description', 'USB device checkout management'),
|
|
author=self._manifest.get('author', 'ShopDB Team'),
|
|
dependencies=self._manifest.get('dependencies', []),
|
|
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
|
api_prefix=self._manifest.get('api_prefix', '/api/usb'),
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
"""Return Flask Blueprint with API routes."""
|
|
return usb_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
"""Return list of SQLAlchemy model classes."""
|
|
return [USBDeviceType, USBDevice, USBCheckout]
|
|
|
|
def get_provisioning_note(self) -> Optional[Dict]:
|
|
return {
|
|
'mode_setting': 'usb_directory_mode',
|
|
'tables': ['usbdevicetypes', 'usbdevices', 'usbcheckouts'],
|
|
'note': ('Enabling this creates USB tracking tables in the shopdb '
|
|
'database (usbdevicetypes, usbdevices, usbcheckouts) for CMMC '
|
|
'removable-media check-in/out. Devices, check-in/out events, '
|
|
'and per-event log ids live here. Planned build-out: a USB-ID '
|
|
'standard, overdue-device email reminders, and DLP/Fabric '
|
|
'approval integration (see the captured design notes). A site '
|
|
'with an existing cmmc_usb database can use external mode '
|
|
'instead.'),
|
|
'docs': 'plugins/usb/README.md',
|
|
}
|
|
|
|
def get_settings_defaults(self) -> List[Dict]:
|
|
"""Kiosk settings.
|
|
|
|
The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns
|
|
it on deliberately. Opening that door is a decision about the door.
|
|
"""
|
|
return [
|
|
{
|
|
'key': 'usb_kiosk_enabled',
|
|
'value': 'false',
|
|
'valuetype': 'boolean',
|
|
'category': 'usb',
|
|
# Public: the kiosk page runs logged out and has to be able to
|
|
# tell "turned off" from "broken" before anyone signs in.
|
|
'public': True,
|
|
'description': 'Allow check-out and check-in from the logged-out '
|
|
'USB kiosk screen (/usb-kiosk). Off by default: the '
|
|
'kiosk writes without a login, attributing every '
|
|
'action to a scanned badge instead. It can only move '
|
|
'a device between checked-out and checked-in - it '
|
|
'cannot add, edit, retire or list devices.',
|
|
},
|
|
{
|
|
'key': 'usb_kiosk_allow_return_for_others',
|
|
'value': 'true',
|
|
'valuetype': 'boolean',
|
|
'category': 'usb',
|
|
'public': True,
|
|
'description': 'At the kiosk, allow somebody to return a device '
|
|
'checked out to a DIFFERENT person after confirming. '
|
|
'The returning badge is what gets recorded, so the '
|
|
'log shows who actually handed it back. Turn this '
|
|
'off to refuse any mismatch outright - a device left '
|
|
'behind by someone on leave then needs a signed-in '
|
|
'user to return it.',
|
|
},
|
|
]
|
|
|
|
def get_config_schema(self) -> List[Dict]:
|
|
"""CMMC USB check-in/out database connection. Host/name/user are settings
|
|
the wizard can edit; the password stays in .env (emitted, not stored)."""
|
|
return [
|
|
{'key': 'cmmc_usb_db_host', 'label': 'USB DB host', 'type': 'text',
|
|
'secret': False, 'default': 'localhost',
|
|
'help': 'This DB must expose devices / checkinoutlog / users tables '
|
|
'(or views). See plugins/usb/README.md.'},
|
|
{'key': 'cmmc_usb_db_name', 'label': 'USB DB name', 'type': 'text',
|
|
'secret': False, 'default': 'cmmc_usb'},
|
|
{'key': 'cmmc_usb_db_user', 'label': 'USB DB user', 'type': 'text',
|
|
'secret': False},
|
|
{'key': 'cmmc_usb_db_password', 'label': 'USB DB password', 'type': 'password',
|
|
'secret': True, 'envvar': 'CMMC_USB_DB_PASSWORD',
|
|
'help': 'Stored in .env, not the database. The wizard shows the line to paste.'},
|
|
]
|
|
|
|
def init_app(self, app: Flask, db_instance) -> None:
|
|
"""Initialize plugin with Flask app."""
|
|
logger.info(f"USB plugin initialized (v{self.meta.version})")
|
|
|
|
def on_install(self, app: Flask) -> None:
|
|
"""Called when plugin is installed."""
|
|
logger.info("USB plugin installed")
|
|
|
|
def on_uninstall(self, app: Flask) -> None:
|
|
"""Called when plugin is uninstalled."""
|
|
logger.info("USB plugin uninstalled")
|
|
|
|
def get_navigation_items(self) -> List[Dict]:
|
|
"""Return navigation menu items."""
|
|
return [
|
|
{
|
|
'name': 'USB Devices',
|
|
'icon': 'usb',
|
|
'route': '/usb',
|
|
'position': 45,
|
|
},
|
|
]
|
|
|
|
def get_permissions(self) -> List:
|
|
"""Return the RBAC permissions this plugin owns."""
|
|
return [
|
|
('usb.view', 'View USB devices', 'usb'),
|
|
('usb.create', 'Create USB devices', 'usb'),
|
|
('usb.edit', 'Edit USB devices', 'usb'),
|
|
('usb.delete', 'Delete USB devices', 'usb'),
|
|
]
|