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

@@ -267,3 +267,57 @@ def test_mutation_admin_passes_authz(client, db, auth_headers,
f'{method} {endpoint} ({url}) returned {response.status_code} for '
f'admin - the admin role should bypass every permission check.'
)
# --- Settings exposure allowlist (unauthenticated recon guard) ---------------
def _seed_settings(db):
from shopdb.core.models import Setting
db.session.add_all([
Setting(key='facility_name', value='WJ', valuetype='string', category='site'),
Setting(key='brand_primary_color', value='#123', valuetype='string', category='branding'),
Setting(key='smtp_host', value='mail.internal', valuetype='string', category='communication'),
Setting(key='employee_db_host', value='hr.internal', valuetype='string', category='integrations'),
Setting(key='zabbix_token', value='supersecret', valuetype='string', category='integrations'),
])
db.session.commit()
def test_unauthenticated_settings_list_is_allowlisted(client, db):
"""No token: only the public allowlist (branding + bootstrap keys) is
visible; internal infra config is not enumerable."""
_seed_settings(db)
keys = {s['key'] for s in client.get('/api/settings').get_json()['data']}
assert 'facility_name' in keys and 'brand_primary_color' in keys
assert 'smtp_host' not in keys
assert 'employee_db_host' not in keys
assert 'zabbix_token' not in keys
def test_authenticated_settings_list_full_but_masked(client, db, member_headers):
"""Any authed principal sees the full table; secret values stay masked."""
_seed_settings(db)
rows = {s['key']: s['value']
for s in client.get('/api/settings', headers=member_headers).get_json()['data']}
assert 'smtp_host' in rows and 'employee_db_host' in rows
assert rows['zabbix_token'] == '********' # secret masked even when visible
def test_unauthenticated_private_setting_get_is_404(client, db):
"""A single private key is not confirmable without auth; a public one is."""
_seed_settings(db)
assert client.get('/api/settings/smtp_host').status_code == 404
assert client.get('/api/settings/facility_name').status_code == 200
# --- audit.view gating -------------------------------------------------------
def test_member_cannot_read_auditlogs(client, db, member_headers):
"""Audit-read routes now require audit.view; a role-less member is 403."""
for url in ('/api/auditlogs', '/api/auditlogs/entity/Setting/1', '/api/auditlogs/stats'):
assert client.get(url, headers=member_headers).status_code == 403, url
def test_admin_can_read_auditlogs(client, db, auth_headers):
"""Admin holds audit.view and reads the audit trail."""
assert client.get('/api/auditlogs', headers=auth_headers).status_code == 200

View File

@@ -18,27 +18,29 @@ def _defaults_by_key():
return {d['key']: d for d in build_default_settings()}
def test_anon_settings_list_masks_secrets(client, db):
"""Anonymous GET /api/settings never returns password/token/secret values."""
def test_anon_settings_list_excludes_nonpublic(client, db):
"""Anonymous GET /api/settings returns only the public allowlist - secret
AND non-secret internal keys are absent, so plaintext can never leak."""
from shopdb.core.models import Setting
Setting.set('smtp_password', 'supersecret', valuetype='string', category='email')
Setting.set('zabbix_token', 'tok-abc-123', valuetype='string', category='integrations')
Setting.set('warranty_dell_clientsecret', 'shh', valuetype='string', category='integrations')
# A non-secret key stays visible for contrast.
# A public key stays visible for contrast.
Setting.set('facility_name', 'Test Plant', valuetype='string', category='site')
resp = client.get('/api/settings')
assert resp.status_code == 200, resp.get_json()
by_key = {s['key']: s['value'] for s in resp.get_json()['data']}
assert by_key['smtp_password'] == '********'
assert by_key['zabbix_token'] == '********'
assert by_key['warranty_dell_clientsecret'] == '********'
# Non-public keys are not enumerable at all (stronger than masking).
assert 'smtp_password' not in by_key
assert 'zabbix_token' not in by_key
assert 'warranty_dell_clientsecret' not in by_key
# Plaintext secrets must not leak anywhere in the response body.
assert 'supersecret' not in resp.get_data(as_text=True)
assert 'tok-abc-123' not in resp.get_data(as_text=True)
# Non-secret value passes through untouched.
# A public key passes through untouched.
assert by_key['facility_name'] == 'Test Plant'