Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user