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.
1756 lines
68 KiB
Python
1756 lines
68 KiB
Python
"""USB plugin API endpoints.
|
|
|
|
Two modes, chosen by the usb_directory_mode setting:
|
|
- 'selfhosted' (default): the app-owned tables (usbdevices/usbcheckouts/
|
|
usbdevicetypes) via SQLAlchemy - see selfhosted.py. Each route below
|
|
delegates when _usb_selfhosted() is true.
|
|
- 'external': a separate MySQL database (cmmc_usb) reached with raw
|
|
parameterized pymysql via cmmc_usb_connection(), for a site that already
|
|
runs the cmmc_usb solution. That is the raw-SQL code in this file.
|
|
|
|
Both modes return the same response shape so the frontend is identical.
|
|
|
|
cmmc_usb schema used here:
|
|
devices(device_id PK, device_desc, device_owner, status, locker_location)
|
|
status is one of 'checked-in' | 'checked-out' | 'retired'
|
|
users(badge_number PK, first_name, last_name)
|
|
checkinoutlog(log_id PK auto, badge_number, device_id, action, timestamp,
|
|
scanned_viruses, locker_location, sanitized)
|
|
action is one of 'check-in' | 'check-out'; timestamp is a
|
|
'YYYY-MM-DD HH:MM:SS' string; scanned_viruses/sanitized are '0'|'1'|null.
|
|
"""
|
|
|
|
import hashlib
|
|
import ipaddress
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
import hmac
|
|
|
|
from flask import Blueprint, current_app, redirect, request, Response
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from shopdb.api import (
|
|
cmmc_usb_connection,
|
|
employee_connection,
|
|
success_response,
|
|
error_response,
|
|
paginated_response,
|
|
ErrorCodes,
|
|
get_pagination_params,
|
|
require_permission,
|
|
)
|
|
from shopdb.api import Setting, audit_log, cache, client_ip, db, ip_in_cidrs
|
|
|
|
from . import selfhosted
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
usb_bp = Blueprint('usb', __name__)
|
|
|
|
|
|
def _usb_selfhosted():
|
|
"""True when USB uses the app-owned tables, not the external cmmc_usb DB."""
|
|
row = Setting.query.filter_by(key='usb_directory_mode').first()
|
|
return (row.value if row and row.value else 'external').lower() == 'selfhosted'
|
|
|
|
# public status strings on the devices row
|
|
_STATUS_CHECKED_IN = 'checked-in'
|
|
_STATUS_CHECKED_OUT = 'checked-out'
|
|
_STATUS_RETIRED = 'retired'
|
|
|
|
# map the ?status= query values to the stored devices.status strings
|
|
_STATUS_FILTER = {
|
|
'available': _STATUS_CHECKED_IN,
|
|
'checkedout': _STATUS_CHECKED_OUT,
|
|
'retired': _STATUS_RETIRED,
|
|
}
|
|
|
|
# columns pulled for a devices row
|
|
_DEVICE_COLS = 'device_id, device_desc, device_owner, status, locker_location'
|
|
|
|
# columns pulled for a checkinoutlog row
|
|
_LOG_COLS = (
|
|
'log_id, badge_number, device_id, action, timestamp, '
|
|
'scanned_viruses, locker_location, sanitized'
|
|
)
|
|
|
|
# a badge like 0123456BZ carries a PayNo (the digits) wrapped in 0...BZ
|
|
_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
|
|
|
|
|
|
# =============================================================================
|
|
# Module helpers
|
|
# =============================================================================
|
|
|
|
def _now():
|
|
"""Current local time as a 'YYYY-MM-DD HH:MM:SS' string for the log."""
|
|
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
def _flag(value):
|
|
"""Coerce a truthy/falsy input to a '1'/'0' string, or None when unset."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool):
|
|
return '1' if value else '0'
|
|
text = str(value).strip().lower()
|
|
if text in ('1', 'true', 'yes', 'y'):
|
|
return '1'
|
|
if text in ('0', 'false', 'no', 'n', ''):
|
|
return '0'
|
|
return None
|
|
|
|
|
|
def _lookup_employee_name(badge):
|
|
"""Look up "First Last" in the HR directory for a badge. Empty on any miss.
|
|
|
|
A numeric badge is an SSO. A 0<digits>BZ badge carries a PayNo (the middle
|
|
digits). Any other shape has no HR mapping so we return "".
|
|
"""
|
|
badge = (badge or '').strip()
|
|
if not badge:
|
|
return ''
|
|
|
|
try:
|
|
conn = employee_connection()
|
|
except Exception:
|
|
logger.exception('HR directory connection failed for badge %s', badge)
|
|
return ''
|
|
|
|
try:
|
|
with conn.cursor() as ecur:
|
|
if badge.isdigit():
|
|
ecur.execute(
|
|
'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s',
|
|
(badge,),
|
|
)
|
|
else:
|
|
match = _PAYNO_BADGE.match(badge)
|
|
if not match:
|
|
return ''
|
|
ecur.execute(
|
|
'SELECT First_Name, Last_Name FROM employees WHERE PayNo = %s',
|
|
(match.group(1),),
|
|
)
|
|
emp = ecur.fetchone()
|
|
except Exception:
|
|
logger.exception('HR directory lookup failed for badge %s', badge)
|
|
return ''
|
|
finally:
|
|
conn.close()
|
|
|
|
if not emp:
|
|
return ''
|
|
first = (emp.get('First_Name') or '').strip()
|
|
last = (emp.get('Last_Name') or '').strip()
|
|
return (first + ' ' + last).strip()
|
|
|
|
|
|
def _resolve_badge_name(cur, badge):
|
|
"""Resolve a badge to "First Last". Try cmmc_usb users, then HR directory.
|
|
|
|
cur is an open cmmc_usb cursor. Returns "" when the badge cannot be
|
|
resolved anywhere.
|
|
"""
|
|
badge = (badge or '').strip() if badge is not None else ''
|
|
if not badge:
|
|
return ''
|
|
|
|
cur.execute(
|
|
'SELECT first_name, last_name FROM users WHERE badge_number = %s',
|
|
(badge,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
first = (row.get('first_name') or '').strip()
|
|
last = (row.get('last_name') or '').strip()
|
|
name = (first + ' ' + last).strip()
|
|
if name:
|
|
return name
|
|
|
|
return _lookup_employee_name(badge)
|
|
|
|
|
|
def _ensure_user(cur, badge, name):
|
|
"""Insert a badge into cmmc_usb users if absent. Splits name to first/last."""
|
|
badge = (badge or '').strip() if badge is not None else ''
|
|
if not badge:
|
|
return
|
|
|
|
cur.execute('SELECT badge_number FROM users WHERE badge_number = %s', (badge,))
|
|
if cur.fetchone():
|
|
return
|
|
|
|
first, last = '', ''
|
|
if name:
|
|
parts = name.split()
|
|
if parts:
|
|
first = parts[0]
|
|
last = ' '.join(parts[1:])
|
|
|
|
cur.execute(
|
|
'INSERT INTO users (badge_number, first_name, last_name) VALUES (%s, %s, %s)',
|
|
(badge, first, last),
|
|
)
|
|
|
|
|
|
def _fetch_device(cur, device_id):
|
|
"""Return the devices row for device_id, or None."""
|
|
cur.execute(
|
|
'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE device_id = %s',
|
|
(device_id,),
|
|
)
|
|
return cur.fetchone()
|
|
|
|
|
|
def _device_to_dict(cur, row):
|
|
"""Build the device response dict, resolving owner + current holder names."""
|
|
device = {
|
|
'device_id': row.get('device_id'),
|
|
'device_desc': row.get('device_desc'),
|
|
'device_owner': row.get('device_owner'),
|
|
'owner_name': _resolve_badge_name(cur, row.get('device_owner')),
|
|
'status': row.get('status'),
|
|
'locker_location': row.get('locker_location'),
|
|
'current_holder': None,
|
|
'current_holder_name': None,
|
|
'checkout_time': None,
|
|
}
|
|
|
|
if row.get('status') == _STATUS_CHECKED_OUT:
|
|
cur.execute(
|
|
"SELECT badge_number, timestamp FROM checkinoutlog "
|
|
"WHERE device_id = %s AND action = 'check-out' "
|
|
"ORDER BY timestamp DESC, log_id DESC LIMIT 1",
|
|
(row.get('device_id'),),
|
|
)
|
|
log = cur.fetchone()
|
|
if log:
|
|
device['current_holder'] = log.get('badge_number')
|
|
device['current_holder_name'] = _resolve_badge_name(
|
|
cur, log.get('badge_number')
|
|
)
|
|
device['checkout_time'] = log.get('timestamp')
|
|
|
|
return device
|
|
|
|
|
|
def _log_to_dict(cur, row):
|
|
"""Build a checkinoutlog response dict with the resolved badge name."""
|
|
result = {
|
|
'log_id': row.get('log_id'),
|
|
'badge_number': row.get('badge_number'),
|
|
'badge_name': _resolve_badge_name(cur, row.get('badge_number')),
|
|
'device_id': row.get('device_id'),
|
|
'action': row.get('action'),
|
|
'timestamp': row.get('timestamp'),
|
|
'scanned_viruses': row.get('scanned_viruses'),
|
|
'locker_location': row.get('locker_location'),
|
|
'sanitized': row.get('sanitized'),
|
|
}
|
|
# /checkouts joins in the current device status; pass it through when present
|
|
if 'device_status' in row:
|
|
result['device_status'] = row.get('device_status')
|
|
return result
|
|
|
|
|
|
# =============================================================================
|
|
# USB Devices
|
|
# =============================================================================
|
|
|
|
@usb_bp.route('', methods=['GET'])
|
|
@require_permission('usb.view')
|
|
def list_usb_devices():
|
|
"""List USB devices with checkout status.
|
|
|
|
Query parameters:
|
|
- page, per_page: pagination
|
|
- status: available | checkedout | retired
|
|
- search: match on device_id or device_desc
|
|
"""
|
|
if _usb_selfhosted():
|
|
return selfhosted.list_devices(request)
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
where = []
|
|
params = []
|
|
|
|
status_arg = request.args.get('status', '').strip().lower()
|
|
if status_arg in _STATUS_FILTER:
|
|
where.append('status = %s')
|
|
params.append(_STATUS_FILTER[status_arg])
|
|
|
|
search = request.args.get('search', '').strip()
|
|
if search:
|
|
where.append('(device_id LIKE %s OR device_desc LIKE %s)')
|
|
like = '%' + search + '%'
|
|
params.extend([like, like])
|
|
|
|
where_sql = (' WHERE ' + ' AND '.join(where)) if where else ''
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT COUNT(*) AS total FROM devices' + where_sql, params)
|
|
total = cur.fetchone()['total']
|
|
|
|
cur.execute(
|
|
'SELECT ' + _DEVICE_COLS + ' FROM devices' + where_sql
|
|
+ ' ORDER BY device_id LIMIT %s OFFSET %s',
|
|
params + [per_page, (page - 1) * per_page],
|
|
)
|
|
rows = cur.fetchall()
|
|
data = [_device_to_dict(cur, row) for row in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@usb_bp.route('/<device_id>', methods=['GET'])
|
|
@require_permission('usb.view')
|
|
def get_usb_device(device_id):
|
|
"""Get a single device plus its last 20 check-in/out log rows."""
|
|
if _usb_selfhosted():
|
|
return selfhosted.get_device(device_id)
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
row = _fetch_device(cur, device_id)
|
|
if not row:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'USB device {device_id} not found',
|
|
http_code=404,
|
|
)
|
|
|
|
result = _device_to_dict(cur, row)
|
|
|
|
cur.execute(
|
|
'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s '
|
|
'ORDER BY timestamp DESC, log_id DESC LIMIT 20',
|
|
(device_id,),
|
|
)
|
|
logs = cur.fetchall()
|
|
result['checkinoutlog'] = [_log_to_dict(cur, log) for log in logs]
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(result)
|
|
|
|
|
|
@usb_bp.route('', methods=['POST'])
|
|
@require_permission('usb.create')
|
|
def create_usb_device():
|
|
"""Add a device (starts checked-in)."""
|
|
if _usb_selfhosted():
|
|
return selfhosted.create_device(request.get_json() or {})
|
|
data = request.get_json() or {}
|
|
|
|
device_id = (data.get('device_id') or '').strip()
|
|
if not device_id:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'device_id is required')
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
'SELECT device_id FROM devices WHERE device_id = %s', (device_id,)
|
|
)
|
|
if cur.fetchone():
|
|
return error_response(
|
|
ErrorCodes.CONFLICT, 'device_id already exists', http_code=409
|
|
)
|
|
|
|
cur.execute(
|
|
'INSERT INTO devices '
|
|
'(device_id, device_desc, device_owner, status, locker_location) '
|
|
'VALUES (%s, %s, %s, %s, %s)',
|
|
(
|
|
device_id,
|
|
data.get('device_desc'),
|
|
data.get('device_owner'),
|
|
_STATUS_CHECKED_IN,
|
|
data.get('locker_location'),
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
with conn.cursor() as cur:
|
|
row = _fetch_device(cur, device_id)
|
|
result = _device_to_dict(cur, row)
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(result, message='Device created', http_code=201)
|
|
|
|
|
|
@usb_bp.route('/<device_id>', methods=['PUT'])
|
|
@require_permission('usb.edit')
|
|
def update_usb_device(device_id):
|
|
"""Edit device_desc / device_owner / locker_location / status."""
|
|
if _usb_selfhosted():
|
|
return selfhosted.update_device(device_id, request.get_json() or {})
|
|
data = request.get_json() or {}
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
if not _fetch_device(cur, device_id):
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'USB device {device_id} not found',
|
|
http_code=404,
|
|
)
|
|
|
|
fields = []
|
|
params = []
|
|
for col in ('device_desc', 'device_owner', 'locker_location', 'status'):
|
|
if col in data:
|
|
fields.append(col + ' = %s')
|
|
params.append(data[col])
|
|
|
|
if fields:
|
|
params.append(device_id)
|
|
cur.execute(
|
|
'UPDATE devices SET ' + ', '.join(fields) + ' WHERE device_id = %s',
|
|
params,
|
|
)
|
|
conn.commit()
|
|
|
|
with conn.cursor() as cur:
|
|
row = _fetch_device(cur, device_id)
|
|
result = _device_to_dict(cur, row)
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(result, message='Device updated')
|
|
|
|
|
|
@usb_bp.route('/<device_id>/retire', methods=['POST'])
|
|
@require_permission('usb.edit')
|
|
def retire_usb_device(device_id):
|
|
"""Retire a device (status -> retired)."""
|
|
if _usb_selfhosted():
|
|
return selfhosted.retire_device(device_id)
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
if not _fetch_device(cur, device_id):
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'USB device {device_id} not found',
|
|
http_code=404,
|
|
)
|
|
cur.execute(
|
|
'UPDATE devices SET status = %s WHERE device_id = %s',
|
|
(_STATUS_RETIRED, device_id),
|
|
)
|
|
conn.commit()
|
|
|
|
with conn.cursor() as cur:
|
|
row = _fetch_device(cur, device_id)
|
|
result = _device_to_dict(cur, row)
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(result, message='Device retired')
|
|
|
|
|
|
# =============================================================================
|
|
# Check-in / check-out operations
|
|
# =============================================================================
|
|
|
|
@usb_bp.route('/<device_id>/checkout', methods=['POST'])
|
|
@require_permission('usb.create')
|
|
def checkout_device(device_id):
|
|
"""Check a device out to a badge."""
|
|
if _usb_selfhosted():
|
|
return selfhosted.checkout_device(device_id, request.get_json() or {})
|
|
data = request.get_json() or {}
|
|
|
|
badge = (data.get('badge') or '').strip()
|
|
if not badge:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required')
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
device = _fetch_device(cur, device_id)
|
|
if not device:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'USB device {device_id} not found',
|
|
http_code=404,
|
|
)
|
|
if device.get('status') == _STATUS_CHECKED_OUT:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'Device is already checked out',
|
|
http_code=409,
|
|
)
|
|
|
|
name = _resolve_badge_name(cur, badge)
|
|
_ensure_user(cur, badge, name)
|
|
|
|
locker = data.get('locker_location')
|
|
log_locker = locker if locker is not None else device.get('locker_location')
|
|
|
|
cur.execute(
|
|
'INSERT INTO checkinoutlog '
|
|
'(badge_number, device_id, action, timestamp, locker_location) '
|
|
"VALUES (%s, %s, 'check-out', %s, %s)",
|
|
(badge, device_id, _now(), log_locker),
|
|
)
|
|
|
|
if locker is not None:
|
|
cur.execute(
|
|
'UPDATE devices SET status = %s, locker_location = %s '
|
|
'WHERE device_id = %s',
|
|
(_STATUS_CHECKED_OUT, locker, device_id),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
'UPDATE devices SET status = %s WHERE device_id = %s',
|
|
(_STATUS_CHECKED_OUT, device_id),
|
|
)
|
|
conn.commit()
|
|
|
|
with conn.cursor() as cur:
|
|
row = _fetch_device(cur, device_id)
|
|
result = _device_to_dict(cur, row)
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(result, message='Device checked out', http_code=201)
|
|
|
|
|
|
@usb_bp.route('/<device_id>/checkin', methods=['POST'])
|
|
@require_permission('usb.create')
|
|
def checkin_device(device_id):
|
|
"""Check a device back in from a badge."""
|
|
if _usb_selfhosted():
|
|
return selfhosted.checkin_device(device_id, request.get_json() or {})
|
|
data = request.get_json() or {}
|
|
|
|
badge = (data.get('badge') or '').strip()
|
|
if not badge:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'badge is required')
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
device = _fetch_device(cur, device_id)
|
|
if not device:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
f'USB device {device_id} not found',
|
|
http_code=404,
|
|
)
|
|
if device.get('status') != _STATUS_CHECKED_OUT:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Device is not currently checked out',
|
|
http_code=400,
|
|
)
|
|
|
|
name = _resolve_badge_name(cur, badge)
|
|
_ensure_user(cur, badge, name)
|
|
|
|
locker = data.get('locker_location')
|
|
log_locker = locker if locker is not None else device.get('locker_location')
|
|
sanitized = _flag(data.get('sanitized'))
|
|
scanned_viruses = _flag(data.get('scanned_viruses'))
|
|
|
|
cur.execute(
|
|
'INSERT INTO checkinoutlog '
|
|
'(badge_number, device_id, action, timestamp, scanned_viruses, '
|
|
'locker_location, sanitized) '
|
|
"VALUES (%s, %s, 'check-in', %s, %s, %s, %s)",
|
|
(badge, device_id, _now(), scanned_viruses, log_locker, sanitized),
|
|
)
|
|
|
|
if locker is not None:
|
|
cur.execute(
|
|
'UPDATE devices SET status = %s, locker_location = %s '
|
|
'WHERE device_id = %s',
|
|
(_STATUS_CHECKED_IN, locker, device_id),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
'UPDATE devices SET status = %s WHERE device_id = %s',
|
|
(_STATUS_CHECKED_IN, device_id),
|
|
)
|
|
conn.commit()
|
|
|
|
with conn.cursor() as cur:
|
|
row = _fetch_device(cur, device_id)
|
|
result = _device_to_dict(cur, row)
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(result, message='Device checked in')
|
|
|
|
|
|
# =============================================================================
|
|
# Check-in/out log queries
|
|
# =============================================================================
|
|
|
|
@usb_bp.route('/<device_id>/history', methods=['GET'])
|
|
@require_permission('usb.view')
|
|
def get_device_history(device_id):
|
|
"""Paginated check-in/out log for one device."""
|
|
if _usb_selfhosted():
|
|
return selfhosted.device_history(device_id, request)
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
'SELECT COUNT(*) AS total FROM checkinoutlog WHERE device_id = %s',
|
|
(device_id,),
|
|
)
|
|
total = cur.fetchone()['total']
|
|
|
|
cur.execute(
|
|
'SELECT ' + _LOG_COLS + ' FROM checkinoutlog WHERE device_id = %s '
|
|
'ORDER BY timestamp DESC, log_id DESC LIMIT %s OFFSET %s',
|
|
(device_id, per_page, (page - 1) * per_page),
|
|
)
|
|
rows = cur.fetchall()
|
|
data = [_log_to_dict(cur, row) for row in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@usb_bp.route('/checkouts', methods=['GET'])
|
|
@require_permission('usb.view')
|
|
def list_all_checkouts():
|
|
"""List check-out log rows.
|
|
|
|
Query parameters:
|
|
- active: true -> only rows whose device is currently checked out
|
|
- badge: filter by badge_number
|
|
"""
|
|
if _usb_selfhosted():
|
|
return selfhosted.list_checkouts(request, active_only=False)
|
|
page, per_page = get_pagination_params(request)
|
|
|
|
where = ["l.action = 'check-out'"]
|
|
params = []
|
|
|
|
if request.args.get('active', '').lower() == 'true':
|
|
where.append("d.status = 'checked-out'")
|
|
|
|
badge = request.args.get('badge', '').strip()
|
|
if badge:
|
|
where.append('l.badge_number = %s')
|
|
params.append(badge)
|
|
|
|
where_sql = ' WHERE ' + ' AND '.join(where)
|
|
base = (
|
|
'FROM checkinoutlog l JOIN devices d ON l.device_id = d.device_id'
|
|
+ where_sql
|
|
)
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT COUNT(*) AS total ' + base, params)
|
|
total = cur.fetchone()['total']
|
|
|
|
cur.execute(
|
|
'SELECT l.log_id, l.badge_number, l.device_id, l.action, '
|
|
'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, '
|
|
'd.status AS device_status ' + base
|
|
+ ' ORDER BY l.timestamp DESC, l.log_id DESC LIMIT %s OFFSET %s',
|
|
params + [per_page, (page - 1) * per_page],
|
|
)
|
|
rows = cur.fetchall()
|
|
data = [_log_to_dict(cur, row) for row in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
return paginated_response(data, page, per_page, total)
|
|
|
|
|
|
@usb_bp.route('/checkouts/active', methods=['GET'])
|
|
@require_permission('usb.view')
|
|
def list_active_checkouts():
|
|
"""List the latest check-out log row for every currently checked-out device.
|
|
|
|
Query parameters:
|
|
- badge: filter by badge_number
|
|
"""
|
|
if _usb_selfhosted():
|
|
return selfhosted.list_checkouts(request, active_only=True)
|
|
where_badge = ''
|
|
params = []
|
|
badge = request.args.get('badge', '').strip()
|
|
if badge:
|
|
where_badge = 'AND l.badge_number = %s '
|
|
params.append(badge)
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
'SELECT l.log_id, l.badge_number, l.device_id, l.action, '
|
|
'l.timestamp, l.scanned_viruses, l.locker_location, l.sanitized, '
|
|
'd.status AS device_status '
|
|
'FROM devices d '
|
|
'JOIN checkinoutlog l ON l.device_id = d.device_id '
|
|
"AND l.action = 'check-out' "
|
|
"WHERE d.status = 'checked-out' "
|
|
+ where_badge +
|
|
'AND l.log_id = ('
|
|
' SELECT MAX(l2.log_id) FROM checkinoutlog l2 '
|
|
" WHERE l2.device_id = d.device_id AND l2.action = 'check-out'"
|
|
') '
|
|
'ORDER BY l.timestamp DESC, l.log_id DESC',
|
|
params,
|
|
)
|
|
rows = cur.fetchall()
|
|
data = [_log_to_dict(cur, row) for row in rows]
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(data)
|
|
|
|
|
|
# =============================================================================
|
|
# Kiosk: UNAUTHENTICATED check-out / check-in
|
|
# =============================================================================
|
|
# The bar these have to meet, same as the parts kiosk: they can move a device
|
|
# between checked-out and checked-in and NOTHING else. No create, no edit, no
|
|
# retire, no browsing the fleet, no reading who holds what without presenting
|
|
# the device. Identity is the scanned badge, resolved server-side; the client
|
|
# never asserts who it is.
|
|
#
|
|
# Off unless a site turns it on. An unauthenticated write is a decision about
|
|
# the door, not about the person walking through it.
|
|
|
|
def _kiosk_setting(key, default=False):
|
|
row = Setting.query.filter_by(key=key).first()
|
|
if row is None or row.value is None or row.value == '':
|
|
return default
|
|
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
|
|
|
|
|
|
def _kiosk_enabled():
|
|
return _kiosk_setting('usb_kiosk_enabled', False)
|
|
|
|
|
|
# The device token also travels as a cookie, set by the setup exchange below,
|
|
# so it never has to sit in a URL to reach a screen.
|
|
KIOSK_TOKEN_COOKIE = 'usbkiosktoken'
|
|
|
|
# Where this site's reverse proxies are, as CIDRs. NOT a source of addresses -
|
|
# see the block below - but a subtraction from the allowlist: a request wearing
|
|
# one of these addresses is infrastructure talking, never a caller.
|
|
KIOSK_TRUSTED_PROXIES = 'usb_kiosk_trusted_proxies'
|
|
|
|
# Addresses that are never a kiosk client at any site, whatever was typed into
|
|
# the allowlist. Loopback is the shipped Windows stack with no forwarding header
|
|
# at all: HttpPlatformHandler connects from 127.0.0.1, waitress has nothing to
|
|
# promote, and every caller then arrives wearing the app server's own address.
|
|
KIOSK_NEVER_A_CLIENT = '127.0.0.0/8, ::1/128'
|
|
|
|
|
|
def _kiosk_token_presented():
|
|
"""True when the caller presents the configured device token.
|
|
|
|
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
|
|
a token served inside the page would be readable by anyone who could load
|
|
it.
|
|
|
|
Two carriers, one value. A screen provisioned the old way keeps it in
|
|
localStorage and sends X-Kiosk-Token; a screen provisioned through
|
|
kiosk_setup holds it in an HttpOnly cookie that the browser sends on its
|
|
own. Accepting both is what lets the URL-borne token go away without
|
|
reprovisioning every screen already on the floor.
|
|
"""
|
|
expected = (Setting.get('usb_kiosk_token') or '').strip()
|
|
if not expected:
|
|
return False
|
|
presented = ((request.headers.get('X-Kiosk-Token') or '').strip()
|
|
or (request.cookies.get(KIOSK_TOKEN_COOKIE) or '').strip())
|
|
if not presented:
|
|
return False
|
|
# compare_digest, not ==: == leaks the token's length and matching prefix
|
|
# through timing.
|
|
return hmac.compare_digest(presented, expected)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The address a caller is admitted on, traced through the stack that ships
|
|
# ---------------------------------------------------------------------------
|
|
# IIS -> HttpPlatformHandler -> waitress on 127.0.0.1 -> this app, and waitress
|
|
# is launched with --trusted-proxy=127.0.0.1
|
|
# --trusted-proxy-headers=x-forwarded-for --trusted-proxy-count=1
|
|
# (deploy/windows/web.config). HttpPlatformHandler ALWAYS connects over
|
|
# loopback, so waitress always sees its configured trusted proxy as the peer: it
|
|
# takes the LAST X-Forwarded-For hop, writes it into REMOTE_ADDR, and truncates
|
|
# the environ header to that one hop, all before Flask is entered.
|
|
#
|
|
# So request.remote_addr IS the forwarded address by the time anything here
|
|
# runs, and X-Forwarded-For carries a single entry equal to it. That is why the
|
|
# version of this code that walked the header could not work on the Windows
|
|
# deployment: it looked for the peer inside usb_kiosk_trusted_proxies before it
|
|
# would read the header, and the peer is never the proxy any more - waitress
|
|
# already replaced it with whatever the proxy forwarded. The branch could not
|
|
# fire, and the parsing under it was inert.
|
|
#
|
|
# THIS APP TAKES THE WSGI LAYER'S WORD, and the app-level parsing is gone. Not
|
|
# because that word is trustworthy in itself, but because Python is the wrong
|
|
# place to adjudicate it: the proxy's own address is destroyed before Flask is
|
|
# reached, so nothing here can tell a hop IIS wrote from one a caller sent.
|
|
# Whether the address is real is settled in front of waitress, and only there:
|
|
# -ClientIpSource direct: the IIS rewrite rule OVERWRITES X-Forwarded-For with
|
|
# IIS's own REMOTE_ADDR, so a caller-sent header is destroyed on arrival.
|
|
# -ClientIpSource proxy: the reverse proxy APPENDS the caller, so the last hop
|
|
# is the address that proxy itself observed.
|
|
# Under either, a client-sent header cannot choose the value waitress promotes.
|
|
# The installer checks this in both modes and, when it does not hold, says to
|
|
# leave the kiosk allowlist empty (deploy/windows/installer/shopdb-install.ps1).
|
|
#
|
|
# Reading request.headers['X-Forwarded-For'] here would undo all of it: on a box
|
|
# whose front end does not overwrite, that header is the caller's to invent, and
|
|
# the kiosk would admit on a value the caller's network position does not
|
|
# support. So it is not read. Anywhere in this file.
|
|
|
|
|
|
def _kiosk_client_ip():
|
|
"""The caller address, as the layer in front of Flask resolved it."""
|
|
return client_ip(request)
|
|
|
|
|
|
def _kiosk_address_is_infrastructure(address):
|
|
"""True when this address belongs to the path, not to a caller.
|
|
|
|
The fail-closed half of network admission, and the half that was missing. A
|
|
reverse proxy normally sits ON the plant network a site enters in
|
|
usb_kiosk_allowed_cidrs. A request that arrives wearing the proxy's own
|
|
address - because the proxy forwarded nothing, or because nothing forwarded
|
|
at all - therefore MATCHED the allowlist and was admitted, while carrying no
|
|
evidence whatever about who sent it. That is the one case that has to
|
|
refuse.
|
|
|
|
Being inside the allowlist is not enough on its own: the address also has to
|
|
be one a client could plausibly have had. The previous version only looked
|
|
closed because the proxy in its test happened to sit outside the allowlist.
|
|
"""
|
|
if ip_in_cidrs(address, KIOSK_NEVER_A_CLIENT):
|
|
return True
|
|
return ip_in_cidrs(address, Setting.get(KIOSK_TRUSTED_PROXIES))
|
|
|
|
|
|
def _kiosk_ip_allowlisted():
|
|
"""True when the caller sits in the configured kiosk network allowlist.
|
|
|
|
Why this exists alongside the token: the token model assumes a handful of
|
|
fixed screens provisioned once. Most people reach this kiosk from their own
|
|
PC instead, and there is no login - so per-browser provisioning would mean
|
|
provisioning every PC on the floor, and losing it on every reimage or
|
|
cleared profile. Network position is what those callers actually have in
|
|
common.
|
|
|
|
Fail-closed behaviour is documented on ip_in_cidrs; where the caller address
|
|
comes from is on _kiosk_client_ip, and which addresses can never be a caller
|
|
at all is on _kiosk_address_is_infrastructure.
|
|
"""
|
|
address = _kiosk_client_ip()
|
|
if _kiosk_address_is_infrastructure(address):
|
|
return False
|
|
return ip_in_cidrs(address, Setting.get('usb_kiosk_allowed_cidrs'))
|
|
|
|
|
|
def _kiosk_open():
|
|
"""True when the kiosk is on for this site AND the caller is admitted.
|
|
|
|
The flag alone was the whole gate, and a flag is a decision, not a
|
|
credential: with the kiosk on, anyone who could reach the app could move a
|
|
device between checked-out and checked-in and have it recorded against a
|
|
badge they guessed. A badge is a short numeric SSO, so it is attribution,
|
|
never authorization - the same reasoning that closed the parts kiosk.
|
|
|
|
Two ways in, either sufficient, mirroring what the GE-Enforce client
|
|
endpoints already do:
|
|
1. the per-device token, for unattended screens by the cabinet, and
|
|
2. a source address inside usb_kiosk_allowed_cidrs, for the people who
|
|
open this page on their own PC and never log in.
|
|
|
|
Both are still gated on usb_kiosk_enabled, so the door stays shut until a
|
|
site opens it deliberately.
|
|
"""
|
|
if not _kiosk_enabled():
|
|
return False
|
|
return _kiosk_token_presented() or _kiosk_ip_allowlisted()
|
|
|
|
|
|
def _kiosk_disabled_response():
|
|
# 404, not 403: when the kiosk is off these routes should not read as
|
|
# "there is a door here and you may not use it".
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
|
|
|
|
|
def _kiosk_badge(data):
|
|
"""The scanned badge, or an error response. Never trusts a client name."""
|
|
badge = (data.get('badge') or '').strip()
|
|
if not badge:
|
|
return None, error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Scan your badge')
|
|
return badge, None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# What a site may store in a kiosk CIDR list
|
|
# ---------------------------------------------------------------------------
|
|
# These two settings decide who may write without logging in, and the settings
|
|
# API stored whatever was typed: it checks valuetype and nothing else, and has
|
|
# no per-key hook a plugin can declare. So '0.0.0.0/0' saved cleanly and turned
|
|
# the kiosk into an open write for anything that could reach the server.
|
|
#
|
|
# Checked at the SAVE (see validate_kiosk_settings_write, registered by the
|
|
# plugin), because that is the only moment an administrator finds out. Refusing
|
|
# a range at admission time instead would look exactly like the setting quietly
|
|
# not working.
|
|
|
|
KIOSK_CIDR_KEYS = ('usb_kiosk_allowed_cidrs', 'usb_kiosk_trusted_proxies')
|
|
KIOSK_WIDE_OVERRIDE = 'usb_kiosk_allow_wide_cidrs'
|
|
# A /16 is already 65,536 addresses. Anything wider is a claim about the site
|
|
# that a site should have to make on purpose, not a typo that widens a door.
|
|
KIOSK_MIN_PREFIX = {4: 16, 6: 48}
|
|
|
|
|
|
def kiosk_cidr_problem(raw, allowwide=False):
|
|
"""Why this CIDR list may not be stored, or None when it may.
|
|
|
|
The message names the entry and says what it would mean, because the person
|
|
reading it is fixing a range on a settings page, not debugging a validator.
|
|
"""
|
|
for part in (raw or '').split(','):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
try:
|
|
network = ipaddress.ip_network(part, strict=False)
|
|
except ValueError:
|
|
return ('"{0}" is not a network. Use address/prefix, for example '
|
|
'192.0.2.0/24.'.format(part))
|
|
if network.prefixlen == 0:
|
|
return ('"{0}" is every address there is. The kiosk writes without '
|
|
'a login, so this would let anything that can reach the '
|
|
'server move a drive against a badge it guessed. Name the '
|
|
'shop-floor ranges instead.'.format(part))
|
|
floor = KIOSK_MIN_PREFIX[network.version]
|
|
if network.prefixlen < floor and not allowwide:
|
|
return ('"{0}" covers {1} addresses, which is wider than a site '
|
|
'range (/{2} or narrower). Everyone inside it is admitted '
|
|
'with no token and no login. Narrow it, or turn on "{3}" '
|
|
'if this site really is one flat network that '
|
|
'wide.'.format(part, network.num_addresses, floor,
|
|
KIOSK_WIDE_OVERRIDE))
|
|
return None
|
|
|
|
|
|
# Both settings routes are gated on this one permission. Named here because the
|
|
# guard below has to apply the SAME gate the view would, and a guard that
|
|
# guessed a weaker one would be the hole it is meant to close.
|
|
SETTINGS_WRITE_PERMISSION = 'settings.edit'
|
|
|
|
|
|
def _settings_write_refusal():
|
|
"""The view's own 401/403 for this caller, or None when it may write.
|
|
|
|
A before_request runs ahead of the view, so it runs ahead of
|
|
@require_permission too. That ordering leaked the validation rules: an
|
|
anonymous PUT of '0.0.0.0/0' was answered 400 with the reason, while an
|
|
anonymous PUT of a sane range fell through to the view and was answered 401.
|
|
The difference is an oracle - anyone who could reach the app could walk the
|
|
allowlist rules without ever holding an account.
|
|
|
|
So authentication decides first, by running the same decorator the view
|
|
carries rather than a local imitation of it. Guest grants, scoped personal
|
|
tokens and the admin bypass therefore behave identically here and there, and
|
|
the response an unauthorized caller gets is the one it would have got if
|
|
this guard did not exist.
|
|
"""
|
|
@require_permission(SETTINGS_WRITE_PERMISSION)
|
|
def permitted():
|
|
return None
|
|
|
|
return permitted()
|
|
|
|
|
|
def validate_kiosk_settings_write():
|
|
"""before_request guard on the core settings save, for the kiosk CIDR keys.
|
|
|
|
Registered app-wide by the plugin (init_app): the keys belong to this
|
|
plugin, so this plugin is what knows a bad value, and returning a response
|
|
from before_request answers the save without performing it.
|
|
"""
|
|
if request.endpoint not in ('settings.update_setting',
|
|
'settings.create_setting'):
|
|
return None
|
|
data = request.get_json(silent=True) or {}
|
|
if request.endpoint == 'settings.update_setting':
|
|
key = (request.view_args or {}).get('key')
|
|
else:
|
|
key = data.get('key')
|
|
if key not in KIOSK_CIDR_KEYS or 'value' not in data:
|
|
return None
|
|
refusal = _settings_write_refusal()
|
|
if refusal is not None:
|
|
return refusal
|
|
# _kiosk_setting, not bool(Setting.get(...)): a row stored as a string holds
|
|
# the word 'false', and bool('false') is True - which would turn the
|
|
# override on for a site that had just turned it off.
|
|
problem = kiosk_cidr_problem(str(data.get('value') or ''),
|
|
_kiosk_setting(KIOSK_WIDE_OVERRIDE, False))
|
|
if problem:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Badge lookups: a bounded number of misses per caller
|
|
# ---------------------------------------------------------------------------
|
|
# Every kiosk call that takes a badge answers with the employee's NAME, which is
|
|
# what makes the kiosk usable and also makes it a lookup table: a badge is a
|
|
# short number, so a caller who is admitted can walk it. Admission used to mean
|
|
# one screen by the cabinet; with the network allowlist it can mean a whole
|
|
# VLAN, and that changes what the lookup is worth to somebody with a loop.
|
|
#
|
|
# Only MISSES are counted. An ordinary day at the cabinet produces none - a miss
|
|
# is a scan of something this site has never heard of - so the budget can sit
|
|
# well above a bad scanning day and still stop enumeration inside a minute.
|
|
# Same fixed-window cache shape as the login limiter in shopdb.core.api.auth:
|
|
# under SimpleCache the counter is per-process, which is defence in depth rather
|
|
# than a hard quota.
|
|
|
|
BADGE_MISS_MAX = 15
|
|
BADGE_MISS_WINDOW_SECONDS = 600
|
|
|
|
# The two of those an operator would ever reach for, as settings. They were
|
|
# app-config only, which on an air-gapped server means hand-editing
|
|
# instance/config.py and restarting IIS - so in practice the limiter had no off
|
|
# switch and no dial at all, and the one lever anybody had was to turn the whole
|
|
# kiosk off. Both default to exactly what the constants above did.
|
|
#
|
|
# The WINDOW deliberately stays app-config. It is the shape of the limiter, not
|
|
# a number a site tunes; a budget expressed per ten minutes is the thing being
|
|
# decided, and offering two interacting dials invites a combination nobody
|
|
# meant.
|
|
KIOSK_BADGE_LIMIT_ENABLED = 'usb_kiosk_badge_limit_enabled'
|
|
KIOSK_BADGE_MISS_MAX = 'usb_kiosk_badge_miss_max'
|
|
|
|
|
|
def _kiosk_int_setting(key, default):
|
|
"""A positive whole number from a setting, else default.
|
|
|
|
A settings page stores text, so a blank, a word or a pasted range can all
|
|
arrive here. Any of those has to land on the shipped budget rather than on
|
|
zero, because zero is not "no limit" in this counter - it refuses every
|
|
badge lookup the site has from the first scan of the window.
|
|
"""
|
|
row = Setting.query.filter_by(key=key).first()
|
|
raw = row.value if row is not None and row.value is not None else ''
|
|
try:
|
|
value = int(str(raw).strip())
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return value if value > 0 else default
|
|
|
|
|
|
def _badge_miss_budget():
|
|
# Config first so instance/config.py and the test fixtures keep overriding,
|
|
# then the setting, then the shipped number.
|
|
maxmisses = current_app.config.get('USB_KIOSK_BADGE_MISS_MAX')
|
|
if maxmisses is None:
|
|
maxmisses = _kiosk_int_setting(KIOSK_BADGE_MISS_MAX, BADGE_MISS_MAX)
|
|
return (current_app.config.get('USB_KIOSK_BADGE_WINDOW_SECONDS',
|
|
BADGE_MISS_WINDOW_SECONDS),
|
|
maxmisses)
|
|
|
|
|
|
def _badge_miss_key(window):
|
|
bucket = int(time.time() // window) if window > 0 else 0
|
|
return 'usbkioskbadgemiss:{0}:{1}'.format(
|
|
_kiosk_client_ip() or 'unknown', bucket)
|
|
|
|
|
|
def _badge_limit_enabled():
|
|
"""On everywhere but a test run, where the shared cache would bleed.
|
|
|
|
The counter lives in the process-wide cache, which outlives a test and is
|
|
shared by whatever else lands on the same xdist worker - so misses spent in
|
|
one module surface as an unrelated 429 in another, and which modules share a
|
|
worker moves around. AUTH_RATELIMIT_ENABLED and the endpoints enrollment
|
|
limiter are switched off in TestingConfig for exactly that reason; this one
|
|
is younger than that file and defaults itself the same way.
|
|
|
|
Order: an explicit app config value (instance/config.py, and the fixtures in
|
|
the test suite) wins, then TESTING, then the site setting, which ships on.
|
|
A site turning this off is a real decision - the kiosk answers with an
|
|
employee NAME, so an unbounded badge lookup is a directory anyone admitted
|
|
can walk - which is why it is reachable rather than buried in a config file
|
|
nobody on an air-gapped box can edit without a maintenance window.
|
|
"""
|
|
override = current_app.config.get('USB_KIOSK_BADGE_LIMIT_ENABLED')
|
|
if override is not None:
|
|
return bool(override)
|
|
if current_app.config.get('TESTING', False):
|
|
return False
|
|
return _kiosk_setting(KIOSK_BADGE_LIMIT_ENABLED, True)
|
|
|
|
|
|
def badge_lookups_exhausted():
|
|
"""True when this caller has spent its budget of unrecognised badges."""
|
|
if not _badge_limit_enabled():
|
|
return False
|
|
window, maxmisses = _badge_miss_budget()
|
|
return (cache.get(_badge_miss_key(window)) or 0) >= maxmisses
|
|
|
|
|
|
def record_badge_miss():
|
|
"""Count one unrecognised scan against this caller.
|
|
|
|
Called from both directory modes - the raw-SQL path below and the
|
|
self-hosted one in selfhosted.py - at the point where a scanned badge
|
|
resolves to nobody. Holder-name lookups do not come through here: those are
|
|
the kiosk naming a drive's holder, not a caller guessing.
|
|
"""
|
|
if not _badge_limit_enabled():
|
|
return
|
|
window, maxmisses = _badge_miss_budget()
|
|
key = _badge_miss_key(window)
|
|
count = (cache.get(key) or 0) + 1
|
|
cache.set(key, count, timeout=window)
|
|
if count == maxmisses:
|
|
logger.warning('usb kiosk: %s unrecognised badges from %s within %ss; '
|
|
'refusing badge lookups for the rest of the window',
|
|
count, _kiosk_client_ip(), window)
|
|
|
|
|
|
def _badge_lookups_refused():
|
|
return error_response(
|
|
'RATE_LIMITED',
|
|
'Too many badges this screen does not recognise. Wait a few minutes '
|
|
'and try again, or see IT.', http_code=429)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provisioning a screen without putting the token in a URL
|
|
# ---------------------------------------------------------------------------
|
|
# A query string is the one place a credential is guaranteed to be copied:
|
|
# browser history, the Referer on the next request, the IIS access log, whatever
|
|
# the proxy keeps. So the link an administrator carries to a screen holds a code
|
|
# that is good once and briefly, and the token itself comes back in a Set-Cookie
|
|
# that no log line carries.
|
|
#
|
|
# Only the code's DIGEST is stored, so the pending row is not itself a
|
|
# credential; the expiry rides with it.
|
|
|
|
KIOSK_SETUP_CODE = 'usb_kiosk_setup_code'
|
|
# Long enough to walk from a desk to the cabinet, short enough that a link left
|
|
# in a history or a log is already dead when somebody reads it back.
|
|
KIOSK_SETUP_TTL_SECONDS = 30 * 60
|
|
# The screen is provisioned once and must not quietly fall out of the kiosk
|
|
# months later, which is the failure this cookie has to avoid above all.
|
|
KIOSK_COOKIE_MAX_AGE = 5 * 365 * 24 * 60 * 60
|
|
|
|
|
|
def _issue_kiosk_setup_code():
|
|
"""Mint a single-use code, store its digest and expiry, return the code."""
|
|
code = secrets.token_urlsafe(24)
|
|
digest = hashlib.sha256(code.encode()).hexdigest()
|
|
expiry = int(time.time()) + KIOSK_SETUP_TTL_SECONDS
|
|
Setting.set(KIOSK_SETUP_CODE, '{0}|{1}'.format(digest, expiry),
|
|
valuetype='string', category='usb',
|
|
description='Digest and expiry of the pending kiosk setup '
|
|
'code. Written and cleared by the setup link; not '
|
|
'edited by hand.')
|
|
return code
|
|
|
|
|
|
def _redeem_kiosk_setup_code(code):
|
|
"""True exactly once per issued code, and never once it has expired.
|
|
|
|
Cleared on ANY attempt. A code is worth one screen; burning it on a wrong
|
|
guess costs an administrator one click and leaves a prober nothing to
|
|
retry against.
|
|
"""
|
|
stored = (Setting.get(KIOSK_SETUP_CODE) or '').strip()
|
|
if not stored or '|' not in stored:
|
|
return False
|
|
Setting.set(KIOSK_SETUP_CODE, '', valuetype='string', category='usb')
|
|
digest, _, expiry = stored.partition('|')
|
|
try:
|
|
if int(expiry) < int(time.time()):
|
|
return False
|
|
except ValueError:
|
|
return False
|
|
return hmac.compare_digest(
|
|
digest, hashlib.sha256((code or '').encode()).hexdigest())
|
|
|
|
|
|
@usb_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
|
@require_permission('settings.edit')
|
|
def kiosk_provisioning_link():
|
|
"""The URL that provisions a screen, token included, for an administrator.
|
|
|
|
WHY THIS EXISTS. The settings API masks every key whose name contains
|
|
'token', so the settings page shows the kiosk token as '********'. The real
|
|
value is visible for exactly one moment - after Generate, before Save - and
|
|
an administrator who copies the field at any other time provisions a screen
|
|
with the literal string '********'. Nothing says so: the kiosk answers 404
|
|
the same way it answers a caller it has never heard of, because the route
|
|
deliberately does not advertise itself. That has cost real time.
|
|
|
|
So the copy step stops depending on catching that moment. The mask stays -
|
|
it is right that a token does not travel in an ordinary settings list - and
|
|
this named, permission-gated route hands over a way in to somebody who is
|
|
already allowed to set it.
|
|
|
|
WHAT IT HANDS OVER IS NOT THE TOKEN. The link used to be
|
|
'/usb-kiosk?token=<value>', which put the credential in a query string: the
|
|
address bar, the browser history, a Referer, the IIS log and anything in
|
|
between. It is now a single-use code that kiosk_setup trades for the token
|
|
over a Set-Cookie. One click here, one open on the screen - the operator
|
|
flow is the same.
|
|
|
|
Returns a relative URL. The caller's own origin is the right base for a
|
|
screen being provisioned from that same browser, and the server has no
|
|
reliable idea what hostname the operator reached it by.
|
|
"""
|
|
token = (Setting.get('usb_kiosk_token') or '').strip()
|
|
if not token:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'No kiosk token set. Generate one first.')
|
|
code = _issue_kiosk_setup_code()
|
|
# Issuing a way to claim the token is worth a line in the audit log: the
|
|
# trail should say who asked, and when the clock on it started.
|
|
audit_log('viewed', 'Setting', entityname='usb_kiosk_token',
|
|
details='kiosk setup link issued')
|
|
db.session.commit()
|
|
return success_response({
|
|
'url': '/api/usb/kiosk/setup?code=' + code,
|
|
'expiresinminutes': KIOSK_SETUP_TTL_SECONDS // 60,
|
|
})
|
|
|
|
|
|
@usb_bp.route('/kiosk/setup', methods=['GET'])
|
|
def kiosk_setup():
|
|
"""Trade a setup code for this browser's kiosk cookie, then open the kiosk.
|
|
|
|
Opened once on the screen being provisioned. The code is spent here, the
|
|
token travels back in a Set-Cookie rather than a URL, and the browser is
|
|
sent on to the kiosk page with nothing left in the address bar.
|
|
|
|
HttpOnly, so the page's own JavaScript cannot read the token back out and
|
|
put it somewhere that does get logged. SameSite=Lax, so another site cannot
|
|
make this browser POST at the kiosk routes with it. Secure only over https:
|
|
setting it unconditionally would drop the cookie on the plain-http sites
|
|
this is deployed on and provision nothing.
|
|
|
|
Not gated on usb_kiosk_enabled: a site sets its screens up before opening
|
|
the door, and a kiosk that is off refuses every call anyway.
|
|
"""
|
|
token = (Setting.get('usb_kiosk_token') or '').strip()
|
|
if not token:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'No kiosk token set. Generate one first.')
|
|
if not _redeem_kiosk_setup_code(request.args.get('code')):
|
|
# 410, not 404: this one is read by an operator standing at a screen,
|
|
# and "gone" plus what to do next is the difference between a working
|
|
# kiosk and a support call.
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
'This setup link has already been used or has expired. Generate a '
|
|
'new one under Settings > USB.', http_code=410)
|
|
audit_log('viewed', 'Setting', entityname='usb_kiosk_token',
|
|
details='kiosk setup link redeemed')
|
|
db.session.commit()
|
|
|
|
mount = request.script_root or ''
|
|
response = redirect(mount + '/usb-kiosk')
|
|
response.set_cookie(KIOSK_TOKEN_COOKIE, token,
|
|
max_age=KIOSK_COOKIE_MAX_AGE, httponly=True,
|
|
samesite='Lax', secure=request.is_secure,
|
|
path=mount + '/')
|
|
# This response carries the token. Nothing anywhere may keep a copy.
|
|
response.headers['Cache-Control'] = 'no-store'
|
|
return response
|
|
|
|
|
|
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
|
|
def kiosk_device(device_id):
|
|
"""What the kiosk shows after a device is scanned.
|
|
|
|
Deliberately narrow: the description, whether it is out, and the NAME of
|
|
whoever holds it - which the return-for-someone-else confirm has to show, or
|
|
the person standing there cannot tell whether they are doing the right
|
|
thing. No badge number, no history, no contact details.
|
|
"""
|
|
if not _kiosk_open():
|
|
return _kiosk_disabled_response()
|
|
if _usb_selfhosted():
|
|
return selfhosted.kiosk_device(device_id)
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
device = _fetch_device(cur, device_id)
|
|
if not device:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'No device matches that code',
|
|
http_code=404)
|
|
holder = ''
|
|
if device.get('status') == _STATUS_CHECKED_OUT:
|
|
holder = _kiosk_holder_name(cur, device_id)
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response({
|
|
'device_id': device.get('device_id'),
|
|
'device_desc': device.get('device_desc'),
|
|
'status': device.get('status'),
|
|
'locker_location': device.get('locker_location'),
|
|
'holdername': holder,
|
|
})
|
|
|
|
|
|
def _kiosk_holder_badge(cur, device_id):
|
|
"""Badge on the most recent check-out for this device, or ''."""
|
|
cur.execute(
|
|
'SELECT badge_number FROM checkinoutlog '
|
|
"WHERE device_id = %s AND action = 'check-out' "
|
|
'ORDER BY timestamp DESC, log_id DESC LIMIT 1',
|
|
(device_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
return (row.get('badge_number') or '').strip() if row else ''
|
|
|
|
|
|
def _kiosk_holder_name(cur, device_id):
|
|
badge = _kiosk_holder_badge(cur, device_id)
|
|
return _resolve_badge_name(cur, badge) if badge else ''
|
|
|
|
|
|
@usb_bp.route('/kiosk/session', methods=['POST'])
|
|
def kiosk_session():
|
|
"""What one badge may do right now. Body: {badge}.
|
|
|
|
The drives here carry no barcode, so the kiosk cannot ask somebody to
|
|
present one - it has to offer a LIST. That is a real widening: before, you
|
|
had to already hold a drive to learn anything. So the list is not anonymous.
|
|
It costs a badge that resolves to a real person, which is the same bar
|
|
check-out itself has to clear, and it is a POST so the badge does not end up
|
|
in a proxy access log the way a query string would.
|
|
|
|
Returns the caller's own drives (to return), what is free (to take), and a
|
|
COUNT of everything else - names for those are a separate, deliberate step,
|
|
so a passer-by does not read the whole cabinet's holders off a wall screen.
|
|
"""
|
|
if not _kiosk_open():
|
|
return _kiosk_disabled_response()
|
|
if badge_lookups_exhausted():
|
|
return _badge_lookups_refused()
|
|
data = request.get_json() or {}
|
|
if _usb_selfhosted():
|
|
return selfhosted.kiosk_session(data)
|
|
|
|
badge, failure = _kiosk_badge(data)
|
|
if failure:
|
|
return failure
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
name = _resolve_badge_name(cur, badge)
|
|
if not name:
|
|
record_badge_miss()
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Badge not recognized - see IT',
|
|
http_code=422)
|
|
|
|
cur.execute(
|
|
'SELECT ' + _DEVICE_COLS + ' FROM devices '
|
|
'WHERE status IN (%s, %s) ORDER BY device_id',
|
|
(_STATUS_CHECKED_IN, _STATUS_CHECKED_OUT),
|
|
)
|
|
rows = cur.fetchall() or []
|
|
|
|
yours, available, others = [], [], 0
|
|
for row in rows:
|
|
entry = {
|
|
'device_id': row.get('device_id'),
|
|
'device_desc': row.get('device_desc'),
|
|
'locker_location': row.get('locker_location'),
|
|
}
|
|
if row.get('status') == _STATUS_CHECKED_IN:
|
|
available.append(entry)
|
|
elif _kiosk_holder_badge(cur, row.get('device_id')) == badge:
|
|
yours.append(entry)
|
|
else:
|
|
others += 1
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response({'name': name, 'yours': yours,
|
|
'available': available, 'otherscount': others})
|
|
|
|
|
|
@usb_bp.route('/kiosk/others', methods=['POST'])
|
|
def kiosk_others():
|
|
"""Drives checked out to OTHER people, with holder names. Body: {badge}.
|
|
|
|
Behind its own tap, and only when returning for somebody else is allowed at
|
|
all. Somebody has to be able to put back a drive left on a bench by a person
|
|
who is on leave - but the names that makes visible should not be the first
|
|
thing on a shopfloor screen.
|
|
"""
|
|
if not _kiosk_open():
|
|
return _kiosk_disabled_response()
|
|
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
|
return _kiosk_disabled_response()
|
|
if badge_lookups_exhausted():
|
|
return _badge_lookups_refused()
|
|
data = request.get_json() or {}
|
|
if _usb_selfhosted():
|
|
return selfhosted.kiosk_others(data)
|
|
|
|
badge, failure = _kiosk_badge(data)
|
|
if failure:
|
|
return failure
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
if not _resolve_badge_name(cur, badge):
|
|
record_badge_miss()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Badge not recognized - see IT',
|
|
http_code=422)
|
|
cur.execute(
|
|
'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE status = %s '
|
|
'ORDER BY device_id',
|
|
(_STATUS_CHECKED_OUT,),
|
|
)
|
|
out = []
|
|
for row in cur.fetchall() or []:
|
|
holderbadge = _kiosk_holder_badge(cur, row.get('device_id'))
|
|
if holderbadge == badge:
|
|
continue
|
|
out.append({
|
|
'device_id': row.get('device_id'),
|
|
'device_desc': row.get('device_desc'),
|
|
'locker_location': row.get('locker_location'),
|
|
'holdername': _resolve_badge_name(cur, holderbadge),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response(out)
|
|
|
|
|
|
@usb_bp.route('/kiosk/checkout', methods=['POST'])
|
|
def kiosk_checkout():
|
|
"""Check a device out at the kiosk. Body: {device_id, badge}."""
|
|
if not _kiosk_open():
|
|
return _kiosk_disabled_response()
|
|
if badge_lookups_exhausted():
|
|
return _badge_lookups_refused()
|
|
data = request.get_json() or {}
|
|
if _usb_selfhosted():
|
|
return selfhosted.kiosk_checkout(data)
|
|
|
|
device_id = (data.get('device_id') or '').strip()
|
|
badge, failure = _kiosk_badge(data)
|
|
if failure:
|
|
return failure
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
device = _fetch_device(cur, device_id)
|
|
if not device:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'No device matches that code',
|
|
http_code=404)
|
|
if device.get('status') == _STATUS_RETIRED:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'That device is retired - see IT')
|
|
if device.get('status') == _STATUS_CHECKED_OUT:
|
|
holder = _kiosk_holder_name(cur, device_id)
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'That device is already checked out{}'.format(
|
|
' to {}'.format(holder) if holder else ''),
|
|
http_code=409)
|
|
|
|
# An unresolvable badge is REFUSED here, unlike the signed-in route
|
|
# which creates the user row. A checkout nobody can trace to a
|
|
# person is the one thing this tracking exists to prevent, and at a
|
|
# kiosk there is no signed-in operator to vouch for it.
|
|
name = _resolve_badge_name(cur, badge)
|
|
if not name:
|
|
record_badge_miss()
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Badge not recognized - see IT to check this device out',
|
|
http_code=422)
|
|
|
|
# Claim the device FIRST, and let the status transition be the
|
|
# guard: the read-then-write above cannot stop two scans of the
|
|
# same drive that interleave, because both read 'available'. With
|
|
# the status in the WHERE clause the second UPDATE matches no row
|
|
# and there is no log entry to undo.
|
|
cur.execute(
|
|
'UPDATE devices SET status = %s '
|
|
'WHERE device_id = %s AND status <> %s',
|
|
(_STATUS_CHECKED_OUT, device_id, _STATUS_CHECKED_OUT),
|
|
)
|
|
if not cur.rowcount:
|
|
holder = _kiosk_holder_name(cur, device_id)
|
|
conn.rollback()
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'That device is already checked out{}'.format(
|
|
' to {}'.format(holder) if holder else ''),
|
|
http_code=409)
|
|
cur.execute(
|
|
'INSERT INTO checkinoutlog '
|
|
'(badge_number, device_id, action, timestamp, locker_location) '
|
|
"VALUES (%s, %s, 'check-out', %s, %s)",
|
|
(badge, device_id, _now(), device.get('locker_location')),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response({'device_id': device_id, 'holdername': name},
|
|
message='{} checked out to {}'.format(
|
|
device_id, name))
|
|
|
|
|
|
@usb_bp.route('/kiosk/checkin', methods=['POST'])
|
|
def kiosk_checkin():
|
|
"""Check a device in at the kiosk. Body: {device_id, badge, forsomeoneelse}.
|
|
|
|
Holder by default. A mismatch is refused with the holder's name and a flag
|
|
the screen uses to offer "returning it for them", because the alternative -
|
|
a drive whose owner is on leave that literally cannot be returned at the
|
|
kiosk - just moves the problem to somebody with a login.
|
|
|
|
The badge RECORDED is the one that was scanned: the log says who actually
|
|
handed the device back, which is the honest record and needs no change to a
|
|
schema this app does not own.
|
|
"""
|
|
if not _kiosk_open():
|
|
return _kiosk_disabled_response()
|
|
if badge_lookups_exhausted():
|
|
return _badge_lookups_refused()
|
|
data = request.get_json() or {}
|
|
if _usb_selfhosted():
|
|
return selfhosted.kiosk_checkin(data)
|
|
|
|
device_id = (data.get('device_id') or '').strip()
|
|
badge, failure = _kiosk_badge(data)
|
|
if failure:
|
|
return failure
|
|
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
device = _fetch_device(cur, device_id)
|
|
if not device:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'No device matches that code',
|
|
http_code=404)
|
|
if device.get('status') != _STATUS_CHECKED_OUT:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'That device is not checked out')
|
|
|
|
name = _resolve_badge_name(cur, badge)
|
|
if not name:
|
|
record_badge_miss()
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'Badge not recognized - see IT to return this device',
|
|
http_code=422)
|
|
|
|
holderbadge = _kiosk_holder_badge(cur, device_id)
|
|
if holderbadge and holderbadge != badge:
|
|
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
|
return error_response(
|
|
ErrorCodes.FORBIDDEN,
|
|
'That device is checked out to somebody else - they '
|
|
'need to return it, or see IT',
|
|
http_code=403)
|
|
if not forsomeoneelse:
|
|
holdername = _resolve_badge_name(cur, holderbadge)
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
'That device is checked out to {}. Confirm you are '
|
|
'returning it for them.'.format(holdername or 'somebody else'),
|
|
details={'needsconfirm': True,
|
|
'holdername': holdername},
|
|
http_code=409)
|
|
|
|
cur.execute(
|
|
'INSERT INTO checkinoutlog '
|
|
'(badge_number, device_id, action, timestamp, locker_location) '
|
|
"VALUES (%s, %s, 'check-in', %s, %s)",
|
|
(badge, device_id, _now(), device.get('locker_location')),
|
|
)
|
|
cur.execute(
|
|
'UPDATE devices SET status = %s WHERE device_id = %s',
|
|
(_STATUS_CHECKED_IN, device_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
return success_response({'device_id': device_id},
|
|
message='{} checked in'.format(device_id))
|
|
|
|
|
|
# =============================================================================
|
|
# Reports
|
|
# =============================================================================
|
|
# CMMC removable-media tracking is an audit obligation, and until now the only
|
|
# way to answer "who had which drive, when" was to read the check-in/out log
|
|
# page by page. This is the same question over a window: a day for "what
|
|
# happened this shift", a month or six for a periodic review, a year or all time
|
|
# for an audit.
|
|
|
|
# The windows the UI offers, in days. 0 means everything ever recorded.
|
|
AUDITPERIODS = {'day': 1, 'month': 30, 'sixmonths': 182, 'year': 365, 'all': 0}
|
|
|
|
|
|
def _auditdays():
|
|
"""Days of history to report. Accepts ?period= or a raw ?days=."""
|
|
period = (request.args.get('period') or '').strip().lower()
|
|
if period in AUDITPERIODS:
|
|
return AUDITPERIODS[period]
|
|
days = request.args.get('days', type=int)
|
|
return max(0, days) if days is not None else 30
|
|
|
|
|
|
def _auditcsv(rows, columns, filename):
|
|
"""CSV download; generate_csv is not on the contract surface."""
|
|
import csv
|
|
import io
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(columns)
|
|
for row in rows:
|
|
writer.writerow([row.get(column, '') for column in columns])
|
|
return Response(output.getvalue(), mimetype='text/csv',
|
|
headers={'Content-Disposition':
|
|
'attachment; filename={}'.format(filename)})
|
|
|
|
|
|
AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge',
|
|
'name', 'locker_location', 'sanitized', 'scanned_viruses']
|
|
|
|
|
|
|
|
@usb_bp.route('/reports/audit', methods=['GET'])
|
|
@require_permission('usb.view')
|
|
def report_audit():
|
|
"""Every check-out and check-in in the window, newest first.
|
|
|
|
Rows, not just totals: an audit asks who had a specific drive on a specific
|
|
day, and a count cannot answer that. Summary figures ride along for the
|
|
people who only want the shape of it.
|
|
"""
|
|
days = _auditdays()
|
|
if _usb_selfhosted():
|
|
rows = selfhosted.audit_rows(days)
|
|
return _auditresponse(rows, days)
|
|
|
|
cutoff = None
|
|
if days > 0:
|
|
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime(
|
|
'%Y-%m-%d %H:%M:%S')
|
|
|
|
conn = cmmc_usb_connection()
|
|
try:
|
|
with conn.cursor() as cur:
|
|
sql = ('SELECT l.timestamp, l.action, l.device_id, l.badge_number, '
|
|
'l.locker_location, l.sanitized, l.scanned_viruses, '
|
|
'd.device_desc, '
|
|
'u.first_name, u.last_name '
|
|
'FROM checkinoutlog l '
|
|
'LEFT JOIN devices d ON d.device_id = l.device_id '
|
|
'LEFT JOIN users u ON u.badge_number = l.badge_number ')
|
|
params = []
|
|
if cutoff:
|
|
sql += 'WHERE l.timestamp >= %s '
|
|
params.append(cutoff)
|
|
# A total order: timestamp alone ties for two events in the same
|
|
# second and the same query then returns them in a different order
|
|
# each run, which reads as the log being unstable.
|
|
sql += 'ORDER BY l.timestamp DESC, l.log_id DESC'
|
|
cur.execute(sql, tuple(params))
|
|
rows = []
|
|
for row in cur.fetchall() or []:
|
|
name = '{} {}'.format(row.get('first_name') or '',
|
|
row.get('last_name') or '').strip()
|
|
stamp = row.get('timestamp')
|
|
rows.append({
|
|
'timestamp': stamp.isoformat() if hasattr(stamp, 'isoformat')
|
|
else (stamp or ''),
|
|
'action': row.get('action') or '',
|
|
'device_id': row.get('device_id') or '',
|
|
'device_desc': row.get('device_desc') or '',
|
|
'badge': row.get('badge_number') or '',
|
|
'name': name,
|
|
'locker_location': row.get('locker_location') or '',
|
|
# Only a return can carry these: the wipe and the scan
|
|
# happen when the drive comes back, so a check-out row
|
|
# showing them would invent a fact.
|
|
'sanitized': (selfhosted.auditflag(row.get('sanitized'))
|
|
if row.get('action') == 'check-in' else ''),
|
|
'scanned_viruses': (selfhosted.auditflag(row.get('scanned_viruses'))
|
|
if row.get('action') == 'check-in' else ''),
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
return _auditresponse(rows, days)
|
|
|
|
|
|
def _auditresponse(rows, days):
|
|
"""One voice for both directory modes: same CSV, same JSON, same summary."""
|
|
if request.args.get('format') == 'csv':
|
|
return _auditcsv(rows, AUDITCOLUMNS, 'usb-audit.csv')
|
|
return success_response({
|
|
'columns': AUDITCOLUMNS,
|
|
'rows': rows,
|
|
'days': days,
|
|
'summary': _auditsummary(rows),
|
|
})
|
|
|
|
|
|
def _auditsummary(rows):
|
|
checkouts = sum(1 for row in rows if row['action'] == 'check-out')
|
|
return {
|
|
'events': len(rows),
|
|
'checkouts': checkouts,
|
|
'checkins': sum(1 for row in rows if row['action'] == 'check-in'),
|
|
'devices': len({row['device_id'] for row in rows if row['device_id']}),
|
|
'people': len({row['badge'] for row in rows if row['badge']}),
|
|
}
|