Security closeout: settings public allowlist, audit.view gating, test-user guard

Settings exposure (review medium): GET /api/settings and /api/settings/<key>
now return the full table only to an authenticated principal. Unauthenticated
callers (kiosk dashboards, print pages, login screen, setup router) get just a
public allowlist - categories branding + map plus a named set (site_base_url,
facility_name, printer_hostname_template, contact_email_domain,
servicenow_enabled, setup_complete). A non-public single-key GET returns 404 so
existence is not confirmed. Secrets stay masked in both cases. Closes the
unauthenticated enumeration of smtp_host / employee_db_host / zabbix_url /
servicenow URLs. Allowlist mirrors the keys siteSettings.js + mapConfig.js +
setupState.js read before login.

audit.view (review low): the three audit-read routes (list, entity-history,
stats) were jwt_required only despite a defined-but-unwired audit.view
permission; now gated by it (seeded to admin), so a role-less member or unscoped
PAT can no longer read the cross-user audit trail.

flask seed test-user (review low): refuses outside DEBUG/TESTING - it creates
the well-known admin/admin123; production sites use `flask seed admin`.

Tests: unauthenticated allowlist + authed-full-masked + private-key-404, and
member-403 / admin-200 on audit routes. 336 authz tests pass; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 08:36:19 -04:00
parent cd353b6432
commit d49baeb5fa
5 changed files with 104 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required
from shopdb.core.models import AuditLog
from shopdb.utils.responses import success_response
from shopdb.utils.authz import require_permission
auditlogs_bp = Blueprint('auditlogs', __name__)
@@ -51,6 +52,7 @@ def _resolve_full_names(ssos):
@auditlogs_bp.route('', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def list_auditlogs():
"""
List audit logs with filtering and pagination.
@@ -134,6 +136,7 @@ def list_auditlogs():
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_entity_history(entitytype: str, entityid: int):
"""Get audit history for a specific entity."""
logs = AuditLog.query.filter_by(
@@ -146,6 +149,7 @@ def get_entity_history(entitytype: str, entityid: int):
@auditlogs_bp.route('/stats', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_stats():
"""Get audit log statistics."""
from sqlalchemy import func

View File

@@ -3,7 +3,7 @@
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask_jwt_extended import jwt_required
from flask_jwt_extended import jwt_required, get_jwt_identity
from werkzeug.utils import secure_filename
from shopdb.extensions import db, cache
@@ -45,6 +45,24 @@ SETTINGS_CACHE_TTL = 300 # 5 minutes
# exposed in plaintext. Sending it back on update is treated as "unchanged".
SECRET_MASK = '********'
# Public-settings allowlist. An UNAUTHENTICATED caller (kiosk dashboards, print
# pages, the login screen, the setup router) may read only these; everything
# else (smtp_host, employee_db_host, zabbix_url, servicenow URLs, ...) needs a
# valid token. Secrets stay masked in both cases. Keep in sync with the keys
# frontend/src/utils/siteSettings.js + mapConfig.js + setupState.js read before
# login. Whole categories that are purely presentation are allowed wholesale;
# the rest are named keys so a new integration key does not leak by default.
PUBLIC_SETTING_CATEGORIES = {'branding', 'map'}
PUBLIC_SETTING_KEYS = {
'site_base_url', 'facility_name', 'printer_hostname_template',
'contact_email_domain', 'servicenow_enabled', 'setup_complete',
}
def _is_public_setting(setting) -> bool:
return (setting.category in PUBLIC_SETTING_CATEGORIES
or setting.key in PUBLIC_SETTING_KEYS)
# Optional asset identifiers and the asset types they can be toggled on.
# Drives per-type seed keys and the Settings matrix UI. The asset type names
# match the AssetType.assettype values seeded by each plugin.
@@ -200,6 +218,10 @@ def list_settings():
query = query.filter_by(category=category)
settings = query.order_by(Setting.category, Setting.key).all()
# Unauthenticated callers see only the public allowlist (branding + a few
# bootstrap keys); an authed principal sees everything (secrets masked).
if get_jwt_identity() is None:
settings = [s for s in settings if _is_public_setting(s)]
return success_response([_serialize_setting(s) for s in settings])
@@ -212,6 +234,11 @@ def get_setting(key: str):
if not setting:
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
# A non-public key is invisible to an unauthenticated caller (404, not 403,
# so its existence is not confirmed either).
if get_jwt_identity() is None and not _is_public_setting(setting):
return error_response(ErrorCodes.NOT_FOUND, f'Setting {key} not found', http_code=404)
return success_response(_serialize_setting(setting))