Files
shopdb-flask/shopdb/core/api/setup.py
cproudlock 32d3f4b29d Rework the first-run setup wizard around what a site cannot run without
The wizard configured five of the product's 129 settings. A site that finished
it still had no timezone, no authentication, no mail server, no backup
destination and no endpoint agent policy, and nothing told the operator so.

Six steps now: Administrator, Site, Authentication, Features, Mail and Backups,
Review. Everything else moves to a standing setup checklist at
/settings/setupchecklist, because a wizard that asked for all 129 settings would
be worse than one that asked for none.

Two new endpoints back it. /setup/checklist reports twelve areas as
ok/partial/todo/na with a link to the page that fixes each, and /setup/health
reports account counts plus whether a plugin toggle has reached every worker.
Both take care not to lie:

- Several settings are SEEDED non-empty (the shipped logo, a placeholder floor
  plan, every identifier field), so "is it set" compares against the shipped
  default rather than emptiness.
- plugin.endpoints.sharedkeyallowed DEFAULTS TO ALLOWED when its row is absent,
  so an absent row is reported as the fleet-wide key being accepted. Reporting
  it as refused told an admin the opposite of the truth about the credential
  they are migrating away from.
- A plugin is held at "starting" until several DISTINCT workers report it live.
  Counting replies instead let one recycled worker answer four times and declare
  a change complete while other workers still served the old routes.

Recommended plugins are declared in each plugin's manifest rather than listed in
the wizard, so adding a plugin cannot leave a stale list in another file.

Also fixed: the logo preview was invisible, because the image borrowed a class
that hardcodes a white background while the shipped logo is white; and signing
in did not re-read setup state, so an admin already browsing was never steered
to the wizard and a hard refresh was the only cure.
2026-09-20 09:06:29 -04:00

532 lines
22 KiB
Python

"""First-run setup wizard support endpoints.
The wizard itself is frontend; these endpoints cover the pieces that need
server work: seeding common starter data and marking setup finished. Site,
plugin, and map configuration reuse the existing settings / plugins APIs.
"""
import hmac
import ipaddress
import os
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.security import generate_password_hash
from shopdb.extensions import db
from shopdb.core.models import Vendor, Setting, User, Role
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
setup_bp = Blueprint('setup', __name__)
# Common hardware vendors most sites will want on hand.
STARTER_VENDORS = [
('Dell Inc.', 'https://www.dell.com'),
('HP Inc.', 'https://www.hp.com'),
('Lenovo', 'https://www.lenovo.com'),
('Xerox', 'https://www.xerox.com'),
('Zebra Technologies', 'https://www.zebra.com'),
('Cisco', 'https://www.cisco.com'),
('Brother', 'https://www.brother.com'),
('Microsoft', 'https://www.microsoft.com'),
]
# Minimum length for the bootstrap administrator password. 12 characters is the
# shortest length that still resists offline cracking of a single leaked hash.
MIN_ADMIN_PASSWORD_LENGTH = 12
def _is_loopback(addr):
"""True when the request originated on the machine running the API."""
if not addr:
return False
try:
return ipaddress.ip_address(addr.split('%')[0]).is_loopback
except ValueError:
return False
def _bootstrap_allowed():
"""Proof of operator control for first-admin creation.
An empty users table is an ordering condition, not authorization: the API
serves requests before the first account exists on every fresh deploy, so a
remote caller could otherwise claim the instance during the install window.
Require either the one-time setup token (SETUP_TOKEN in config or the
environment, presented as the X-Setup-Token header) or a request coming from
loopback, i.e. the host the operator is installing on.
"""
token = str(current_app.config.get('SETUP_TOKEN')
or os.environ.get('SETUP_TOKEN') or '').strip()
if token:
supplied = (request.headers.get('X-Setup-Token') or '').strip()
return bool(supplied) and hmac.compare_digest(supplied, token)
return _is_loopback(request.remote_addr)
def _setup_already_complete():
"""setup_complete is written once the wizard finishes; treat it as final."""
row = Setting.query.filter_by(key='setup_complete').first()
return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes')
@setup_bp.route('/needs-admin', methods=['GET'])
def needs_admin():
"""True when no users exist yet - the app's very first run. Public so the
login screen can offer to create the first admin."""
return success_response({'needsadmin': User.query.count() == 0})
@setup_bp.route('/create-admin', methods=['POST'])
def create_admin():
"""Create the first admin. Only works while there are zero users, setup has
not been marked complete, and the caller proves operator control (setup
token or a loopback request)."""
if User.query.count() > 0 or _setup_already_complete():
return error_response(ErrorCodes.FORBIDDEN,
'An account already exists; sign in instead.', http_code=403)
if not _bootstrap_allowed():
return error_response(
ErrorCodes.FORBIDDEN,
'First-admin creation requires the setup token (X-Setup-Token) '
'or a request from the server itself.',
http_code=403)
data = request.get_json() or {}
username = (data.get('username') or '').strip()
email = (data.get('email') or '').strip()
password = data.get('password') or ''
if not (username and email and password):
return error_response(ErrorCodes.VALIDATION_ERROR,
'username, email and password are required')
if len(password) < MIN_ADMIN_PASSWORD_LENGTH:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters')
admin_role = Role.query.filter_by(rolename='admin').first()
if not admin_role:
admin_role = Role(rolename='admin', description='Administrator')
db.session.add(admin_role)
user = User(username=username, email=email,
passwordhash=generate_password_hash(password), isactive=True)
user.roles.append(admin_role)
db.session.add(user)
db.session.commit()
current_app.logger.warning('First administrator %r created from %s',
username, request.remote_addr)
return success_response({'username': username}, message='Admin created', http_code=201)
@setup_bp.route('/seed-reference', methods=['POST'])
@jwt_required()
@require_role('admin')
def seed_reference():
"""Seed core reference data + permissions + default settings. Idempotent -
reuses the same routines as the `flask seed ...` CLI commands."""
# Invoke the seed CLI commands in a proper click context via the runner.
runner = current_app.test_cli_runner()
for command in ('reference-data', 'permissions', 'settings'):
result = runner.invoke(args=['seed', command])
if result.exit_code not in (0, None):
return error_response(ErrorCodes.INTERNAL_ERROR,
f'seed {command} failed: {result.output}', http_code=500)
return success_response(message='Reference data, permissions and settings seeded.')
@setup_bp.route('/seed-starter', methods=['POST'])
@jwt_required()
@require_role('admin')
def seed_starter():
"""Add common vendors that are not already present. Idempotent."""
existing = {v.vendor.strip().lower() for v in Vendor.query.all()}
added = []
for name, website in STARTER_VENDORS:
if name.strip().lower() in existing:
continue
db.session.add(Vendor(vendor=name, website=website))
added.append(name)
db.session.commit()
return success_response(
{'added': added, 'addedcount': len(added)},
message=f'Seeded {len(added)} vendor(s).'
)
def _setting_values():
"""Every setting as {key: value}, read once.
The checklist inspects roughly forty keys across a dozen categories, so it
reads the table once rather than issuing a query per item.
"""
return {row.key: (row.value or '') for row in Setting.query.all()}
def _truthy(value):
return str(value or '').strip().lower() in ('true', '1', 'yes', 'on')
def _filled(values, *keys):
"""True only when every named key has a non-empty value."""
return all(str(values.get(k) or '').strip() for k in keys)
# Values that are SEEDED, not chosen. A setting holding one of these is still
# at its shipped default, so reporting it as configured would tell a site it had
# done something it has not: site_logo ships as the GE mark and the floor map
# ships as a placeholder image, and both are non-empty from the first boot.
SHIPPED_DEFAULTS = {
'site_logo': ('/ge-aerospace-logo.svg',),
'map_blueprint_light': ('/static/images/floorplan-placeholder.svg',),
'map_blueprint_dark': ('/static/images/floorplan-placeholder.svg',),
}
def _customised(values, key):
"""True when a key holds something other than what shipped with it."""
value = str(values.get(key) or '').strip()
if not value:
return False
if value in SHIPPED_DEFAULTS.get(key, ()):
return False
# Defensive: a renamed placeholder asset should still read as unconfigured.
return 'placeholder' not in value.lower()
def _area(key, label, status, detail, route, wizard=False):
return {'key': key, 'label': label, 'status': status,
'detail': detail, 'route': route, 'inwizard': wizard}
def _enabled_plugin_names():
"""Plugins the registry says are on. Empty when plugins are unavailable."""
from shopdb.plugins import plugin_manager
registry = getattr(plugin_manager, 'registry', None)
if registry is None:
return set()
try:
return set(registry.get_enabled_plugins())
except Exception: # a corrupt registry must not break the checklist
return set()
def _loaded_plugin_names():
"""Plugins actually loaded into THIS process."""
from shopdb.plugins import plugin_manager
loader = getattr(plugin_manager, 'loader', None)
if loader is None:
return set()
try:
return set(loader.get_all_loaded().keys())
except Exception:
return set()
def _manager_call(method):
"""Call a plugin-manager method by name, tolerating its absence."""
from shopdb.plugins import plugin_manager
getter = getattr(plugin_manager, method, None)
if getter is None:
return set()
try:
return set(getter())
except Exception:
return set()
def _enabled_on_disk():
"""Enabled plugins per plugins.json, or None when it cannot be read.
None and the empty set mean different things: "I could not tell you" versus
"nothing is enabled". Collapsing them made an all-disabled site fall back to
a stale in-memory view.
"""
from shopdb.plugins import plugin_manager
getter = getattr(plugin_manager, 'enabled_on_disk', None)
if getter is None:
return None
try:
return set(getter())
except Exception:
return None
def _booted_with_names():
return _manager_call('get_booted_with')
def _deferred_registration_names():
"""Plugins whose routes are waiting on a restart in this process."""
from shopdb.plugins import plugin_manager
getter = getattr(plugin_manager, 'get_deferred_registrations', None)
if getter is None:
return set()
try:
return set(getter())
except Exception:
return set()
def _build_checklist(values):
"""Configured-vs-not for every area a site is expected to set up.
WHY THIS EXISTS. The wizard covers only what a site cannot run without;
everything else would otherwise be invisible until someone went looking for
it in Settings. There are well over a hundred settings, and a wizard that
asked for all of them would be worse than no wizard. This is the other half:
a standing list of what is still unconfigured, with a link straight to the
page that configures it.
Status is deliberately three-valued. 'partial' matters because several areas
have an enable flag plus the fields that flag needs - SAML switched on with
no IdP metadata is a broken site, and reporting that as 'ok' would hide it.
"""
enabled = _enabled_plugin_names()
areas = []
# --- identity -------------------------------------------------------
# Only facility_name is genuinely required. The wizard offers "blank = use
# the browsing origin" for site_base_url and "Use the server timezone" for
# site_timezone, and saves '' for both - so demanding them here told an
# operator who took an option the wizard offered that they had not finished.
if _filled(values, 'facility_name'):
status, detail = 'ok', values.get('facility_name', '')
else:
status, detail = 'todo', 'Facility name is unset'
areas.append(_area('site_identity', 'Site identity', status, detail,
'/settings/siteconfig/site', wizard=True))
# --- authentication -------------------------------------------------
if _truthy(values.get('saml_enabled')):
if _filled(values, 'saml_entity_id', 'saml_acs_url', 'saml_idp_metadata_url'):
status, detail = 'ok', 'SAML configured'
else:
status, detail = 'partial', 'SAML is on but the IdP fields are incomplete'
else:
status, detail = 'todo', 'Local accounts only; SAML is off'
areas.append(_area('authentication', 'Authentication', status, detail,
'/settings/security/authentication', wizard=True))
# --- email ----------------------------------------------------------
if _truthy(values.get('smtp_enabled')):
if _filled(values, 'smtp_host', 'smtp_from_address'):
status, detail = 'ok', values.get('smtp_host', '')
else:
status, detail = 'partial', 'SMTP is on but host or from-address is unset'
else:
status, detail = 'todo', 'No mail server; alerts and notifications cannot send'
areas.append(_area('email', 'Email / SMTP', status, detail,
'/settings/email', wizard=True))
# --- backup destination ---------------------------------------------
kind = (values.get('backup_destination_kind') or '').strip().lower()
if kind == 'smb' and _filled(values, 'backup_smb_host', 'backup_smb_share'):
status, detail = 'ok', f"SMB: {values.get('backup_smb_host')}"
elif kind in ('aws', 's3') and _filled(values, 'backup_aws_bucket'):
status, detail = 'ok', f"S3: {values.get('backup_aws_bucket')}"
elif kind in ('smb', 'aws', 's3'):
status, detail = 'partial', f'{kind} selected but its fields are incomplete'
else:
# 'local' is the seeded default and a legitimate choice, but it means
# no copy leaves this host, so a site should make that decision rather
# than inherit it.
status, detail = 'todo', 'Backups stay on this host; no offsite copy'
areas.append(_area('backup', 'Backup destination', status, detail,
'/settings/shopdbbackups', wizard=True))
# --- branding -------------------------------------------------------
if _customised(values, 'site_logo'):
status, detail = 'ok', 'Custom logo set'
else:
status, detail = 'todo', 'Using the shipped logo and colours'
areas.append(_area('branding', 'Branding', status, detail,
'/settings/siteconfig/branding'))
# --- floor map ------------------------------------------------------
has_blueprint = _customised(values, 'map_blueprint_light')
if has_blueprint and _filled(values, 'map_width', 'map_height'):
status, detail = 'ok', f"{values.get('map_width')} x {values.get('map_height')}"
elif has_blueprint:
status, detail = 'partial', 'Blueprint uploaded but dimensions are unset'
else:
status, detail = 'todo', 'Still on the shipped placeholder blueprint'
areas.append(_area('floormap', 'Floor map', status, detail,
'/settings/siteconfig/floormap'))
# --- integrations ---------------------------------------------------
live = [name for flag, name in (
('zabbix_enabled', 'Zabbix'),
('warranty_dell_enabled', 'Dell warranty'),
('servicenow_enabled', 'ServiceNow'),
) if _truthy(values.get(flag))]
if live:
status, detail = 'ok', ', '.join(live)
else:
status, detail = 'todo', 'Zabbix, Dell warranty and ServiceNow are all off'
areas.append(_area('integrations', 'Integrations', status, detail,
'/settings/integrations'))
# --- identifiers ----------------------------------------------------
# Every identifier_* key is seeded 'true' (settings.py build_default_settings),
# so "any enabled" was true on every fresh site and this area could never
# read anything but ok. Report it as a review item instead of pretending a
# decision was made.
ident_total = [k for k in values if k.startswith('identifier_')]
ident_on = [k for k in ident_total if _truthy(values[k])]
if ident_total and len(ident_on) < len(ident_total):
status = 'ok'
detail = f'{len(ident_on)} of {len(ident_total)} identifier fields enabled'
else:
status = 'todo'
detail = 'All identifier fields are on by default; trim to what this site uses'
areas.append(_area('identifiers', 'Asset identifiers', status, detail,
'/settings/assetconfig/identifiers'))
# --- printing / QR ---------------------------------------------------
qr = [k for k in values if k.startswith('qr_target_') and _filled(values, k)]
if qr:
status, detail = 'ok', f'{len(qr)} QR target(s) set'
else:
status, detail = 'todo', 'QR codes will fall back to defaults'
areas.append(_area('printing', 'Printing and labels', status, detail,
'/settings/printing'))
# --- endpoint agent policy (only when the plugin is on) --------------
if 'endpoints' in enabled:
# The runtime default is PERMISSIVE: shared_key_allowed() in
# plugins/endpoints/api/agentauth.py reads the setting with default=True,
# so an absent row means the fleet-wide key is accepted. Treating absent
# as "off" told an admin the key was refused on exactly the sites that
# still accept it, which is the opposite of the truth about a credential
# readable by any local user.
raw = values.get('plugin.endpoints.sharedkeyallowed')
shared = True if raw in (None, '') else _truthy(raw)
if shared:
status = 'partial'
detail = ('The fleet-wide shared key is still accepted'
+ ('' if raw not in (None, '') else ' (setting unset, defaults to allowed)'))
else:
status, detail = 'ok', 'Shared key refused; per-PC credentials only'
else:
status, detail = 'na', 'Endpoints plugin is not enabled'
areas.append(_area('endpoint_policy', 'Endpoint agent policy', status, detail,
'/settings/plugins'))
# --- locations -------------------------------------------------------
# Location is a top-level export, so the import cannot fail; only the query
# can. Swallowing that reported "no locations" as a data fact AND left the
# session in a failed transaction, so the next count() below raised and the
# whole endpoint 500'd with a misleading detail already in the payload.
from shopdb.core.models import Location
try:
count = Location.query.count()
except Exception:
db.session.rollback()
count = None
if count is None:
status, detail = 'todo', 'Could not read locations'
else:
status = 'ok' if count else 'todo'
detail = f'{count} location(s)' if count else 'No locations defined yet'
areas.append(_area('locations', 'Locations', status, detail,
'/settings/locations/list'))
# --- reference data ---------------------------------------------------
vendors = Vendor.query.count()
status = 'ok' if vendors else 'todo'
detail = f'{vendors} vendor(s)' if vendors else 'No vendors seeded'
areas.append(_area('reference_data', 'Reference data', status, detail,
'/settings/catalog/vendors', wizard=True))
return areas
@setup_bp.route('/checklist', methods=['GET'])
@jwt_required()
@require_role('admin')
def checklist():
"""What is configured and what is not, with a link to each fix."""
values = _setting_values()
areas = _build_checklist(values)
counts = {'ok': 0, 'partial': 0, 'todo': 0, 'na': 0}
for area in areas:
counts[area['status']] = counts.get(area['status'], 0) + 1
return success_response({
'areas': areas,
'counts': counts,
'complete': _setup_already_complete(),
})
@setup_bp.route('/health', methods=['GET'])
@jwt_required()
@require_role('admin')
def health():
"""Post-setup reality check, including whether a restart is outstanding.
RESTART DETECTION IS THE POINT. Plugins are read from plugins.json at app
startup, so enabling one in the wizard changes the file but not the running
process: every route that plugin contributes answers 404 until the app is
restarted, with nothing on screen to say so. Comparing what the registry
says is enabled against what this process actually loaded turns that into
something the wizard can state plainly and then re-verify.
"""
# Intent comes from plugins.json re-read from disk, because that file is
# shared by every gunicorn worker while each worker's in-memory registry is
# its own. Reality is the set of plugins THIS process registered routes for
# at boot. Two things make loader.get_all_loaded() the wrong comparison:
# installing a plugin instantiates it (so it looks loaded while its
# blueprint was skipped), and a plugin enabled through another worker is
# invisible here. The boot snapshot has neither problem - a plugin enabled
# after start is missing from every worker's snapshot.
# `or` would treat "nothing enabled" as "could not read", falling back to
# this worker's stale boot view and hiding every stale-loaded plugin.
enabled = _enabled_on_disk()
if enabled is None:
enabled = _enabled_plugin_names()
booted = _booted_with_names()
pending = sorted(enabled - booted)
stale = sorted(booted - enabled)
loaded = _loaded_plugin_names()
# role.users is a dynamic relationship (AppenderQuery), so it answers to
# .count() and NOT to len(). len() raised here, the exception was swallowed,
# and the endpoint reported zero admins on a site that had one.
admin_role = Role.query.filter_by(rolename='admin').first()
admins = admin_role.users.count() if admin_role else 0
return success_response({
'database': True, # reaching here means the session works
# Which worker answered. Each one recycles independently when the
# enabled set changes, so a single clean reply only proves THAT worker
# has rolled. A caller that sees clean replies from several distinct
# pids knows the change has actually taken hold across the pool.
'worker': os.getpid(),
'users': User.query.count(),
'admins': admins,
'plugins': {
'enabled': sorted(enabled),
'loaded': sorted(loaded),
'awaitingrestart': pending,
'stillloaded': stale,
},
'restartrequired': bool(pending or stale),
})
@setup_bp.route('/complete', methods=['POST'])
@jwt_required()
@require_role('admin')
def mark_complete():
"""Flag the first-run setup as finished."""
row = Setting.query.filter_by(key='setup_complete').first()
if row:
row.value = 'true'
else:
db.session.add(Setting(key='setup_complete', value='true',
valuetype='boolean', category='site'))
db.session.commit()
return success_response({'complete': True}, message='Setup marked complete.')