- Enable SAML SSO for enterprise authentication. Users can sign in using your organization's
- identity provider (Azure AD, Okta, etc.). Local login can remain enabled as a fallback.
+ When it ships, users will sign in through your organization's identity
+ provider (Azure AD, Okta, etc.), with local login available as a
+ fallback.
@@ -148,15 +162,20 @@ const {
// Per-page connection status (stays with the page, not the composable).
const samlStatus = computed(() => {
if (!settings.saml_enabled) return 'inactive'
- if (!settings.saml_idp_metadata_url || !settings.saml_entity_id) return 'warning'
+ // 'pending' rather than a healthy state even when every field is filled in:
+ // nothing is connected until the feature exists.
return 'pending'
})
+// Never says "available". It used to report "Configured - SAML login
+// available" as soon as two fields held text, which is a claim about a feature
+// that does not exist: an administrator could reasonably read that as SSO
+// being live and stop issuing local accounts.
const samlMessage = computed(() => {
- if (!settings.saml_enabled) return 'Disabled - using local authentication only'
- if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not configured'
- if (!settings.saml_entity_id) return 'Entity ID not configured'
- return 'Configured - SAML login available'
+ if (!settings.saml_enabled) return 'Off - local accounts only'
+ if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not set'
+ if (!settings.saml_entity_id) return 'Entity ID not set'
+ return 'Settings saved - waiting on SAML support, local accounts still in use'
})
onMounted(loadSettings)
diff --git a/migrations/versions/7d39_audit_retention_key_merge.py b/migrations/versions/7d39_audit_retention_key_merge.py
new file mode 100644
index 00000000..3ac412dd
--- /dev/null
+++ b/migrations/versions/7d39_audit_retention_key_merge.py
@@ -0,0 +1,95 @@
+"""Merge the second audit-retention setting into the one that is actually read.
+
+Two keys existed for one setting. shopdb/core/services/auditretention.py reads
+`audit_retentiondays` (seeded under category 'site', default 0). A second key
+`audit_retention_days` was seeded under category 'audit' with a default of 90,
+and the Audit & Logging settings page wrote to that one - so an administrator
+set a retention period, reloaded, saw it persisted, and nothing ever consulted
+it. Retention stayed off while the page said otherwise.
+
+The page now writes the key that is read, and the duplicate is out of the seed
+list. This handles the sites that already have it: if an operator set a real
+value on the dead key and never set the live one, that intent is carried over
+rather than dropped. Their intent was "keep 30 days", and the fix should not
+silently discard it.
+
+Not carried over when the live key already holds a non-default value - that one
+was set through the page that worked, and it wins.
+
+The dead key is then deleted. Leaving it would preserve the ambiguity that
+caused this, and nothing reads it.
+
+Revision ID: 7d39_audit_retention_key_merge
+Revises: 7d38_widen_importbatch_status
+"""
+from alembic import op
+
+import sqlalchemy as sa
+
+
+revision = '7d39_audit_retention_key_merge'
+down_revision = '7d38_widen_importbatch_status'
+branch_labels = None
+depends_on = None
+
+DEAD = 'audit_retention_days'
+LIVE = 'audit_retentiondays'
+
+
+def _value(bind, key):
+ return bind.execute(
+ sa.text('SELECT value FROM settings WHERE `key` = :key'
+ if bind.dialect.name == 'mysql'
+ else 'SELECT value FROM settings WHERE "key" = :key'),
+ {'key': key}).scalar()
+
+
+def upgrade():
+ bind = op.get_bind()
+ if not sa.inspect(bind).has_table('settings'):
+ return
+
+ dead = _value(bind, DEAD)
+ if dead is None:
+ return
+
+ live = _value(bind, LIVE)
+ # '90' was the dead key's seeded default, so it is not evidence of intent.
+ operator_set_dead = dead not in (None, '', '90')
+ live_is_default = live in (None, '', '0')
+
+ if operator_set_dead and live_is_default:
+ if live is None:
+ bind.execute(sa.text(
+ 'INSERT INTO settings (`key`, value, valuetype, category, '
+ 'description, createddate, modifieddate, isactive) VALUES '
+ '(:key, :value, :valuetype, :category, :description, '
+ 'NOW(), NOW(), 1)'
+ if bind.dialect.name == 'mysql' else
+ 'INSERT INTO settings ("key", value, valuetype, category, '
+ 'description, createddate, modifieddate, isactive) VALUES '
+ '(:key, :value, :valuetype, :category, :description, '
+ "datetime('now'), datetime('now'), 1)"),
+ {'key': LIVE, 'value': dead, 'valuetype': 'integer',
+ 'category': 'site',
+ 'description': 'Delete audit log entries older than this many '
+ 'days. 0 keeps everything forever.'})
+ else:
+ bind.execute(sa.text(
+ 'UPDATE settings SET value = :value WHERE `key` = :key'
+ if bind.dialect.name == 'mysql' else
+ 'UPDATE settings SET value = :value WHERE "key" = :key'),
+ {'value': dead, 'key': LIVE})
+
+ bind.execute(sa.text(
+ 'DELETE FROM settings WHERE `key` = :key'
+ if bind.dialect.name == 'mysql' else
+ 'DELETE FROM settings WHERE "key" = :key'), {'key': DEAD})
+
+
+def downgrade():
+ """Deliberately a no-op.
+
+ Re-creating a key that nothing reads, to sit beside the one that works,
+ would restore the defect. The live setting keeps whatever value it holds.
+ """
diff --git a/migrations/versions/7d40_machine_maintenance_dates_are_dates.py b/migrations/versions/7d40_machine_maintenance_dates_are_dates.py
new file mode 100644
index 00000000..74b48014
--- /dev/null
+++ b/migrations/versions/7d40_machine_maintenance_dates_are_dates.py
@@ -0,0 +1,73 @@
+"""Store a maintenance date as a DATE, because that is what it is.
+
+lastmaintenancedate and nextmaintenancedate were DATETIME columns holding
+midnight. BaseModel.to_dict serialises a datetime as isoformat + 'Z', so
+2026-12-15 00:00:00 went out as "2026-12-15T00:00:00Z" and a browser west of
+UTC rendered it as 14 December. The maintenance panel showed every date one
+calendar day early on this site, and would show it a day LATE east of UTC.
+
+There is no time in a maintenance date. Nobody records that a machine was
+serviced at midnight; the form offers a date picker, the CSV importer takes
+'2026-06-01', and the value is compared against other dates. Every sibling
+already models it correctly - measuringtools.lastcalibrationdate,
+warranties.startdate and enddate are all db.Date - so this is machines being
+the odd one out rather than a new decision.
+
+As a Date, to_dict emits "2026-12-15" with no timezone marker at all, and
+nothing can shift it.
+
+MySQL truncates the (midnight) time component on the ALTER, so no data moves.
+SQLite stores what it is given and does not enforce column types, so the
+conversion is a no-op there and is skipped.
+
+Revision ID: 7d40_machine_maintenance_dates
+Revises: 7d39_audit_retention_key_merge
+"""
+from alembic import op
+
+import sqlalchemy as sa
+
+
+revision = '7d40_machine_maintenance_dates'
+down_revision = '7d39_audit_retention_key_merge'
+branch_labels = None
+depends_on = None
+
+COLUMNS = ('lastmaintenancedate', 'nextmaintenancedate')
+
+
+def _machines_table(bind):
+ inspector = sa.inspect(bind)
+ if 'machines' not in inspector.get_table_names():
+ return None
+ return {column['name'] for column in inspector.get_columns('machines')}
+
+
+def upgrade():
+ bind = op.get_bind()
+ if bind.dialect.name == 'sqlite':
+ return
+ present = _machines_table(bind)
+ if not present:
+ return
+ for column in COLUMNS:
+ if column in present:
+ op.alter_column('machines', column,
+ existing_type=sa.DateTime(),
+ type_=sa.Date(),
+ existing_nullable=True)
+
+
+def downgrade():
+ bind = op.get_bind()
+ if bind.dialect.name == 'sqlite':
+ return
+ present = _machines_table(bind)
+ if not present:
+ return
+ for column in COLUMNS:
+ if column in present:
+ op.alter_column('machines', column,
+ existing_type=sa.Date(),
+ type_=sa.DateTime(),
+ existing_nullable=True)
diff --git a/migrations/versions/7d41_drop_guest_tools_secrets.py b/migrations/versions/7d41_drop_guest_tools_secrets.py
new file mode 100644
index 00000000..50ac629d
--- /dev/null
+++ b/migrations/versions/7d41_drop_guest_tools_secrets.py
@@ -0,0 +1,56 @@
+"""Revoke a guest grant that never did anything, before it locks the role.
+
+tools.secrets was declared guest-grantable, so the role grid offered it and
+stored it - and the secure-send route never consulted the guest ROLE at all. It
+decides on the securesend_allow_guests setting, which fails closed and carries
+the rate limit. A site that ticked the box got no change in behaviour, in either
+direction.
+
+The plugin no longer declares it grantable. That alone would repeat the deadlock
+fixed in 723a4ff: update_role refuses the WHOLE request when a submitted
+permission is not grantable, and the role grid resubmits what the role already
+holds - so a site whose guest role holds tools.secrets could no longer save the
+guest role at all, including to change something else entirely.
+
+So the grant is removed here, from the one role that could hold it. Nothing is
+lost: it granted nothing. Guest secret-link creation stays exactly where it was,
+under securesend_allow_guests.
+
+Only the GUEST role is touched. A signed-in role holding tools.secrets is
+unaffected - that permission works normally for an account.
+
+Revision ID: 7d41_drop_guest_tools_secrets
+Revises: 7d40_machine_maintenance_dates
+"""
+from alembic import op
+
+import sqlalchemy as sa
+
+
+revision = '7d41_drop_guest_tools_secrets'
+down_revision = '7d40_machine_maintenance_dates'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ tables = set(inspector.get_table_names())
+ if not {'roles', 'permissions', 'rolepermissions'} <= tables:
+ return
+
+ bind.execute(sa.text(
+ 'DELETE FROM rolepermissions WHERE roleid IN '
+ '(SELECT roleid FROM roles WHERE rolename = :guest) '
+ 'AND permissionid IN '
+ '(SELECT permissionid FROM permissions WHERE name = :permission)'),
+ {'guest': 'guest', 'permission': 'tools.secrets'})
+
+
+def downgrade():
+ """Deliberately a no-op.
+
+ Re-granting a permission that nothing reads would restore the confusion
+ rather than any capability.
+ """
diff --git a/plugins/backups/api/routes.py b/plugins/backups/api/routes.py
index 26953834..06cec5fb 100644
--- a/plugins/backups/api/routes.py
+++ b/plugins/backups/api/routes.py
@@ -116,13 +116,25 @@ def asset_revisions(assetid):
revisions = query.order_by(BackupRevision.backuprevisionid.desc()).all()
- # "Latest" is per kind, not per asset, so an unfiltered listing still marks
- # the current revision of each kind correctly.
+ # "Latest" is per kind AND per source chain. Per kind alone was wrong on
+ # the case this exists for: NTLARS settings describe a machine but are read
+ # off the controlling PC, so a machine with two reporting PCs has two
+ # independent chains of the same kind. Marking only the highest revision id
+ # meant the second PC's current revision was badged as history and lost its
+ # "last verified" stamp - the machine looked like it had one source that
+ # kept re-reporting, rather than two.
+ #
+ # sourcehostname is the chain: it records which PC produced the revision.
+ # A revision with none (an older row, or a share-drop with no host) forms
+ # its own chain under the empty key, which keeps the per-kind behaviour for
+ # everything that has only ever had one source.
seen = set()
out = []
for revision in revisions:
- islatest = revision.backupkind not in seen
- seen.add(revision.backupkind)
+ chain = (revision.backupkind,
+ (revision.sourcehostname or '').strip().lower())
+ islatest = chain not in seen
+ seen.add(chain)
out.append(_summary(revision, islatest=islatest))
return success_response(out)
@@ -297,6 +309,23 @@ def diff_revision(backuprevisionid):
againstid = request.args.get('against', type=int)
if againstid:
other = db.session.get(BackupRevision, againstid)
+ # It has to belong to the SAME asset. Any revision id was accepted, so
+ # ?against=
rendered that asset's
+ # configuration values as the "before" side of this asset's history -
+ # a diff that reads as a change on this machine and describes a
+ # different one. The default branch below has always been asset-scoped;
+ # only the explicit one was not.
+ if other is not None and other.assetid != revision.assetid:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'Revision {} belongs to a different asset and cannot be '
+ 'compared against this one'.format(againstid))
+ if other is not None and other.backupkind != revision.backupkind:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'Revision {} is a {} backup and this one is {} - there is '
+ 'nothing to compare'.format(againstid, other.backupkind,
+ revision.backupkind))
else:
# The previous revision FROM THE SAME PC. Several PCs can share a
# machine number, and diffing across them reported one device's COM port
diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py
index 9adb608a..c455f32d 100644
--- a/plugins/computers/api/routes.py
+++ b/plugins/computers/api/routes.py
@@ -7,7 +7,7 @@ from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, Audit
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
-from shopdb.api import require_permission, require_role, apply_import_timestamps
+from shopdb.api import require_permission, require_role, apply_import_timestamps, intarg
computers_bp = Blueprint('computers', __name__)
@@ -258,13 +258,42 @@ def _computer_access_links(comp, domain=None):
return links
+def _ip_comtype():
+ """The 'IP' communication type, created if this site has none.
+
+ Both the create and the update path did `if ip_comtype:` and silently did
+ nothing when the row was missing - so a person typed an address, saved, got
+ a 200, and the address was gone. A missing reference row is a seeding gap,
+ not a reason to discard what somebody typed.
+ """
+ comtype = CommunicationType.query.filter_by(comtype='IP').first()
+ if comtype is None:
+ comtype = CommunicationType(comtype='IP', description='IP address')
+ db.session.add(comtype)
+ db.session.flush()
+ return comtype
+
+
def _sync_access_methods(comp, data):
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
- don't touch access aren't affected."""
+ don't touch access aren't affected.
+
+ Returns a {'old': [...], 'new': [...]} change entry when the set actually
+ moved, or None. The caller records it: which remote-access protocols reach
+ a shop-floor PC, and on which ports, is exactly the kind of change an audit
+ log exists to remember, and this wrote nothing at all.
+ """
if 'accessmethods' not in data:
- return
+ return None
desired = data.get('accessmethods') or []
+
+ def describe(rows):
+ return sorted('%s:%s' % (row.protocolid, row.portoverride or 'default')
+ for row in rows)
+
+ before = describe(ComputerAccess.query.filter_by(
+ computerid=comp.computerid).all())
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
seen = set()
for m in desired:
@@ -287,6 +316,13 @@ def _sync_access_methods(comp, data):
isactive=True,
))
+ db.session.flush()
+ after = describe(ComputerAccess.query.filter_by(
+ computerid=comp.computerid).all())
+ if before != after:
+ return {'old': before, 'new': after}
+ return None
+
# =============================================================================
# Computers CRUD
@@ -380,20 +416,20 @@ def list_computers():
)
# Computer type filter
- if type_id := request.args.get('typeid', request.args.get('type_id')):
- query = query.filter(Computer.computertypeid == int(type_id))
+ if (type_id := intarg('typeid', intarg('type_id'))) is not None:
+ query = query.filter(Computer.computertypeid == type_id)
# OS filter
- if os_id := request.args.get('osid', request.args.get('os_id')):
- query = query.filter(Computer.osid == int(os_id))
+ if (os_id := intarg('osid', intarg('os_id'))) is not None:
+ query = query.filter(Computer.osid == os_id)
# Location filter
- if location_id := request.args.get('locationid', request.args.get('location_id')):
- query = query.filter(Asset.locationid == int(location_id))
+ if (location_id := intarg('locationid', intarg('location_id'))) is not None:
+ query = query.filter(Asset.locationid == location_id)
# Business unit filter
- if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
- query = query.filter(Asset.businessunitid == int(bu_id))
+ if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
+ query = query.filter(Asset.businessunitid == bu_id)
# Shopfloor filter (by the Shopfloor computer type)
if shopfloor := request.args.get('shopfloor'):
@@ -587,7 +623,7 @@ def create_computer():
# Optional primary IP communication
if data.get('ipaddress'):
- ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
+ ip_comtype = _ip_comtype()
if ip_comtype:
db.session.add(Communication(
assetid=asset.assetid,
@@ -596,7 +632,9 @@ def create_computer():
isprimary=True,
))
- # Remote-access protocols
+ # Remote-access protocols. The return value is not recorded here: a create
+ # is already described in full by its 'created' entry, and there is no
+ # previous state to compare against.
_sync_access_methods(comp, data)
# Preserve legacy timestamps in import mode (no-op otherwise)
@@ -697,20 +735,27 @@ def update_computer(computer_id: int):
ip = (data.get('ipaddress') or '').strip()
primary = Communication.query.filter_by(
assetid=asset.assetid, isprimary=True).first()
+ # Recorded, like every other field on this form. The address a PC
+ # answers on is how somebody finds it later; changing it wrote no audit
+ # entry at all, so a machine that moved had no history of moving.
+ oldip = primary.ipaddress if primary else None
+ if (oldip or None) != (ip or None):
+ changes['ipaddress'] = {'old': oldip, 'new': ip or None}
if ip:
if primary:
primary.ipaddress = ip
else:
- ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
- if ip_comtype:
- db.session.add(Communication(
- assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
- ipaddress=ip, isprimary=True))
+ db.session.add(Communication(
+ assetid=asset.assetid,
+ comtypeid=_ip_comtype().comtypeid,
+ ipaddress=ip, isprimary=True))
elif primary:
primary.ipaddress = None
# Remote-access protocols
- _sync_access_methods(comp, data)
+ accesschange = _sync_access_methods(comp, data)
+ if accesschange:
+ changes['accessmethods'] = accesschange
# Audit log if there were changes
if changes:
diff --git a/plugins/computers/frontend/views/BusinessPoliciesList.vue b/plugins/computers/frontend/views/BusinessPoliciesList.vue
index e2f3b971..db4d7a4e 100644
--- a/plugins/computers/frontend/views/BusinessPoliciesList.vue
+++ b/plugins/computers/frontend/views/BusinessPoliciesList.vue
@@ -2,7 +2,14 @@
+
+
+
Site policies are not enabled on this site.
+
Nothing can be created here until the feature is turned on.
+ The list below is empty because the feature is off, not
+ because nothing has been configured.
@@ -110,6 +117,7 @@ const toast = useToast()
const policies = ref([])
const configPolicies = ref([])
const loading = ref(true)
+const featureOff = ref(false)
const showModal = ref(false)
const editing = ref(null)
@@ -135,6 +143,10 @@ async function loadData() {
configPolicies.value = config.data.data || []
} catch (err) {
console.error('Error loading business policies:', err)
+ // The API answers 404 'Policy feature is not enabled' when the
+ // flag is off. Without this the page rendered its empty table
+ // and an Add button for a feature that cannot accept one.
+ featureOff.value = err?.response?.status === 404
} finally {
loading.value = false
}
@@ -214,6 +226,13 @@ async function remove(p) {