The USB kiosk had a feature flag and nothing else. A flag is a decision, not a credential: with the kiosk on, anyone who could reach the app could move a drive between checked-out and checked-in and sign it with a badge they guessed. Same reasoning that closed the parts kiosk, smaller blast radius - which is why Darkwing did not raise it and why it was worth asking about anyway. Both kiosks now share one mechanism. The device token moved out of the printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token by URL prefix, with one localStorage key per kiosk - they are separate screens in separate rooms, and a shared token would mean provisioning one grants the other. claimKioskToken() is shared too, so both views take the token from a ?token= URL once and strip it from the address bar identically. Then the part that matters more than either fix. I told you to set the new settings in the UI, and that was impossible: every plugin settings page carries a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled, printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read correctly, and rendered by no page - changeable only by writing SQL. tests/test_plugin_settings_reachable.py now fails when any declared setting appears nowhere in its plugin's frontend. It found seven MORE that were already in that state, all pre-existing: collector_corporate_ranges computers_configpolicies_enabled computers_machinelink_alerts computers_quietreporthours geenforce_reportstaleminutes printers_dashboardpercent warranty_expiringdays All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then fixed them and deleted the ratchet - a backlog list in a new check is a way of agreeing the check does not apply yet. There is no backlog; the check is absolute. Two of my own mistakes on the way, both caught by tests rather than by reading: the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so it called itself until the stack ran out; and the first version of the reachable check skipped all 17 plugins because it looked for get_settings() when the method is get_settings_defaults() - it passed by testing nothing, which is the exact shape it exists to catch. Suite 2336, frontend 316, build clean. Scope: product
219 lines
9.0 KiB
Python
219 lines
9.0 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_cli_commands(self) -> List:
|
|
"""CLI: import records off an external cmmc_usb database."""
|
|
from .cli import build_cli
|
|
return build_cli()
|
|
|
|
def get_reports(self) -> List[Dict]:
|
|
"""Removable-media audit.
|
|
|
|
CMMC tracking is an audit obligation, and the only way to answer "who
|
|
had which drive, when" was to page through the check-in/out list by
|
|
hand. The window covers a shift, a periodic review, or everything ever
|
|
recorded.
|
|
"""
|
|
return [
|
|
{
|
|
'id': 'usb-audit',
|
|
'name': 'USB Check-in / Check-out Audit',
|
|
'description': 'Every take and return over a chosen window - '
|
|
'day, month, six months, year or all time - with '
|
|
'who, which drive and when',
|
|
'category': 'usage',
|
|
'endpoint': '/api/usb/reports/audit',
|
|
},
|
|
]
|
|
|
|
def get_settings_cards(self) -> List[Dict]:
|
|
"""Somewhere to actually turn the kiosk on.
|
|
|
|
The kiosk page tells an administrator to enable it in Settings under
|
|
USB. Without this card that place does not exist, and the instruction
|
|
is a dead end - the same trap the backups plugin shipped with, where
|
|
settings existed but only a shell could reach them.
|
|
"""
|
|
return [
|
|
{
|
|
'group': 'USB Devices',
|
|
'to': '/settings/usb',
|
|
'icon': 'usb',
|
|
'title': 'USB Settings',
|
|
'description': 'Logged-out kiosk for taking and returning '
|
|
'drives, and whether somebody may return a drive '
|
|
'checked out to another person',
|
|
'position': 45,
|
|
},
|
|
]
|
|
|
|
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_token',
|
|
'value': '',
|
|
# string: the column documents string/boolean/integer/json.
|
|
# Masking keys on the NAME containing 'token'.
|
|
'valuetype': 'string',
|
|
'category': 'usb',
|
|
'description': 'Device token the USB kiosk sends as '
|
|
'X-Kiosk-Token. Generate one, then open the '
|
|
'kiosk once as /usb-kiosk?token=<value> on each '
|
|
'screen',
|
|
},
|
|
{
|
|
'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',
|
|
'permission': 'usb.view',
|
|
'position': 25,
|
|
'section': 'assets',
|
|
},
|
|
]
|
|
|
|
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'),
|
|
]
|