Merge origin/main (West Jeff) into wilm

Bring in the natural-key import fix, Metrics->Monitors rename, PC detail tabbing and panel groups, guest search grants, and the 2026-08-30 security fixes. Reconcile endpoint packaging/installer, migrations, contract version, and tests; keep the delivery uninstaller and 6-step installer alongside main's permission-based RBAC and pin-drift protection.
This commit is contained in:
benjamin.baker
2026-08-31 16:59:07 -04:00
59 changed files with 2859 additions and 180 deletions

View File

@@ -69,7 +69,7 @@
</label>
<label class="field">
<span>Link template</span>
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="scheme://{host}:{port}" />
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
</label>
<label class="field checkbox">

View File

@@ -84,8 +84,10 @@
Hardware and the rest follow. Panels self-order among themselves by
their declared position, and self-hide when a site has no plugin
answering for this asset type. -->
<div class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
<div v-show="activegroup === 'details'" class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
<!-- Left Column -->
<div class="content-column">
@@ -256,7 +258,28 @@
</div>
</div>
<!-- Installed Applications -->
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Software and Activity run FULL WIDTH, not in the two-column flow the
Details tab uses. Both are long lists rather than short fact cards:
an application inventory or an event feed split into balanced columns
reads as two unrelated lists, and the eye has to find where the first
one ended. -->
<div v-show="activegroup === 'software'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="software" />
<div>
<div class="section-card" v-if="installedApps.length > 0">
<h3 class="section-title">Installed Applications</h3>
<div class="app-list">
@@ -276,21 +299,22 @@
</router-link>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ computer.notes }}</p>
</div>
</div>
</div>
<!-- Monitors and Activity are entirely plugin-contributed: what is being
watched on the box, the service state and its controls, and the event
feed all belong to the endpoints plugin, and core knows nothing about
them.
Monitors keeps the two-column flow because it is short cards. -->
<div v-show="activegroup === 'monitors'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="monitors" />
</div>
<div v-show="activegroup === 'activity'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
@@ -305,7 +329,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
@@ -315,6 +339,7 @@ import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
@@ -344,6 +369,37 @@ const hasRecordCheckin = computed(() => {
})
const installedApps = ref([])
const assetPanels = ref(null)
// Which tab is showing. Always opens on Details: it holds what the asset IS,
// which is the answer somebody arriving from a search or a link came for.
const activegroup = ref('details')
// Only offer a tab that has something in it. An empty tab is worse than an
// absent one - it reads as broken rather than as "this site does not collect
// that". Details always shows because core fills it regardless of plugins.
//
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
const tabs = [{ key: 'details', label: 'Details' }]
// Monitors sits second: what is being watched on this box is the question
// asked right after "what is it", and well before its software inventory.
if (has('monitors')) tabs.push({ key: 'monitors', label: 'Monitors' })
if (has('software') || installedApps.value.length > 0) {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
}
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
return tabs
})
// A tab can disappear on refetch - a plugin gets disabled, telemetry stops
// answering. Fall back to Details rather than leaving the page showing nothing.
watch(detailtabs, (tabs) => {
if (!tabs.some((tab) => tab.key === activegroup.value)) activegroup.value = 'details'
})
const restarting = ref(false)
const refreshing = ref(false)
@@ -550,6 +606,7 @@ async function confirmRetire() {
</script>
<style scoped>
/* PC-specific styles - shared styles are in global style.css */
/* Remote-access protocol links */

View File

@@ -2013,3 +2013,26 @@ class ComputersPlugin(BasePlugin):
('computers.edit', 'Edit computers', 'computers'),
('computers.delete', 'Delete computers', 'computers'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Declared because a site chose it: the floor looks PCs up by number.
KNOWN GAP, tracked separately. The computer serializer is
BaseModel.to_dict(), so it publishes every column - serialnumber, the
Azure device and object ids - and the detail route resolves
accessmethods into launch links, each built from that protocol's own
linktemplate, plus the full Communication row. A guest granted this
gets all of it, on the LIST route as well, so it is one request rather
than one per asset.
Do not name schemes here. The templates are site-editable rows in
accessprotocols, so what a link looks like is data, not a constant -
the shipped VNC row is com.realvnc.vncviewer.connect://, not vnc://,
and an earlier version of this docstring said otherwise for months.
The fix is a field allowlist on Computer.to_dict() plus suppressing the
resolved links unless the caller holds a remote-access permission. Do
not widen this hook until that lands; a site enabling guest access
today should weigh it.
"""
return ['computers.view']

View File

@@ -23,7 +23,7 @@ import zipfile
from flask import request, current_app, send_file
from shopdb.api import error_response, ErrorCodes, require_role
from shopdb.api import error_response, ErrorCodes, require_permission
from .routes import endpoints_bp
@@ -66,7 +66,7 @@ def _build_readme():
@endpoints_bp.route('/adoptiontool/package', methods=['POST'])
@require_role('admin')
@require_permission('endpoints.agentpackage')
def package_adoption_tool():
"""Bundle the adoption tool as a downloadable ZIP.

View File

@@ -7,6 +7,7 @@ These routes are the human-facing read surface, gated by user JWT + RBAC.
"""
import hashlib
import hmac
import json
import os
import time
@@ -941,6 +942,43 @@ _CLAIM_HOLD_SECONDS = 25
_CLAIM_POLL_INTERVAL = 1.0
def _pin_holds(command, descriptor):
"""Does the descriptor still carry the digest this command was queued for?
True when nothing was pinned (actions that deliver no content), or when the
descriptor is missing entirely - a vanished script or version already sends
the command through with no descriptor so the agent fails closed, and that
path is not a content swap.
"""
if not command.pinnedsha256:
return True
if descriptor is None:
return True
return hmac.compare_digest(
str(descriptor.get('sha256') or ''), command.pinnedsha256)
def _fail_on_pin_drift(command, action):
"""Refuse a claim whose content changed after the operator approved it."""
command.status = EndpointCommand.STATUS_FAILED
command.resultmessage = (
'Refused: the {} content changed after this command was queued'
.format(action))
command.completedat = _naive_utcnow()
db.session.commit()
current_app.logger.warning(
'Endpoint command %s (%s, asset %s) refused: pinned sha256 %s no '
'longer matches the resolved descriptor',
command.endpointcommandid, action, command.assetid,
command.pinnedsha256)
AuditLog.log('refused', 'EndpointCommand',
entityid=command.endpointcommandid,
entityname=action,
details='content digest changed after enqueue')
db.session.commit()
return ('', 204)
def _reap_stale_commands(asset_id):
"""Expire in-flight commands an agent will never finish for this endpoint.
@@ -1097,14 +1135,39 @@ def _resolve_update_app(target):
if application is None or not application.installpath:
return None, ('Application for appversionid {} has no installer package'
.format(ident))
# PIN THE FILE THAT IS ACTUALLY SERVED.
#
# /api/endpoints/apppackage/<appversionid> resolves the version to its
# APPLICATION and serves application.installpath - there is no
# version-specific artifact behind that route. So version.sha256 pinned
# a digest that need not describe the bytes delivered, and where it was
# null the descriptor carried no digest at all and the agent had nothing
# to verify against. Deriving it from the application, exactly as the
# app branch does, makes the pin describe the delivery.
sha256 = (application.installersha256
or _backfill_app_installer_sha256(application))
if not sha256:
return None, ('Application for appversionid {} has an installer '
'package with no recorded SHA-256, so the delivery '
'cannot be verified'.format(ident))
# Carry the real filename so the agent's Path.GetExtension routes an MSI
# through msiexec. Without it every MSI push failed, because this branch
# ended the URL at the id and the extension was simply absent.
filename = _hosted_package_filename(application)
installpath = '/api/endpoints/apppackage/{}'.format(version.appversionid)
if filename:
installpath = '/api/endpoints/apppackage/app/{}/{}'.format(
application.appid, filename)
return {
'source': 'version',
'appversionid': version.appversionid,
'appid': version.appid,
'appname': application.appname,
'version': version.version,
'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid),
'sha256': version.sha256,
'installpath': installpath,
'sha256': sha256,
'silentargs': version.silentargs,
}, None
@@ -1209,18 +1272,25 @@ def enqueue_endpoint_command(asset_id: int):
# update_app pins a specific application version; verify it resolves to a
# real installer now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint minutes later.
# Digest of the content this push was APPROVED against. Kept on the row so
# the claim path can refuse a descriptor that has changed since; see
# EndpointCommand.pinnedsha256.
pinnedsha256 = None
if action == 'update_app':
_descriptor, resolve_error = _resolve_update_app(target)
descriptor, resolve_error = _resolve_update_app(target)
if resolve_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, resolve_error)
pinnedsha256 = (descriptor or {}).get('sha256')
# run_script pins a ShopDB-hosted script; verify it resolves to a real
# uploaded body now so a broken push is rejected at enqueue rather than
# failing opaquely on the endpoint later.
if action == 'run_script':
_script, script_error = _resolve_run_script(target)
script, script_error = _resolve_run_script(target)
if script_error is not None:
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
pinnedsha256 = (script or {}).get('sha256')
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
@@ -1252,6 +1322,7 @@ def enqueue_endpoint_command(asset_id: int):
targetkind=targetkind if target else None,
status=EndpointCommand.STATUS_PENDING,
requestedby=getattr(current_user, 'userid', None),
pinnedsha256=pinnedsha256,
)
db.session.add(command)
@@ -1312,6 +1383,8 @@ def claim_next_command():
# fail closed rather than 500 the poll.
if command.action == 'update_app':
descriptor, _resolve_error = _resolve_update_app(command.target)
if not _pin_holds(command, descriptor):
return _fail_on_pin_drift(command, 'update_app')
if descriptor is not None:
claim['app'] = descriptor
# run_script rides a resolved script descriptor: the agent gets a
@@ -1322,6 +1395,8 @@ def claim_next_command():
# agent fail closed rather than 500 the poll.
if command.action == 'run_script':
script_descriptor, _script_error = _resolve_run_script(command.target)
if not _pin_holds(command, script_descriptor):
return _fail_on_pin_drift(command, 'run_script')
if script_descriptor is not None:
claim['script'] = script_descriptor
return success_response(claim)
@@ -1351,6 +1426,33 @@ def report_command_result(command_id: int):
return error_response(ErrorCodes.NOT_FOUND,
f'No command {command_id}', http_code=404)
# An agent may only finalize ITS OWN command. The collector key is shared
# fleet-wide, so without this any agent could post a result for any command
# id - marking an install succeeded on a machine that never ran it, which
# writes a false compliance record. Binding to the claimed hostname is a
# floor, not a fix: the credential still does not prove which endpoint is
# calling, and only a per-endpoint credential closes that.
# Optional because the shipped agent does not send it yet; enforced when it
# does, so an updated fleet gets the check without a flag day.
hostname = (request.args.get('hostname') or '').strip()
if hostname:
caller_asset_id = _resolve_asset_for_hostname(hostname)
if caller_asset_id != command.assetid:
return error_response(
ErrorCodes.FORBIDDEN,
'This command belongs to another endpoint', http_code=403)
# Only a claimed command can be finalized. Needs no agent change, and it
# closes two things the hostname check cannot: reporting on a command that
# was never claimed, and overwriting a terminal result - which would let a
# recorded failure be replaced by a success.
if command.status != EndpointCommand.STATUS_RUNNING:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Command {} is {}, not running'.format(
command_id, command.status),
http_code=409)
payload = request.get_json(silent=True) or {}
succeeded = bool(payload.get('succeeded'))
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded

View File

@@ -31,7 +31,8 @@ import zipfile
from flask import request, current_app, send_file
from shopdb.api import success_response, error_response, ErrorCodes, require_role
from shopdb.api import (success_response, error_response, ErrorCodes,
require_permission)
from .routes import endpoints_bp
@@ -166,16 +167,16 @@ FILES
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
SECURITY
SECURITY
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
@@ -456,14 +457,14 @@ def _siteagent_status_payload():
@endpoints_bp.route('/siteagent', methods=['GET'])
@require_role('admin')
@require_permission('endpoints.agentpackage')
def get_siteagent():
"""Report whether the site agent build has a prebuilt binary staged."""
return success_response(_siteagent_status_payload())
@endpoints_bp.route('/siteagent/package', methods=['POST'])
@require_role('admin')
@require_permission('endpoints.agentpackage')
def package_siteagent():
"""Bundle the site agent as a downloadable ZIP.

View File

@@ -38,7 +38,17 @@ def upgrade():
op.create_table(
'endpointapplications',
sa.Column('endpointapplicationid', sa.Integer(), nullable=False),
# BigInteger, not Integer. This is a SNAPSHOT table: every check-in
# deletes the endpoint's rows and reinserts them, so the surrogate key
# burns through values at (endpoints x installed apps) per report and
# never reuses them. A 32-bit key exhausts on MySQL - a few hundred
# endpoints reporting a few hundred apps every 15 minutes reaches 2.1
# billion inside a couple of years, and the failure is an insert that
# stops working forever. SQLite has no such ceiling, so no test would
# have caught it. Changed while the table is still unreleased.
sa.Column('endpointapplicationid',
sa.BigInteger().with_variant(sa.Integer, 'sqlite'),
nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('displayname', sa.String(length=255), nullable=False),
sa.Column('displayversion', sa.String(length=128), nullable=True),

View File

@@ -0,0 +1,51 @@
"""Pin the approved content digest on an endpoint command.
Delivery descriptors (run_script, update_app) are re-resolved when the agent
claims a command, not when an operator queues it. Between those two moments the
underlying script body or installer package can be replaced by anyone who can
edit the applications catalog, and the descriptor's SHA-256 would then describe
the NEW bytes: the agent verifies correctly and runs something the operator
never approved.
pinnedsha256 records the digest as it stood at enqueue. The claim path compares
and refuses the command if it has moved, so a swap fails the push instead of
shipping it.
Null for actions that deliver no content, and null on rows queued before this
migration - those are treated as unpinned and claim as before.
Idempotent: checks the live schema first so a db.create_all() site re-runs clean
on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0005cmdpin'
down_revision = 'endpoints0004apps'
branch_labels = None
depends_on = None
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column('endpointcommands', 'pinnedsha256'):
op.add_column('endpointcommands',
sa.Column('pinnedsha256', sa.String(length=64),
nullable=True))
def downgrade():
if _has_column('endpointcommands', 'pinnedsha256'):
op.drop_column('endpointcommands', 'pinnedsha256')

View File

@@ -172,6 +172,19 @@ class EndpointCommand(db.Model):
# Who queued it (userid) for audit; the agent never sees this.
requestedby = db.Column(db.Integer, nullable=True)
# The content digest as it stood WHEN AN OPERATOR APPROVED THIS PUSH.
#
# Delivery descriptors are re-resolved at claim time, minutes or hours
# later, so without this the bytes an operator approved and the bytes the
# agent runs are two different lookups against mutable content. Whoever can
# replace a script body or an installer package could swap it in the gap
# and the digest would faithfully describe the new bytes.
#
# Set at enqueue for run_script and update_app; null for actions that
# deliver no content. The claim path refuses a command whose descriptor no
# longer matches, so a swap fails the command instead of shipping.
pinnedsha256 = db.Column(db.String(64), nullable=True)
# Agent-reported outcome. resultjson holds structured detail (for example
# the observed MTConnect running state); resultmessage is a short summary.
resultmessage = db.Column(db.Text, nullable=True)
@@ -282,7 +295,16 @@ class EndpointApplication(db.Model):
__tablename__ = 'endpointapplications'
endpointapplicationid = db.Column(db.Integer, primary_key=True)
# BigInteger on MySQL, Integer on SQLite. This table is rewritten wholesale
# on every check-in, so the key never stops climbing and a 32-bit column
# eventually stops accepting inserts - see the migration for the arithmetic.
#
# The variant is not decoration: SQLite only autoincrements a column typed
# exactly INTEGER PRIMARY KEY, so a plain BigInteger makes every insert fail
# with a NOT NULL violation on the test database. Nothing caught that,
# because no test inserts into this table.
endpointapplicationid = db.Column(
db.BigInteger().with_variant(db.Integer, 'sqlite'), primary_key=True)
# The asset this install belongs to. Not unique: many applications per
# endpoint. CASCADE so removing the asset clears its SBOM snapshot.

View File

@@ -78,6 +78,20 @@ def _as_json(value):
return json.dumps(value, separators=(',', ':'), sort_keys=True)
def _clamp(value, length):
"""A string bounded to its column width, or None.
Ingest writes whatever an endpoint reports. A registry value longer than its
column is not a data-quality nuisance on MySQL: in strict mode it raises
Data too long and fails the whole check-in transaction, so one machine with
a malformed Uninstall entry stops reporting telemetry and events entirely.
"""
if value is None:
return None
text = str(value).strip()
return text[:length] or None
class EndpointsPlugin(BasePlugin):
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
@@ -131,10 +145,11 @@ class EndpointsPlugin(BasePlugin):
# including every logged-in username on the floor, because the
# panels and the list were gated on the same string.
#
# It is deliberately NOT named '.view'. guest_grantable_permissions
# opens any '.view' or '.read' permission to the guest role, so this
# name is what keeps the fleet roster off the public surface while
# per-asset telemetry can still be granted.
# The name avoids '.view' for readability, but the NAME no longer
# decides anything: guest_grantable_permissions is a declared list
# now, and this plugin blesses endpoints.view alone below. Adding
# endpoints.fleet there would be what opens the roster, and the
# docstring on that hook says why not to.
('endpoints.fleet',
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
# Issue an allowlisted command to an endpoint agent (for example the
@@ -152,8 +167,19 @@ class EndpointsPlugin(BasePlugin):
# running a staged script or pushing an installer is a strictly
# heavier grant than a routine service/process toggle.
('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
# this can mint a distributable artefact containing the fleet-wide
# command-channel credential. It was require_role('admin'), which
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
('endpoints.agentpackage',
'Generate and download an agent or adoption-tool package '
'(contains a live collector key)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing
# else, so rotating/revoking it stops endpoint-agent traffic without
# touching computers/geenforce collectors. Minted from the settings
@@ -162,6 +188,21 @@ class EndpointsPlugin(BasePlugin):
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Declared because a site chose it: live status on a PC page.
endpoints.view only. NOT endpoints.fleet - that is the roster, and the
whole reason the two were split.
KNOWN GAP, tracked separately: the per-asset panels this opens are
keyed on a small integer asset id and the status panel emits the
logged-in username, so walking ids reconstructs most of the roster the
split exists to protect. Bounding or rate-limiting the per-asset panels
is the fix. Until then a site enabling guest access should know the
split is a speed bump here, not a wall.
"""
return ['endpoints.view']
def get_settings_defaults(self) -> List[Dict]:
"""Settings owned by the endpoints agent-key kill switch.
@@ -310,6 +351,9 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored services reported.',
'position': 12,
# Service state is what is being watched; the Start/Stop
# controls belong beside it, not in a tab of their own.
'group': 'monitors',
},
{
'id': 'endpoints-tracked-processes',
@@ -348,6 +392,8 @@ class EndpointsPlugin(BasePlugin):
'permission': 'endpoints.command',
'empty': 'No monitored processes reported.',
'position': 13,
# observed process state, not something you act on here
'group': 'monitors',
},
{
# One feed instead of two cards. Events and commands answer the
@@ -391,6 +437,8 @@ class EndpointsPlugin(BasePlugin):
},
'empty': 'Nothing recorded for this endpoint yet.',
'position': 20,
# the event and command feed IS the activity tab
'group': 'activity',
},
{
'id': 'endpoints-interfaces',
@@ -410,6 +458,33 @@ class EndpointsPlugin(BasePlugin):
],
'empty': 'No network interfaces reported.',
'position': 16,
# adapter state is watched, not something you act on
'group': 'monitors',
},
{
# Installed-software (SBOM) inventory. Raw discovered list for
# now: no managed-application linkage is surfaced yet.
#
# NOT collapsible. It used to be, and started collapsed, because
# it was low-priority reference data sitting near the bottom of
# one long page. It is now the Software tab's whole reason to
# exist, and somebody who clicked that tab has already said what
# they came for - making them click again to see it is a step
# that asks a question already answered.
'id': 'endpoints-applications',
'title': 'Installed software',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/applications',
'render': 'table',
'columns': [
{'key': 'displayname', 'label': 'Name'},
{'key': 'displayversion', 'label': 'Version'},
{'key': 'publisher', 'label': 'Publisher'},
],
'empty': 'No installed software reported.',
'position': 30,
# the installed-software inventory
'group': 'software',
},
{
# Installed-software (SBOM) inventory. Low-priority reference
@@ -641,11 +716,18 @@ class EndpointsPlugin(BasePlugin):
if not displayname:
warnings.append('skipped application with no displayname')
continue
# TRUNCATE EVERY STRING, not just the name. On MySQL in strict
# mode an over-long value raises Data too long and takes down the
# WHOLE check-in transaction, so one machine with a malformed
# Uninstall registry entry stops reporting entirely - telemetry,
# events and all - until somebody uninstalls the offender. SQLite
# does not enforce declared lengths, so no test here would ever see
# it. displayname was already clamped; these two were not.
db.session.add(EndpointApplication(
assetid=assetid,
displayname=displayname[:255],
displayversion=(item.get('displayversion') or None),
publisher=(item.get('publisher') or None),
displayversion=_clamp(item.get('displayversion'), 128),
publisher=_clamp(item.get('publisher'), 255),
))
stored += 1
return stored

View File

@@ -1,249 +0,0 @@
"""Endpoints plugin smoke + collector-contract tests.
Asserts the plugin loads cleanly, satisfies the framework contract, and that
its ADR-006 collector schema is well formed. Extend with DB-backed ingest tests
as the plugin grows.
"""
from flask import Blueprint
from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
from plugins.endpoints.commands import (
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
requires_target,
)
def test_endpoints_plugin_meta_is_valid():
"""meta returns a PluginMeta with the expected name and prefix."""
plugin = EndpointsPlugin()
assert plugin.meta.name == 'endpoints'
assert plugin.meta.api_prefix == '/api/endpoints'
assert 'computers' in plugin.meta.dependencies
def test_endpoints_plugin_get_blueprint_returns_blueprint():
"""get_blueprint returns a Flask Blueprint, not None."""
plugin = EndpointsPlugin()
assert isinstance(plugin.get_blueprint(), Blueprint)
def test_endpoints_plugin_get_models_returns_models():
"""get_models returns the two telemetry tables."""
plugin = EndpointsPlugin()
models = plugin.get_models()
assert len(models) >= 2
for model in models:
assert hasattr(model, '__tablename__')
def test_endpoints_collector_schema_identity_is_hostname():
"""ADR-006 schema declares hostname as the identity field."""
plugin = EndpointsPlugin()
schema = plugin.get_collector_schema()
assert schema is not None
assert schema['identityfield'] == 'hostname'
assert 'hostname' in schema['fields']['required']
def test_endpoints_curated_event_ids_include_core_security_ids():
"""The curated catalog covers the agreed logon/stability IDs."""
for eventid in (4624, 4625, 4634, 4672, 4697, 4698, 41, 4618, 55, 7011,
10016, 1001):
assert eventid in CURATED_EVENT_IDS
def test_curated_ids_match_the_event_catalog():
"""Ingest filter and display catalog cannot drift apart."""
assert CURATED_EVENT_IDS == frozenset(EVENT_CATALOG)
def test_critical_event_ids_are_a_subset_of_the_catalog():
"""Dashboard stability card only queries known critical IDs."""
assert set(CRITICAL_EVENT_IDS)
assert set(CRITICAL_EVENT_IDS).issubset(set(EVENT_CATALOG))
def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
assert len(panels) == 5
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
assert panel['render'] in ('keyvalue', 'list', 'table')
def test_services_and_processes_are_split_into_two_filtered_cards():
"""The single services card is now two cards filtered by row kind, both
reading the same services snapshot so a future top-20 collector is just
another filtered panel over the same feed."""
plugin = EndpointsPlugin()
panels = {p['id']: p for p in plugin.get_asset_panels()}
services = panels['endpoints-tracked-services']
processes = panels['endpoints-tracked-processes']
assert services['title'] == 'Tracked Services'
assert processes['title'] == 'Tracked Processes'
assert services['filter'] == {'key': 'kind', 'equals': 'service'}
assert processes['filter'] == {'key': 'kind', 'equals': 'process'}
# Both slice one endpoint feed.
assert services['endpoint'] == processes['endpoint']
assert services['endpoint'].endswith('/services')
# Command wiring carries over to both cards.
assert services['permission'] == 'endpoints.command'
assert processes['permission'] == 'endpoints.command'
def test_recent_events_card_is_last_and_starts_collapsed():
"""Events is the lowest-priority card: forced to the bottom of the column
and collapsed by default."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
events = next(p for p in panels if p['id'] == 'endpoints-events')
assert events['collapsedDefault'] is True
assert events['position'] == max(p['position'] for p in panels)
def test_endpoints_dashboard_widgets_declared():
"""Quiet + stability exception cards are declared and hide when empty."""
plugin = EndpointsPlugin()
widgets = plugin.get_dashboard_widgets()
ids = {w['id'] for w in widgets}
assert {'endpoints-quiet', 'endpoints-stability'} <= ids
for widget in widgets:
assert widget['render'] == 'exceptions'
assert widget['empty'] == 'hide'
assert widget['permission'] == 'endpoints.view'
def test_endpoints_navigation_item_points_at_fleet_view():
"""The fleet list view is exposed as a single /endpoints nav item."""
plugin = EndpointsPlugin()
items = plugin.get_navigation_items()
routes = {item['route'] for item in items}
assert '/endpoints' in routes
def test_system_restart_is_allowlisted_and_policy_selectable():
"""Restart is a real allowlisted action, so the policy builder (which
reads COMMAND_ALLOWLIST) surfaces it as a selectable action."""
assert is_allowed_action('system_restart')
assert 'system_restart' in COMMAND_ALLOWLIST
assert COMMAND_ALLOWLIST['system_restart']['label'] == 'Restart computer'
def test_system_restart_requires_the_elevated_restart_permission():
"""Restart maps to endpoints.restart so the enqueue route can demand a
heavier grant than the routine endpoints.command toggle. Routine actions
carry no elevated requirement."""
assert elevated_permission_for('system_restart') == 'endpoints.restart'
assert elevated_permission_for('mtconnect_start') is None
def test_endpoints_declares_the_restart_permission():
"""The dedicated restart permission is declared for role-grid seeding."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.restart' in permissions
assert 'endpoints.command' in permissions
def test_collect_now_is_allowlisted_targetless_and_routine():
"""collect_now forces an off-cadence telemetry report. It is allowlisted so
the enqueue route accepts it, carries no wire target (fixed action), and
needs no elevated grant beyond endpoints.command."""
assert is_allowed_action('collect_now')
assert 'collect_now' in COMMAND_ALLOWLIST
assert not requires_target('collect_now')
assert elevated_permission_for('collect_now') is None
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
"""The two software-delivery actions are allowlisted with a targetkind so
the enqueue route accepts them and carries their subject on the wire:
run_script carries a script name, update_app carries an appversionid."""
for action in ('run_script', 'update_app'):
assert is_allowed_action(action)
assert action in COMMAND_ALLOWLIST
assert requires_target(action)
assert COMMAND_ALLOWLIST['run_script']['targetkind'] == 'script'
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
def test_delivery_actions_require_the_elevated_deploy_permission():
"""run_script and update_app both demand endpoints.deploy ON TOP OF
endpoints.command, so pushing software is a strictly heavier grant than a
routine service/process toggle."""
assert elevated_permission_for('run_script') == 'endpoints.deploy'
assert elevated_permission_for('update_app') == 'endpoints.deploy'
def test_endpoints_declares_the_deploy_permission():
"""The dedicated software-delivery permission is declared for role seeding."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.deploy' in permissions
def test_known_payload_keys_cover_persisted_and_relayed_fields():
"""The ingest allow-set must list every key the handler consumes today, so
those keys are never mislogged as 'unpersisted'. Anything the agent sends
outside this set is logged for observability before columns exist."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
persisted_or_relayed = {
'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype',
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
'scheduledtasks', 'events',
}
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
"""Disk/bios/memory inventory is collected at the agent before the server
persists it. Those keys must be OUTSIDE the known set so ingest logs them
rather than dropping them silently."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'applications'):
assert key not in KNOWN_PAYLOAD_KEYS
def test_telemetrymeta_route_is_registered_for_freshness_gating():
"""The WebUI gates panel refreshes on lastreportedat advancing, so the
lightweight freshness probe must be exposed on the plugin blueprint."""
from flask import Flask
application = Flask(__name__)
application.register_blueprint(EndpointsPlugin().get_blueprint())
rules = {rule.rule for rule in application.url_map.iter_rules()}
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)
def test_fleet_list_route_resolves_without_trailing_slash():
"""The Fleet list route must match the slash-less URL the SPA calls.
The list is the only route on the bare blueprint prefix. If it demands a
trailing slash, Flask 308-redirects /api/endpoints to /api/endpoints/, and
behind the /shopdb reverse proxy that redirect drops the path prefix and
the list call fails while the detail routes (which carry a path suffix)
keep working. strict_slashes=False makes both forms resolve directly.
"""
from flask import Flask
application = Flask(__name__)
application.register_blueprint(
EndpointsPlugin().get_blueprint(), url_prefix='/api/endpoints')
adapter = application.url_map.bind('localhost')
endpoint, _ = adapter.match('/api/endpoints', method='GET')
assert endpoint.endswith('list_endpoints')
endpoint_slash, _ = adapter.match('/api/endpoints/', method='GET')
assert endpoint_slash.endswith('list_endpoints')

View File

@@ -21,7 +21,7 @@ from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, cache, success_response, error_response, ErrorCodes, require_permission,
require_any_permission,
authorized_service_token, Setting, Application,
authorized_service_token, Setting, Application, client_ip,
)
SHAREROOT_SETTING = 'geenforce_share_root'
@@ -207,12 +207,14 @@ PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60
def _client_ip():
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop
(mirrors shopdb.core.api.auth._login_ip)."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr or 'unknown'
"""Caller IP for rate limiting.
Uses the shared helper, which resolves from remote_addr. The raw
X-Forwarded-For hop is client-supplied, so keying a rate limit on it lets a
caller rotate the header and get an unbounded budget - the same reasoning
_trusted_client_ip() above already applies to the allowlist.
"""
return client_ip(request) or 'unknown'
def _payload_max_bytes():

View File

@@ -74,6 +74,25 @@ class GuidesPlugin(BasePlugin):
('guides.delete', 'Delete guides', 'guides'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""A procedure is for whoever is standing at the machine.
Guides are written to be followed on the floor, by people who often
have no account and no reason to get one. The nav item and the API
route both already reason about a signed-out reader holding this, so
the grant was intended from the start; it qualified under the old
.view suffix rule and is written down here now that a grant has to be
declared.
What it reaches is bounded by the routes, not by this: list_guides
returns published guides only, matching search against the PUBLISHED
revision so draft wording cannot leak through a hit, and a guest can
never take the author branches because a guest has no user id. Guide
history stays with the editors. Publishing, editing and deleting are
separate permissions and none of them is grantable.
"""
return ['guides.view']
def get_navigation_items(self) -> List[Dict]:
# Position 8, directly under Slides (7), rather than down in the
# Information section with the reference lists. A guide is something you

View File

@@ -1,30 +0,0 @@
"""Printedparts plugin smoke tests.
Asserts the plugin loads cleanly and satisfies the framework contract.
Replace and extend with domain tests as you build the plugin out.
"""
from plugins.printedparts.plugin import PrintedpartsPlugin
def test_printedparts_plugin_meta_is_valid():
"""PrintedpartsPlugin.meta returns a PluginMeta with the expected name."""
plugin = PrintedpartsPlugin()
assert plugin.meta.name == 'printedparts'
assert plugin.meta.api_prefix == '/api/printedparts'
def test_printedparts_plugin_get_blueprint_returns_blueprint():
"""get_blueprint returns a Flask Blueprint, not None."""
from flask import Blueprint
plugin = PrintedpartsPlugin()
assert isinstance(plugin.get_blueprint(), Blueprint)
def test_printedparts_plugin_get_models_returns_a_model():
"""get_models returns a list with at least one SQLAlchemy model."""
plugin = PrintedpartsPlugin()
models = plugin.get_models()
assert len(models) >= 1
for model in models:
assert hasattr(model, '__tablename__')

View File

@@ -2659,6 +2659,9 @@ def dashboard_supplies():
# dashboard card and the toner report were both doing.
'levelid': printer.get('levelid'),
'iscritical': any(s['status'] == 'critical' for s in depleted),
# The emptiest cartridge on this printer, which is what decides the
# row's place in the queue. depleted is sorted and non-empty here.
'worstremaining': depleted[0]['remaining'],
'supplies': [{
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
supply.get('remaining')),
@@ -2667,7 +2670,18 @@ def dashboard_supplies():
} for supply in depleted],
})
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
# Emptiest first. This sorted on iscritical then NAME, and critical is
# everything at or below 5%, so a cartridge at 5% and one at 0% tied on the
# first key and the alphabet decided which came first. That is bad ordering
# on its own and worse than it looks: the dashboard queue takes only the
# first few rows per source, so the printer that is actually empty was
# being truncated away while a fuller one held its place.
#
# remaining is already normalised for waste cartridges (a full waste bin
# reads as low remaining), so one ascending sort is worst-first for every
# supply type, and iscritical falls out of it rather than needing its own
# key. Name only breaks ties.
rows.sort(key=lambda r: (r['worstremaining'], r['printername']))
# Envelope rather than a bare list, so the card can say WHY it has no rows.
# An empty card and a blind card must not look the same.
return success_response({'rows': rows,

View File

@@ -37,7 +37,7 @@ from flask import current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db, cache, Setting,
db, cache, Setting, client_ip,
success_response, error_response, ErrorCodes,
)
@@ -117,8 +117,9 @@ def _guest_ratelimited():
person has more addresses - but it stops an open endpoint being an easy
place to park data or fill a disk.
"""
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr or 'unknown')
# Not the raw X-Forwarded-For hop: the caller sends that, so rotating it
# gives an unbounded budget and the limit stops being a limit.
address = client_ip(request) or 'unknown'
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
key = f'securesendguest:{address}:{bucket}'
used = cache.get(key) or 0

View File

@@ -85,8 +85,14 @@ class ToolsPlugin(BasePlugin):
A guest-created link records no creator. The audit trail says when and
from where, and never who. That is the cost, and it is why this is the
only write in the product offered to guests at all.
tools.view rides along because tools.secrets without it is a page a
guest holds permission to use and no way to reach: it gates the nav
entry, and the browser tools behind it (codes, conversions) run client
side and touch no data at all. Declared explicitly rather than inferred
from the .view suffix, which is what the grantable rule now asks for.
"""
return ['tools.secrets']
return ['tools.secrets', 'tools.view']
def get_settings_defaults(self) -> List[dict]:
"""The one switch this plugin owns.

View File

@@ -174,5 +174,13 @@ class WarrantyPlugin(BasePlugin):
('warranty.delete', 'Delete warranties', 'warranty'),
]
def get_guest_grantable_permissions(self) -> List[str]:
"""Coverage dates and vendor for an asset somebody is standing at.
Nothing here is a credential and nothing reaches a supplier portal; the
provider lookups run server-side with keys the caller never sees.
"""
return ['warranty.view']
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})")