A site running external mode keeps its devices and check-in/out history in a separate cmmc_usb database. There was no way to bring that in, so the choice was to keep the second database forever or lose the history. This adds 'flask usb import-external' - dry run by default, --commit to write - which reads through the same connection settings the routes already use and never writes to the source. Two things it has to reconcile, and they are why it is a command rather than a few INSERTs. The schemas disagree about SHAPE. External is an event log, one row per action; self-hosted is a paired row carrying both times. Pairing walks each device in time order, and real logs after years of use are untidy. The rule is that nothing is invented and nothing vanishes: a drive still out becomes an open checkout and is NOT flagged (that is just a drive somebody has), a return with no matching issue is skipped and named, and two issues in a row leave the first open rather than inventing a return time nobody recorded. Identity is the other. usbdevices.serialnumber is unique and NOT NULL and the external devices table has no serial, so device_id becomes it. Blank or duplicated ids stop that device rather than inventing a key. external device_owner has no column here, so it is appended to the device notes rather than dropped. Also adds usbcheckouts.scannedviruses, the one field with nowhere to land. waswiped already means what external calls sanitized - selfhosted.py was mapping it - but scanned_viruses was HARDCODED None in the log output: the API promised the field and had nothing behind it, so an imported history would have rendered blank. It now reads and writes properly. Nullable, never defaulted to false. An unrecorded scan is not a failed scan, and for removable-media records those are very different claims. Full suite 2131 passed; seven new tests pin the pairing rules.
207 lines
8.5 KiB
Python
207 lines
8.5 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_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'),
|
|
]
|