"""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. DECLARING A KEY HERE IS HALF THE JOB. A site on an air-gapped server changes a setting through the USB settings page or not at all, and that page renders a hardcoded list - frontend/views/USBSettings.vue, its KEYS and TEXTKEYS arrays plus a field for each. A key declared here and absent there is a constant with extra steps. tests/test_plugin_settings_reachable.py is the gate for it, and it is currently RED for four of the keys below: usb_kiosk_trusted_proxies, usb_kiosk_allow_wide_cidrs, usb_kiosk_badge_limit_enabled and usb_kiosk_badge_miss_max. All four need a field on that page. The last two are a checkbox and a number; the first two belong beside the existing allowlist picker, since all three describe the same decision. """ 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 on every ' 'call. Generate one, then use "Copy setup link" ' 'and open that link once on each screen: the ' 'link carries a single-use code, not the token, ' 'so the token never sits in a URL or a proxy ' 'log. Screens provisioned by an older ' '/usb-kiosk?token= link keep working', }, { 'key': 'usb_kiosk_allowed_cidrs', 'value': '', 'valuetype': 'string', 'category': 'usb', '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 - ' 'provisioning a token into every browser is not ' 'workable. Empty = token only. Wall screens keep ' 'using the token either way. A range wider than ' '/16 is refused unless usb_kiosk_allow_wide_cidrs ' 'is on, and 0.0.0.0/0 always is. This is only an ' 'admission check if the caller cannot choose the ' 'address the app sees: on Windows that means the ' 'IIS rewrite rule is live (-ClientIpSource ' 'direct), or a reverse proxy in front appends the ' 'caller to X-Forwarded-For. The installer checks ' 'both and says so. Until one holds, leave this ' 'empty and provision each screen with the token.', }, { 'key': 'usb_kiosk_trusted_proxies', 'value': '', 'valuetype': 'string', 'category': 'usb', 'description': 'Addresses or CIDRs of the reverse proxies and ' 'load balancers that sit in front of this ' 'server, comma separated. These are SUBTRACTED ' 'from the kiosk allowlist: a request that arrives ' 'wearing one of these addresses is the proxy ' 'talking about itself - it forwarded nothing, or ' 'nothing forwarded at all - so it says nothing ' 'about who the caller is and is refused rather ' 'than admitted. Set this whenever a proxy is in ' 'front, because a proxy usually sits on the very ' 'plant network the allowlist names, and would ' 'otherwise match it. The app never reads ' 'X-Forwarded-For itself; the layer in front of ' 'it already resolved the caller address.', }, { 'key': 'usb_kiosk_allow_wide_cidrs', 'value': 'false', 'valuetype': 'boolean', 'category': 'usb', 'description': 'Allow a kiosk network range wider than /16 ' '(/48 for IPv6) to be saved. Off by default ' 'because the usual reason a wide range appears ' 'is a typo, and everyone inside one is admitted ' 'to an unauthenticated write with no token and ' 'no login. Turn it on only for a site that ' 'really is one flat network that wide. ' '0.0.0.0/0 and ::/0 stay refused either way.', }, { 'key': 'usb_kiosk_badge_limit_enabled', 'value': 'true', 'valuetype': 'boolean', 'category': 'usb', 'description': 'Stop answering badge lookups for a caller that ' 'has scanned too many badges this site does not ' 'recognise. On by default: every kiosk call ' 'answers with the employee NAME, so an unbounded ' 'lookup is a directory anyone admitted can walk, ' 'and admission by network can mean a whole VLAN ' 'rather than one screen by the cabinet. An ' 'ordinary day at the cabinet produces no misses ' 'at all. Turn it off only for a site whose ' 'scanners misread often enough that people are ' 'being locked out mid-shift.', }, { 'key': 'usb_kiosk_badge_miss_max', 'value': '15', 'valuetype': 'integer', 'category': 'usb', 'description': 'How many unrecognised badges one caller may ' 'scan within ten minutes before the kiosk stops ' 'answering for the rest of that window. Only ' 'misses count, so this can sit well above a bad ' 'scanning day and still stop somebody walking ' 'the badge range with a loop. Blank, zero or ' 'anything that is not a number falls back to 15 ' '- zero here would refuse the first scan of ' 'every window, not lift the limit.', }, { '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.""" # The kiosk CIDR settings decide who may write to this site without # logging in, and the core settings API stores what it is given: it # checks valuetype and has no per-key hook a plugin can declare. These # are this plugin's keys, so this plugin validates them, at the save, # where the administrator who typed the range is still there to read # why it was refused. # # Guarded because init_app also runs when an operator enables this # plugin on a RUNNING server, and Flask refuses a setup method after # the first request. Symmetric with the blueprint, which the plugin # manager already declines to register at that point: the guard starts # working at the next restart, and a plugin being switched on is not a # moment to fail the whole load over. from .api.routes import validate_kiosk_settings_write try: app.before_request(validate_kiosk_settings_write) except Exception: logger.warning('USB kiosk CIDR validation is not active until this ' 'app restarts (plugin enabled after first request)') 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'), ]