Security hardening, multi-site portability, and a documentation and API-surface sweep

This commit is contained in:
2026-08-14 16:28:34 -04:00
parent 72f8da0e58
commit 265ca2eb01
76 changed files with 3684 additions and 811 deletions

View File

@@ -40,7 +40,7 @@ def _sitezone():
Same lookup the notifications plugin uses. A stored timestamp is UTC, so
anything rendered server-side has to be converted or it shows the wrong
wall clock for the site - four hours out at West Jefferson.
wall clock for the site - four hours out at the reference site.
"""
from shopdb.api import Setting
row = Setting.query.filter_by(key='site_timezone').first()

View File

@@ -30,7 +30,34 @@ def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.execute('UPDATE backuprevisions SET lastseenat = NULL')
# SCOPED TO THE BACKFILL, not to every row. An unscoped UPDATE was
# correct exactly once; re-running it destroys evidence, and this chain
# HAS been re-running on MySQL (see the missing commit in
# shopdb/plugins/alembic_template.py). A migration that deletes data has
# to assume it will run twice.
#
# TWO CONDITIONS, and both are needed:
#
# 1. The backfill's actual signature is COALESCE(collectedat, createdat)
# - that is the expression 0002 wrote. Matching `lastseenat =
# collectedat` alone misses every row whose collectedat is NULL,
# because NULL comparison is never true, so exactly the rows carrying
# the most invented value would have kept it forever.
#
# 2. A date bound, because value equality on its own is not a signature
# on MySQL. db.DateTime is second-precision there (SQLite keeps
# microseconds, which is why no test could show this), and the
# collector writes collectedat and lastseenat in one statement - so a
# genuinely fresh revision that arrived without a payload collectedat
# has the two stamps equal to the second and would be read as a
# backfill and wiped.
#
# The cutoff is the day this correction ships. Everything the backfill
# touched predates it by construction; anything created after it is a
# live collection this migration must never touch.
op.execute("UPDATE backuprevisions SET lastseenat = NULL "
"WHERE lastseenat = COALESCE(collectedat, createdat) "
"AND createdat < '2026-08-14 00:00:00'")
def downgrade():

View File

@@ -20,6 +20,7 @@ from typing import Dict, List, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import Setting
from .api import backups_bp
from .models import BackupRevision
@@ -165,7 +166,10 @@ class BackupsPlugin(BasePlugin):
'valuetype': 'string',
'category': 'backups',
'description': 'UNC root that opaque (non-JSON) backups are '
'written under by the collecting PC. Site-specific.',
'written under by the collecting PC, e.g. '
'\\\\fileserver\\shopfloor\\backups. Blank until '
'the site sets it: no default can be right at '
'more than one site (ADR-015).',
},
{
'key': 'backups_staledays',
@@ -395,8 +399,14 @@ class BackupsPlugin(BasePlugin):
pruned = prune(
assetid, kindkey,
retentioncount=self.get_setting('backups_retentioncount', 0),
retentiondays=self.get_setting('backups_retentiondays', 0),
# Setting.get, NOT self.get_setting. The BasePlugin helper
# namespaces what it reads to `plugin.backups.<key>`, while
# get_settings_defaults declares - and the settings page writes -
# the bare key. So this read never found the value an operator had
# set, always fell back to 0, and 0 means "keep everything": the
# retention policy was configurable in the UI and did nothing.
retentioncount=Setting.get('backups_retentioncount', 0),
retentiondays=Setting.get('backups_retentiondays', 0),
)
if pruned:
warnings.append('pruned {} old revision(s) per retention policy'

View File

@@ -19,14 +19,17 @@ import json
from . import ntlars as ntlarscodec
# Default root of the opaque-backup tree on the SFLD share. Connected PCs write
# here directly (they need SFLD creds - a SYSTEM process hitting a UNC path
# Root of the opaque-backup tree on the site's file share. Connected PCs write
# here directly (they need share creds - a SYSTEM process hitting a UNC path
# without them gets an access-denied that Test-Path reports as "not found").
#
# This is the WEST JEFFERSON path and is only a DEFAULT: the live value is the
# backups_shareroot setting, because a bundled plugin in a multi-site product
# must not hardcode one site's file server.
DEFAULTSHAREROOT = r'\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backups'
# EMPTY BY DESIGN (ADR-015). This used to ship one site's own file server
# as the default, which put one site's internal topology in a bundled plugin and
# in the public mirror, and silently pointed a second site at a server it cannot
# reach. The live value is the backups_shareroot setting; unset, the share kinds
# have no conventional location to offer and say so rather than composing a path
# from somebody else's hostname.
DEFAULTSHAREROOT = ''
def canonicalhash(projection):
@@ -150,10 +153,16 @@ class BackupKind:
Advisory only - the authoritative path is the sharepath the collector
reports, since the PC is what actually wrote the file. This builds the
conventional location for display and for validating a reported path.
Returns '' when no share root is configured. A path built on an empty
root is not a lesser answer, it is a wrong one - it would render as a
relative path and validate a reported path against nothing.
"""
root = shareroot or DEFAULTSHAREROOT
if not root:
return ''
return '{}\\{}\\{}\\{}'.format(
shareroot or DEFAULTSHAREROOT,
machinetype or 'unknown', identifier or 'unknown', self.key)
root, machinetype or 'unknown', identifier or 'unknown', self.key)
class NtlarsKind(BackupKind):