Files
shopdb-flask/shopdb/__init__.py
Proudlock 397c02611a security: fix admin bootstrap race and KB article disclosure
Address two security findings from darkwing scan:

Finding 1 [High]: Unauthenticated admin bootstrap race
- Require SETUP_TOKEN header or loopback origin for POST /api/setup/create-admin
- Enforce 12-character minimum password for bootstrap admin
- Cross-check setup_complete setting to prevent re-arming
- Log token to container logs on first startup for operator retrieval
- Add flask seed show-setup-token CLI helper
- Add audit log entry when first admin is created
- Preserve existing flask seed admin workflow (uses loopback)

Finding 2 [Medium]: Unauthenticated KB article disclosure
- Gate POST /api/knowledgebase/<id>/click on kb.view permission
- Remove linkurl from response (caller already has it from GET)
- Apply _visible_articles() filter to respect retired applications
- Prevent anonymous link enumeration and counter manipulation

Recovery paths if token is lost:
1. docker logs shopdb-api | grep "Setup token"
2. docker exec shopdb-api flask seed show-setup-token
3. docker exec shopdb-api flask seed admin (unchanged, always works)

Zero breaking changes. CLI and existing workflows unaffected.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 03:37:22 -04:00

593 lines
26 KiB
Python

"""Flask application factory."""
import os
import logging
from flask import Flask, send_from_directory
from .config import config
from .extensions import db, jwt, init_extensions
from .plugins import plugin_manager
# Platform contract version. See ADR-001 for the contract surface and
# ADR-002 for the bump rules. Plugins declare a compatible range in
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the
# contract is still settling; sister sites should pin tight ranges.
# 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache,
# model bases, core models, response + pagination helpers, employee_connection)
# so plugins no longer import internal core paths. Additive, hence minor bump.
# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a
# core concern over the asset model) and wired the get_dashboard_widgets hook to
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api
# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
# minor bump.
# 0.10.0: added the get_permissions hook so a plugin declares the RBAC
# permissions its own routes enforce, instead of core accumulating them in one
# catalog. Consumed by full_permission_catalog() (core + enabled plugins),
# which backs seeding, the role grid, and API-token scope validation. Additive
# optional hook, minor bump.
# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
# 0.16.0: added the get_settings_defaults hook so a plugin declares the Setting
# rows it owns (key, value, type, category, description, public). The framework
# seeds them at install, at enable, and on `flask plugin upgrade-all`, files a
# first-time write under the declared category, and lets a plugin mark a key
# readable without auth for pages that run logged out. Additive optional hook,
# minor bump.
# 0.18.0: added DISPLAY_ROLES, DISPLAY_ROLE_PATHS and normalize_display_role
# beside DashboardDefault (which 0.17.0 imported but never listed in __all__).
# The role vocabulary is now the kiosk's own - Dashboard / Lobby / 3DPrintRoom,
# the literal values of C:\Enrollment\display-type.txt - so a plugin holding its
# own copy of that map (geenforce did) can read core's instead of drifting from
# it. Additive names on the import surface, minor bump.
# 0.19.0: get_dashboard_widgets returns DATA AND SHAPE, not a component name.
# The old shape ('name' + 'component' + 'size') named a Vue component per
# widget, which cannot survive a lean build - a plugin's component may never be
# staged into the frontend bundle (ADR-013) - and five plugins were declaring
# widgets that pointed at components nobody had written. Core now owns the
# renderers and a card declares id / title / endpoint / render / severity /
# permission / empty / position. BREAKING for any plugin still using the old
# shape, which is why it is recorded here: the change itself shipped earlier
# without a bump, and a contract that changes silently is not a contract.
# 0.21.0: added require_any_permission beside require_permission and require_role
# on shopdb.api so a plugin can gate a route on holding ANY of several
# permissions (an OR check) instead of one. Part of the read-permission rework
# where nav items and GET routes declare the permission they need. Additive name
# on the authz surface, minor bump.
# 0.22.0: added register_import_hook to shopdb.api so a plugin can own a VIRTUAL
# COLUMN on an importable CSV table - a header the bulk importer accepts but
# never writes - and a post-write row hook that turns that value into a plugin
# concept. The computers plugin uses it for a machineassetnumber column on
# computers.csv that builds the PC->machine controls link, so a site writes the
# machine number in the PC row instead of hand-building a relationships sheet.
# Core stays ignorant of the column's meaning. Additive name on the import
# surface, minor bump.
# 0.23.0: added mint_service_token, list_service_tokens and revoke_service_tokens
# to shopdb.api so a plugin can provision, list, and revoke a single-scope
# managed service token (an endpoint agent key) without importing core token
# internals. The endpoints plugin uses them for a UI-managed, regenerable
# collector.ingest.endpoints key whose revocation is an instant kill switch.
# Additive names on the surface, minor bump.
# 0.24.0: added collector_authorized to shopdb.api so a plugin can gate an
# agent-facing route that lives OUTSIDE the generic collector blueprint (the
# endpoints command claim/result long-poll callbacks) on the same per-plugin
# collector key + managed-token check the ingest route uses, without importing
# core collector internals. Additive name on the surface, minor bump.
# 0.25.0: added caller_holds(permission) to shopdb.api so a route can ask
# whether THIS caller holds a permission without refusing the request. It is for
# a surface that serves everyone but should only OFFER what the caller can use:
# the endpoints services panel emitted Start/Stop to every reader because the
# emission never consulted the caller, so a read-only viewer was shown controls
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
# visitor holds whatever the guest role was granted. Additive name, minor bump.
# 0.26.0: dashboard cards may declare 'shortname'. The dashboard merged its
# per-source cards into one worst-first queue, and a card's 'title' is written
# as a heading - "Endpoints with crashes or shutdowns" - which as a row badge
# ran wider than the hostname it qualified. Optional, defaults to the title, so
# a plugin that does not declare one is unaffected. Additive field, minor bump.
# 0.27.0: added Script to shopdb.api so a plugin can read the ShopDB-hosted
# script library that backs endpoint run_script delivery without importing core
# model internals. The endpoints plugin resolves a delivered script by name and
# serves its body by digest to the agent. Additive name on the surface, minor
# bump.
# 0.28.0: added client_ip and strip_port to shopdb.api so a plugin does not
# hand-roll caller-IP extraction. Two plugins had, and both read the first
# X-Forwarded-For hop, which the caller sends - so a rate limit keyed on it was
# defeated by rotating the header. The helper resolves from remote_addr, which
# ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a
# proxy is really in front. Additive names on the surface, minor bump.
# 0.30.0: added ImportBatch, ImportJournalEntry, and the BATCH_*/OP_* constants
# to shopdb.api so a plugin's bulk-import UI can list applied import batches and
# reverse one through the shared reversible journal without importing core model
# internals. The journal records the primitive mutations a bulk import makes
# (update a cell, insert a row) tied to a batch id, and a generic undo replays
# them in reverse. Additive names on the surface, minor bump.
#
# 0.31.0 widens the asset-panel vocabulary a plugin may declare: two more
# tab groups (serviceprocess, reports), a datetime column format, a map body
# key, and a tabs section that renders as a table. All optional, so a plugin
# that declares none of them is unaffected. Additive, minor bump.
__contract_version__ = '0.31.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
# bump rules. Not part of the shopdb.api contract surface, so it is
# not re-exported there.
__version__ = '0.12.0'
def create_app(config_name: str = None) -> Flask:
"""
Application factory.
Args:
config_name: Configuration name ('development', 'production', 'testing')
Returns:
Configured Flask application
"""
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
app = Flask(__name__, instance_relative_config=True)
config_class = config.get(config_name, config['default'])
# Production must validate its env-driven config before boot.
if config_name == 'production' and hasattr(config_class, 'validate'):
config_class.validate()
app.config.from_object(config_class)
# The product and contract versions, in config so a PLUGIN can read them
# through current_app instead of importing core (ADR-002 forbids that, and
# the contract test enforces it). Core code may still use the module
# constants directly.
app.config['VERSION'] = __version__
app.config['CONTRACT_VERSION'] = __contract_version__
# Load instance config if exists
app.config.from_pyfile('config.py', silent=True)
# Per-plugin collector keys (ADR-006) are dynamic env-vars
# (COLLECTOR_API_KEY_<PLUGINNAME>) that from_object cannot pick up because
# they are not class attributes. Copy them in explicitly so per-plugin
# credential isolation works in real deploys, not just tests.
for envname, envvalue in os.environ.items():
if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
app.config[envname] = envvalue
# Behind a reverse proxy (IIS at /shopdb, nginx, a load balancer), the app
# sees the PROXY's connection, not the browser's: scheme http even though
# the user is on https, the proxy's host, the proxy's IP. Anything Flask
# builds from that is wrong at the browser - an absolute redirect Location
# above all, which is how a trailing-slash redirect on the endpoints list
# sent an https page to an http URL the browser then refused to follow.
#
# OFF BY DEFAULT AND DELIBERATELY. ProxyFix makes the app TRUST
# X-Forwarded-* headers, and a client can send those itself. Enabled with no
# proxy in front, any caller can claim a different scheme or source IP -
# which matters here because the login rate limit and the lockout are keyed
# on the client address. Set TRUSTED_PROXY_COUNT to the number of proxies
# actually in front of this instance (1 for a single IIS or nginx), and only
# when one really is.
proxycount = app.config.get('TRUSTED_PROXY_COUNT', 0)
if proxycount:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=proxycount, x_proto=proxycount,
x_host=proxycount, x_prefix=proxycount)
# Ensure instance folder exists
os.makedirs(app.instance_path, exist_ok=True)
# Configure logging
configure_logging(app)
# Initialize extensions
init_extensions(app)
# An optional unique column accepts any number of NULLs but exactly one
# empty string, so a second blank code collided and became a 500. Normalise
# blank to NULL once, at the mapper, rather than in each endpoint.
from .utils.blankunique import register_blank_unique_normaliser
register_blank_unique_normaliser()
# Initialize plugin manager
with app.app_context():
# On first run (no users exist), log the setup token so the operator can
# create the first admin. The token gates POST /api/setup/create-admin to
# prevent a network race during the install window (API starts serving
# before the admin exists in every documented deploy path).
from shopdb.core.models import User
try:
if User.query.count() == 0:
token = app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN')
if token:
app.logger.warning('=' * 60)
app.logger.warning('FIRST RUN: No users exist.')
app.logger.warning('Setup token: %s', token)
app.logger.warning('Create admin: POST /api/setup/create-admin')
app.logger.warning(' with header: X-Setup-Token: <token>')
app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin')
app.logger.warning('=' * 60)
else:
app.logger.warning('FIRST RUN: No users exist, no SETUP_TOKEN set.')
app.logger.warning('Use CLI: docker exec shopdb-api flask seed admin')
except Exception:
pass # DB not ready yet, skip
plugin_manager.init_app(app, db)
# Register core blueprints
register_blueprints(app)
# Finish any restore that is waiting on its schema upgrade. The backup
# sidecar has no Flask, so it loads the dump and leaves a sentinel; this
# container owns the app context that can run the migrations. Started here
# rather than left to a browser poll, because a restore must complete
# whether or not anyone is still watching it.
#
# Skipped unless a backups store actually exists, which keeps it off every
# deployment that does not run the backup overlay - and out of the test
# suite, which builds this app thousands of times.
# NOTE: wilm's backup implementation doesn't require initialization here
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
# any JWT decode and mint a request-scoped JWT for the token's owner.
from .utils.apitoken_auth import install_apitoken_auth
install_apitoken_auth(app)
# Register CLI commands
register_cli_commands(app)
# Register error handlers
register_error_handlers(app)
# Serve Vue frontend
register_frontend_routes(app)
# JWT user loader (identity is a string in JWT, convert to int for DB lookup)
@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_data):
from .core.models import User
identity = jwt_data["sub"]
user = db.session.get(User, int(identity))
# Deactivating an account has to end its sessions. There is no token
# denylist, so without this check the access token stays good until it
# expires and the refresh token keeps minting new ones for 30 days.
# /auth/refresh and the API-token path already check isactive.
if user is None or not user.isactive:
return None
return user
@app.after_request
def apply_security_headers(response):
"""Baseline response headers. The app shipped with none of these.
Deliberately the three that cost nothing and break nothing:
nosniff a response whose bytes and declared type disagree is
not re-typed by the browser into something executable.
This is the general form of the upload problem that
shopdb/utils/uploads.py addresses per-file.
frame-ancestors
same-origin only. The kiosks open routes directly
rather than framing them, so this costs the fleet
nothing and stops the UI being framed elsewhere and
clicked through. Sent as X-Frame-Options too, because
the display bays run browsers old enough to want it.
Referrer-Policy
an asset id or a hostname in a path is not handed to
whatever a user clicks through to.
NOT a full page CSP. This app serves an SPA with inline styles, so a
real script-src policy is a change worth making on its own with the
frontend in front of you - claiming one here by adding a permissive
header would be worse than having none.
"""
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
response.headers.setdefault('Referrer-Policy',
'strict-origin-when-cross-origin')
# setdefault: an upload response has already declared its own, stricter
# Content-Security-Policy, and this must not weaken it.
response.headers.setdefault('Content-Security-Policy',
"frame-ancestors 'self'")
return response
return app
CORE_BLUEPRINT_NAMES = (
'auth',
'assets',
'modeltypes',
'plugins',
'vendors',
'models',
'businessunits',
'locations',
'maplevels',
'mappositions',
'operatingsystems',
'dashboard',
'dashboarddefaults',
'applications',
'scripts',
'supportteams',
'search',
'reports',
'collector',
'settings',
'auditlogs',
'users',
'customfields',
'setup',
'pluginui',
'apitokens',
'docs',
'shopdbbackups',
)
def register_blueprints(app: Flask):
"""Register core API blueprints from CORE_BLUEPRINT_NAMES.
Each entry maps to an attribute `<name>_bp` exported by
`shopdb.core.api` and a URL prefix `/api/<name>`. Adding a new
core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line
edit in this function.
"""
from .core import api as api_module
api_prefix = '/api'
for name in CORE_BLUEPRINT_NAMES:
attr_name = f'{name}_bp'
if not hasattr(api_module, attr_name):
raise RuntimeError(
f'Core blueprint "{attr_name}" missing from shopdb.core.api. '
f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.'
)
bp = getattr(api_module, attr_name)
app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}')
def register_cli_commands(app: Flask):
"""Register Flask CLI commands."""
from .plugins.cli import plugin_cli
from .cli import (db_cli, seed_cli, relationships_cli, csv_cli,
catalog_cli, audit_cli)
app.cli.add_command(plugin_cli)
app.cli.add_command(db_cli)
app.cli.add_command(seed_cli)
app.cli.add_command(relationships_cli)
app.cli.add_command(csv_cli)
app.cli.add_command(catalog_cli)
app.cli.add_command(audit_cli)
def register_error_handlers(app: Flask):
"""Register error handlers."""
from .utils.responses import error_response, ErrorCodes
from .exceptions import ShopDBException
# A uniqueness violation is the caller's problem, not a server fault. Without
# this it surfaced as a bare 500 with a SQLAlchemy traceback in the log and
# nothing usable on screen - the operator saw "internal server error" for
# having reused a code that was already taken.
from sqlalchemy.exc import IntegrityError
@app.errorhandler(IntegrityError)
def handle_integrity_error(error):
from .extensions import db
db.session.rollback()
message = str(getattr(error, 'orig', error))
app.logger.warning('integrity error: %s', message)
if 'Duplicate entry' in message or 'UNIQUE constraint' in message:
return error_response(
ErrorCodes.CONFLICT,
'That value is already in use. Codes and identifiers must be unique.',
http_code=409)
if 'foreign key constraint' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That record refers to something that does not exist, or is still in use elsewhere.',
http_code=400)
return error_response(ErrorCodes.VALIDATION_ERROR,
'The database rejected that change.', http_code=400)
# STRICT_TRANS_TABLES (see config.SQLALCHEMY_ENGINE_OPTIONS) turns what
# MySQL used to silently adjust into an error: a value too long for its
# column, a date that is not a date, a number that is not a number. That is
# the behaviour we want - the alternative is a 201 for a row the caller did
# not send - but the error is the CALLER's, so it must not read as a server
# fault. Without this it surfaced as a 500 with a traceback in the log.
from sqlalchemy.exc import DataError
@app.errorhandler(DataError)
def handle_data_error(error):
from .extensions import db
db.session.rollback()
message = str(getattr(error, 'orig', error))
app.logger.warning('data error: %s', message)
column = _column_from_data_error(message)
if 'too long' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That value is too long for %s.' % (column or 'this field'),
http_code=400)
if 'incorrect date' in message.lower() or \
'incorrect datetime' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That is not a valid date for %s.' % (column or 'this field'),
http_code=400)
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The database rejected that value%s.'
% (' for %s' % column if column else ''),
http_code=400)
# A wrong query argument is the caller's mistake. Registered app-wide so a
# list route does not have to remember to catch it - forgetting was the
# whole defect, in six routes across five plugins.
from .utils.queryargs import QueryArgError
@app.errorhandler(QueryArgError)
def handle_query_arg_error(error):
return error_response(ErrorCodes.VALIDATION_ERROR, str(error),
http_code=400)
@app.errorhandler(ShopDBException)
def handle_shopdb_exception(error):
http_codes = {
'NOT_FOUND': 404,
'UNAUTHORIZED': 401,
'FORBIDDEN': 403,
'CONFLICT': 409,
'VALIDATION_ERROR': 400,
}
http_code = http_codes.get(error.code, 400)
return error_response(
error.code,
error.message,
details=error.details,
http_code=http_code
)
@app.errorhandler(404)
def not_found_error(error):
return error_response(
ErrorCodes.NOT_FOUND,
'Resource not found',
http_code=404
)
# Werkzeug answers these three itself, in HTML, so a JSON client asking a
# question slightly wrong got a page of markup with no error code and no
# envelope - and on a route the caller cannot see, the 405 also confirmed
# that the path exists, which is how a gated kiosk route meant to look
# absent announced itself.
#
# Only under /api/. Everything else is the SPA, which needs the framework's
# own behaviour (and its own catch-all) left alone.
from flask import request as flask_request
def _api_request():
return flask_request.path.startswith('/api/')
@app.errorhandler(405)
def method_not_allowed_error(error):
if not _api_request():
return error
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That method is not allowed on this endpoint.',
http_code=405)
@app.errorhandler(415)
def unsupported_media_type_error(error):
if not _api_request():
return error
return error_response(
ErrorCodes.VALIDATION_ERROR,
'This endpoint expects a JSON body; send '
'Content-Type: application/json.',
http_code=415)
@app.errorhandler(400)
def bad_request_error(error):
if not _api_request():
return error
# Werkzeug raises this for malformed JSON before any route sees it.
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The request body could not be read as JSON.',
http_code=400)
@app.errorhandler(500)
def internal_error(error):
return error_response(
ErrorCodes.INTERNAL_ERROR,
'An internal error occurred',
http_code=500
)
@app.errorhandler(401)
def unauthorized_error(error):
return error_response(
ErrorCodes.UNAUTHORIZED,
'Authentication required',
http_code=401
)
def _column_from_data_error(message):
"""Pull the column name out of a MySQL data error, when it names one.
MySQL says "Data too long for column 'username' at row 1"; SQLite says
nothing of the sort. Best effort on purpose - a message that names the
field is much more useful than a generic one, and a message that names
nothing is still better than a 500.
"""
import re
found = re.search(r"column '([^']+)'", message or '')
return found.group(1) if found else None
def register_frontend_routes(app: Flask):
"""Serve Vue frontend static files."""
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve_frontend(path):
# Don't serve API routes as frontend
if path.startswith('api/'):
from .utils.responses import error_response, ErrorCodes
return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404)
# Try to serve a static asset directly. send_from_directory handles
# the safe-join + 404 itself; no explicit existence probe needed
# (the probe was a path-traversal risk surface).
if path:
try:
response = send_from_directory(frontend_dist, path)
# Asset filenames carry a content hash, so a given URL never
# changes - cache them hard. Everything else stays revalidated.
if path.startswith('assets/'):
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
else:
response.headers['Cache-Control'] = 'no-cache'
return response
except Exception:
pass
# index.html names the hashed chunks, so a stale copy points at files a
# deploy has already deleted and the SPA stops navigating. Always
# revalidate it.
response = send_from_directory(frontend_dist, 'index.html')
response.headers['Cache-Control'] = 'no-cache'
return response
def configure_logging(app: Flask):
"""Configure application logging."""
log_level = app.config.get('LOG_LEVEL', 'INFO')
logging.basicConfig(
level=getattr(logging, log_level),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)