Security: mask settings secrets, remove hardcoded employee-DB creds

- GET /settings now masks password/token values (were returned in plaintext
  to anonymous callers); sending the mask back on update is a no-op so the
  real secret is never clobbered.
- Move the employee-directory DB credentials out of source into env-backed
  config (shopdb.utils.employee_db); employees + notification recognition use
  the shared helper. Employee lookups stop leaking exception strings.
- Fix low-supplies report using loc.location instead of loc.locationname.

Employee/notification read endpoints stay unauthenticated by design (public
shopfloor kiosk displays consume them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 08:42:16 -04:00
parent e631564377
commit b516b9b771
6 changed files with 203 additions and 182 deletions

View File

@@ -12,6 +12,7 @@ from shopdb.utils.responses import (
ErrorCodes ErrorCodes
) )
from shopdb.utils.pagination import get_pagination_params, paginate_query from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.employee_db import employee_connection
from ..models import Notification, NotificationType from ..models import Notification, NotificationType
@@ -522,11 +523,7 @@ def get_shopfloor_notifications():
# Try to get picture from wjf_employees # Try to get picture from wjf_employees
if n.employeesso and n.employeesso.isdigit(): if n.employeesso and n.employeesso.isdigit():
try: try:
import pymysql conn = employee_connection()
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),)) cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),))
emp = cur.fetchone() emp = cur.fetchone()
@@ -555,11 +552,7 @@ def get_shopfloor_notifications():
picture = None picture = None
if sso.isdigit(): if sso.isdigit():
try: try:
import pymysql conn = employee_connection()
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone() emp = cur.fetchone()
@@ -591,11 +584,7 @@ def get_shopfloor_notifications():
picture = None picture = None
if sso.isdigit(): if sso.isdigit():
try: try:
import pymysql conn = employee_connection()
conn = pymysql.connect(
host='localhost', user='root', password='rootpassword',
database='wjf_employees', cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),)) cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
emp = cur.fetchone() emp = cur.fetchone()

View File

@@ -565,7 +565,7 @@ def _get_low_supplies_data():
from shopdb.core.models import Location from shopdb.core.models import Location
loc = Location.query.get(asset.locationid) loc = Location.query.get(asset.locationid)
if loc: if loc:
location_name = loc.location location_name = loc.locationname
results.append({ results.append({
'printerid': printer.printerid, 'printerid': printer.printerid,

View File

@@ -57,6 +57,13 @@ class Config:
ZABBIX_URL = os.environ.get('ZABBIX_URL', '') ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '') ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
# Read-only HR/employee directory database (separate from the app DB).
# Credentials come from the environment; never hardcode them in source.
EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost')
EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', 'root')
EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', 'rootpassword')
EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees')
CACHE_TYPE = 'SimpleCache' CACHE_TYPE = 'SimpleCache'
CACHE_DEFAULT_TIMEOUT = 600 CACHE_DEFAULT_TIMEOUT = 600

View File

@@ -1,161 +1,144 @@
"""Employee lookup API endpoints.""" """Employee lookup API endpoints.
from flask import Blueprint, request These read from the separate employee directory DB (see shopdb.utils.employee_db).
from shopdb.utils.responses import success_response, error_response, ErrorCodes They are intentionally reachable by the unauthenticated shopfloor kiosk displays
(recognition wall), so they are not JWT-gated; keep them read-only and never
employees_bp = Blueprint('employees', __name__) return more than the directory fields below.
"""
@employees_bp.route('/search', methods=['GET']) import logging
def search_employees():
""" from flask import Blueprint, request
Search employees by name. from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.employee_db import employee_connection
Query parameters:
- q: Search query (searches first and last name) logger = logging.getLogger(__name__)
- limit: Max results (default 10)
""" employees_bp = Blueprint('employees', __name__)
query = request.args.get('q', '').strip()
limit = min(int(request.args.get('limit', 10)), 50) # Columns safe to expose to the directory/recognition UI
_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture'
if len(query) < 2:
return error_response(
ErrorCodes.VALIDATION_ERROR, @employees_bp.route('/search', methods=['GET'])
'Search query must be at least 2 characters' def search_employees():
) """
Search employees by name.
try:
import pymysql Query parameters:
conn = pymysql.connect( - q: Search query (searches first and last name)
host='localhost', - limit: Max results (default 10)
user='root', """
password='rootpassword', query = request.args.get('q', '').strip()
database='wjf_employees', limit = min(int(request.args.get('limit', 10)), 50)
cursorclass=pymysql.cursors.DictCursor
) if len(query) < 2:
return error_response(
with conn.cursor() as cur: ErrorCodes.VALIDATION_ERROR,
# Search by first name, last name, or SSO 'Search query must be at least 2 characters'
cur.execute(''' )
SELECT SSO, First_Name, Last_Name, Team, Role, Picture
FROM employees try:
WHERE First_Name LIKE %s conn = employee_connection()
OR Last_Name LIKE %s with conn.cursor() as cur:
OR CAST(SSO AS CHAR) LIKE %s cur.execute(f'''
ORDER BY Last_Name, First_Name SELECT {_FIELDS}
LIMIT %s FROM employees
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit)) WHERE First_Name LIKE %s
OR Last_Name LIKE %s
employees = cur.fetchall() OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name
conn.close() LIMIT %s
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
return success_response(employees) employees = cur.fetchall()
conn.close()
except Exception as e: return success_response(employees)
return error_response( except Exception:
ErrorCodes.DATABASE_ERROR, logger.exception('Employee search failed')
f'Employee lookup failed: {str(e)}', return error_response(
http_code=500 ErrorCodes.DATABASE_ERROR,
) 'Employee lookup failed',
http_code=500
)
@employees_bp.route('/lookup/<sso>', methods=['GET'])
def lookup_employee(sso):
"""Look up a single employee by SSO.""" @employees_bp.route('/lookup/<sso>', methods=['GET'])
if not sso.isdigit(): def lookup_employee(sso):
return error_response( """Look up a single employee by SSO."""
ErrorCodes.VALIDATION_ERROR, if not sso.isdigit():
'SSO must be numeric' return error_response(
) ErrorCodes.VALIDATION_ERROR,
'SSO must be numeric'
try: )
import pymysql
conn = pymysql.connect( try:
host='localhost', conn = employee_connection()
user='root', with conn.cursor() as cur:
password='rootpassword', cur.execute(
database='wjf_employees', f'SELECT {_FIELDS} FROM employees WHERE SSO = %s',
cursorclass=pymysql.cursors.DictCursor (int(sso),)
) )
employee = cur.fetchone()
with conn.cursor() as cur: conn.close()
cur.execute(
'SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s', if not employee:
(int(sso),) return error_response(
) ErrorCodes.NOT_FOUND,
employee = cur.fetchone() f'Employee with SSO {sso} not found',
http_code=404
conn.close() )
if not employee: return success_response(employee)
return error_response( except Exception:
ErrorCodes.NOT_FOUND, logger.exception('Employee lookup failed for SSO %s', sso)
f'Employee with SSO {sso} not found', return error_response(
http_code=404 ErrorCodes.DATABASE_ERROR,
) 'Employee lookup failed',
http_code=500
return success_response(employee) )
except Exception as e:
return error_response( @employees_bp.route('/lookup', methods=['GET'])
ErrorCodes.DATABASE_ERROR, def lookup_employees():
f'Employee lookup failed: {str(e)}', """
http_code=500 Look up multiple employees by SSO list.
)
Query parameters:
- sso: Comma-separated list of SSOs
@employees_bp.route('/lookup', methods=['GET']) """
def lookup_employees(): sso_list = request.args.get('sso', '')
""" ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()]
Look up multiple employees by SSO list.
if not ssos:
Query parameters: return error_response(
- sso: Comma-separated list of SSOs ErrorCodes.VALIDATION_ERROR,
""" 'At least one valid SSO is required'
sso_list = request.args.get('sso', '') )
ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()]
try:
if not ssos: conn = employee_connection()
return error_response( with conn.cursor() as cur:
ErrorCodes.VALIDATION_ERROR, placeholders = ','.join(['%s'] * len(ssos))
'At least one valid SSO is required' cur.execute(
) f'SELECT {_FIELDS} FROM employees WHERE SSO IN ({placeholders})',
[int(s) for s in ssos]
try: )
import pymysql employees = cur.fetchall()
conn = pymysql.connect( conn.close()
host='localhost',
user='root', names = ', '.join(
password='rootpassword', f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
database='wjf_employees', for e in employees
cursorclass=pymysql.cursors.DictCursor )
)
return success_response({
with conn.cursor() as cur: 'employees': employees,
placeholders = ','.join(['%s'] * len(ssos)) 'names': names
cur.execute( })
f'SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN ({placeholders})', except Exception:
[int(s) for s in ssos] logger.exception('Employee multi-lookup failed')
) return error_response(
employees = cur.fetchall() ErrorCodes.DATABASE_ERROR,
'Employee lookup failed',
conn.close() http_code=500
)
# Build name string
names = ', '.join(
f"{e['First_Name'].strip()} {e['Last_Name'].strip()}"
for e in employees
)
return success_response({
'employees': employees,
'names': names
})
except Exception as e:
return error_response(
ErrorCodes.DATABASE_ERROR,
f'Employee lookup failed: {str(e)}',
http_code=500
)

View File

@@ -13,6 +13,22 @@ settings_bp = Blueprint('settings', __name__)
SETTINGS_CACHE_KEY = 'system_settings' SETTINGS_CACHE_KEY = 'system_settings'
SETTINGS_CACHE_TTL = 300 # 5 minutes SETTINGS_CACHE_TTL = 300 # 5 minutes
# Placeholder returned in API responses for secret values so they are never
# exposed in plaintext. Sending it back on update is treated as "unchanged".
SECRET_MASK = '********'
def _is_secret(key: str) -> bool:
return 'password' in key or 'token' in key or 'secret' in key
def _serialize_setting(setting):
"""Serialize a setting, masking secret values so they never leave the API."""
data = setting.to_dict()
if _is_secret(setting.key):
data['value'] = SECRET_MASK if setting.value else ''
return data
def get_cached_settings(): def get_cached_settings():
"""Get all settings from cache or database.""" """Get all settings from cache or database."""
@@ -42,7 +58,7 @@ def list_settings():
query = query.filter_by(category=category) query = query.filter_by(category=category)
settings = query.order_by(Setting.category, Setting.key).all() settings = query.order_by(Setting.category, Setting.key).all()
return success_response([s.to_dict() for s in settings]) return success_response([_serialize_setting(s) for s in settings])
@settings_bp.route('/<key>', methods=['GET']) @settings_bp.route('/<key>', methods=['GET'])
@@ -54,7 +70,7 @@ def get_setting(key: str):
if not setting: if not setting:
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404) return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
return success_response(setting.to_dict()) return success_response(_serialize_setting(setting))
@settings_bp.route('/<key>', methods=['PUT']) @settings_bp.route('/<key>', methods=['PUT'])
@@ -74,15 +90,21 @@ def update_setting(key: str):
# Track old value for audit # Track old value for audit
old_value = setting.value old_value = setting.value
# Convert value to string for storage
value = data['value'] value = data['value']
# A secret submitted as the mask placeholder means "leave unchanged" - the
# client only ever received the mask, so don't overwrite the real secret.
if _is_secret(key) and value == SECRET_MASK:
return success_response(_serialize_setting(setting), message='Setting unchanged')
# Convert value to string for storage
if isinstance(value, bool): if isinstance(value, bool):
setting.value = 'true' if value else 'false' setting.value = 'true' if value else 'false'
else: else:
setting.value = str(value) if value is not None else None setting.value = str(value) if value is not None else None
# Audit log (mask sensitive values) # Audit log (mask sensitive values)
is_sensitive = 'password' in key or 'token' in key or 'secret' in key is_sensitive = _is_secret(key)
AuditLog.log('updated', 'Setting', entityname=key, changes={ AuditLog.log('updated', 'Setting', entityname=key, changes={
'value': { 'value': {
'old': '***' if is_sensitive else old_value, 'old': '***' if is_sensitive else old_value,
@@ -93,7 +115,7 @@ def update_setting(key: str):
db.session.commit() db.session.commit()
invalidate_settings_cache() invalidate_settings_cache()
return success_response(setting.to_dict(), message='Setting updated') return success_response(_serialize_setting(setting), message='Setting updated')
@settings_bp.route('', methods=['POST']) @settings_bp.route('', methods=['POST'])

View File

@@ -0,0 +1,20 @@
"""Connection helper for the read-only employee directory database.
Credentials are pulled from app config (env-backed, see Config.EMPLOYEE_DB_*),
never hardcoded. Used by the employee lookup API and the notification
recognition feature.
"""
import pymysql
from flask import current_app
def employee_connection():
"""Open a pymysql connection to the employee directory DB."""
return pymysql.connect(
host=current_app.config['EMPLOYEE_DB_HOST'],
user=current_app.config['EMPLOYEE_DB_USER'],
password=current_app.config['EMPLOYEE_DB_PASSWORD'],
database=current_app.config['EMPLOYEE_DB_NAME'],
cursorclass=pymysql.cursors.DictCursor,
)