harden CSV import reconciliation and preserve saved model selections

Default fuzzy reconcile threshold to 0 and refuse merges when digit runs differ so numbered labels like Bay 311 never bind to Bay 310. Keep edit forms from clearing saved model fields when category filtering hides untyped rows. Includes in-progress computers policy and endpoints command feature work.
This commit is contained in:
benjamin.baker
2026-08-28 09:29:09 -04:00
parent d68c6db9ab
commit 01734e5449
46 changed files with 4241 additions and 52 deletions

View File

@@ -64,6 +64,7 @@ ZABBIX_TOKEN=
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
# COLLECTOR_API_KEY=
# COLLECTOR_API_KEY_COMPUTERS=
# COLLECTOR_API_KEY_ENDPOINTS=
# ---- Trusted plugin publisher keys (ADR-013, optional) ----
# Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used

View File

@@ -72,6 +72,7 @@ name. Spell out: `config context manager request response environment utilities`
with quantities are standalone tables, not assets.
- **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the
signed transaction row it reflects.
- **Configuration for endpoint agent**: The endpoint agent's live collector key and base URL are provisioned in the Windows registry `HKLM\SOFTWARE\GE\ShopDB` (values `CollectorKey`, `BaseUrl`), not committed in `appsettings.json`; `appsettings.json` holds only non-secret runtime settings.
## Before you finish a change

View File

@@ -61,6 +61,8 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ZABBIX_URL: ${ZABBIX_URL:-}
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-}
COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-}
ports:
- "${API_PORT:-5001}:5001"
volumes:

View File

@@ -224,6 +224,50 @@ export const computersApi = {
remove(id) {
return api.delete(`/computers/protocols/${id}`)
}
},
// Configuration/Business policies (RMM-style). Gated server-side by the
// computers_configpolicies_enabled setting; routes return 404 while off.
policies: {
actions() {
return api.get('/computers/policies/actions')
},
effective(assetid) {
return api.get(`/computers/policies/effective/${assetid}`)
},
configuration: {
list() {
return api.get('/computers/policies/configuration')
},
get(id) {
return api.get(`/computers/policies/configuration/${id}`)
},
create(data) {
return api.post('/computers/policies/configuration', data)
},
update(id, data) {
return api.put(`/computers/policies/configuration/${id}`, data)
},
remove(id) {
return api.delete(`/computers/policies/configuration/${id}`)
}
},
business: {
list() {
return api.get('/computers/policies/business')
},
get(id) {
return api.get(`/computers/policies/business/${id}`)
},
create(data) {
return api.post('/computers/policies/business', data)
},
update(id, data) {
return api.put(`/computers/policies/business/${id}`, data)
},
remove(id) {
return api.delete(`/computers/policies/business/${id}`)
}
}
}
}

View File

@@ -4,7 +4,30 @@
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in panels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card">
<h3 class="section-title">{{ panel.title }}</h3>
<!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst
row (a stopped service or a warning/critical event). --><h3
v-if="panel.collapsible"
class="section-title pap-header"
role="button"
tabindex="0"
:aria-expanded="!isCollapsed(panel)"
@click="toggleCollapse(panel)"
@keydown.enter.prevent="toggleCollapse(panel)"
@keydown.space.prevent="toggleCollapse(panel)"
>
<span class="pap-caret" :class="{ collapsed: isCollapsed(panel) }" aria-hidden="true">&#9662;</span>
<span class="pap-header-title">{{ panel.title }}</span>
<span
v-if="rollupStatus(panel)"
class="pap-status-dot"
:style="{ backgroundColor: rollupStatus(panel).color }"
:title="rollupStatus(panel).level"
></span>
</h3>
<h3 v-else class="section-title">{{ panel.title }}</h3>
<div v-show="!panel.collapsible || !isCollapsed(panel)" class="pap-body">
<!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list">
@@ -16,6 +39,13 @@
:style="colorStyle(mapBadge(panel, item).color)">
{{ mapBadge(panel, item).label }}
</span>
<button
v-if="rowAction(panel, item)"
type="button"
class="btn btn-sm pap-row-action"
:disabled="actionBusy[panel.id]"
@click="runRowAction(panel, item)"
>{{ rowAction(panel, item).toggleLabel }}</button>
</div>
<div v-if="mapMeta(panel, item).length" class="pap-meta">
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
@@ -25,6 +55,9 @@
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.label || 'Manage' }}
</router-link>
<div v-if="actionError[panel.id]" class="pap-action-error">
{{ actionError[panel.id] }}
</div>
</template>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
@@ -91,6 +124,7 @@
:style="colorStyle(b.color)">{{ b.label }}</span>
</div>
</div>
</div>
</template>
</template>
@@ -100,7 +134,8 @@ import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction,
} from './pluginAssetPanels'
const props = defineProps({
@@ -115,6 +150,45 @@ const tabs = ref({})
function activeTab(panel) { return tabs.value[panel.id] || 0 }
function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } }
// Collapsed state per panel id (default expanded), kept here for the same
// reason as tabs: panels are replaced wholesale on refetch.
const collapsed = ref({})
function isCollapsed(panel) { return !!collapsed.value[panel.id] }
function toggleCollapse(panel) {
collapsed.value = { ...collapsed.value, [panel.id]: !collapsed.value[panel.id] }
}
// Per-panel action state: busy guards a double-click while an enqueue POST is
// in flight, and the error string surfaces a rejected enqueue (bad action, or
// a 409 when a command is already queued). Keyed by panel id for the same
// reason as tabs/collapsed: panels are replaced wholesale on refetch.
const actionBusy = ref({})
const actionError = ref({})
// Enqueue the inline row toggle's command (Start/Stop for an actionable
// services/processes row, e.g. MTConnect), then reload so the row reflects the
// new state. The agent long-polls, runs it, posts the result, and wakes an
// immediate telemetry re-collect, so the settled run state lands on the next
// panel refresh rather than after the full cadence.
async function runRowAction(panel, item) {
if (actionBusy.value[panel.id]) return
const info = rowAction(panel, item)
if (!info || !info.endpoint || !info.toggle) return
actionBusy.value = { ...actionBusy.value, [panel.id]: true }
actionError.value = { ...actionError.value, [panel.id]: '' }
try {
const body = { action: info.toggle }
if (info.target) body.target = info.target
await api.post(toApiPath(info.endpoint, props.assetid), body)
await load()
} catch (err) {
const message = err?.response?.data?.message || 'Command failed to queue.'
actionError.value = { ...actionError.value, [panel.id]: message }
} finally {
actionBusy.value = { ...actionBusy.value, [panel.id]: false }
}
}
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
@@ -195,6 +269,22 @@ watch(() => props.assetid, load, { immediate: true })
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pap-row-action { margin-left: 0.5rem; flex: 0 0 auto; }
.pap-action-error { color: var(--danger, #f5365c); font-size: 0.82rem; }
.pap-header {
display: flex; align-items: center; gap: 0.5rem;
cursor: pointer; user-select: none;
}
.pap-header:hover .pap-header-title { color: var(--primary); }
.pap-header-title { flex: 1 1 auto; }
.pap-caret {
font-size: 0.7rem; color: var(--text-light);
transition: transform 0.15s ease;
}
.pap-caret.collapsed { transform: rotate(-90deg); }
.pap-status-dot {
flex: 0 0 auto; width: 0.7rem; height: 0.7rem; border-radius: 50%;
}
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
</style>

View File

@@ -98,6 +98,37 @@ export function badges(panel) {
return list.map((b) => ({ label: b.label, color: b.color }))
}
// Header status rollup for a card. A panel opts in with
// status: { key, levels: { <rowvalue>: 'critical'|'warning'|'ok'|'unknown' } }.
// The card header then shows a single green/yellow/red dot reflecting the worst
// row: any critical -> red, else any warning -> yellow, else ok -> green. A
// value not in levels is treated as unknown and does not raise the level.
export const STATUS_RANK = { ok: 1, unknown: 1, warning: 2, critical: 3 }
export const STATUS_COLOR = {
ok: '#2dce89',
warning: '#ffc107',
critical: '#f5365c',
unknown: '#6c757d',
}
export function rollupStatus(panel) {
const status = panel && panel.status
if (!status || !status.key) return null
const items = rows(panel)
if (!items.length) return null
const levels = status.levels || {}
let worst = 'ok'
let sawKnown = false
for (const item of items) {
const level = levels[item[status.key]]
if (!level) continue
sawKnown = true
if (STATUS_RANK[level] > STATUS_RANK[worst]) worst = level
}
if (!sawKnown) return null
return { level: worst, color: STATUS_COLOR[worst] || STATUS_COLOR.unknown }
}
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
// time, so a panel with 30 values stays the height of its largest section
// instead of dragging one multicol column far past the other.
@@ -114,6 +145,30 @@ export function tabSections(panel) {
.filter((s) => s.fields.length > 0)
}
// Inline row action for a list panel. A monitored services/processes row may
// carry action: { running, start, stop, status } when it has a registered
// command (e.g. MTConnect). The card renders a single toggle on that row: when
// running it offers Stop, otherwise Start. Rows with no action are badge-only.
// commandendpoint on the panel is where the enqueue POST goes.
export function rowAction(panel, item) {
const action = item && item.action
if (!action || (!action.start && !action.stop)) return null
const running = action.running === true
return {
running,
// The action name to enqueue for the primary toggle button.
toggle: running ? action.stop : action.start,
toggleLabel: running ? 'Stop' : 'Start',
// status is a non-mutating probe, always available when declared.
status: action.status || null,
// Generic actions (service_*/process_*) carry the subject to act on; fixed
// actions (mtconnect_*) leave this null.
target: action.target || null,
// The declared enqueue endpoint template; the caller substitutes assetid.
endpoint: panel.commandendpoint || null,
}
}
export function panelVisible(panel) {
if (panel.render === 'tabs') return tabSections(panel).length > 0
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction,
} from './pluginAssetPanels'
const warrantyPanel = {
@@ -130,3 +131,72 @@ describe('tabSections', () => {
expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false)
})
})
describe('rollupStatus', () => {
const status = {
key: 'statecolor',
levels: { green: 'ok', yellow: 'warning', red: 'critical' },
}
it('returns null when the panel declares no status', () => {
expect(rollupStatus({ _data: [{ statecolor: 'green' }] })).toBeNull()
})
it('returns null when there are no rows', () => {
expect(rollupStatus({ status, _data: [] })).toBeNull()
})
it('is ok (green) when every row is ok', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'green' }] })
expect(result).toEqual({ level: 'ok', color: '#2dce89' })
})
it('is warning (yellow) when a warning is present but no critical', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'yellow' }] })
expect(result).toEqual({ level: 'warning', color: '#ffc107' })
})
it('is critical (red) when any row is stopped', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'yellow' }, { statecolor: 'red' }] })
expect(result).toEqual({ level: 'critical', color: '#f5365c' })
})
it('ignores unmapped values without raising the level', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'gray' }, { statecolor: 'green' }] })
expect(result).toEqual({ level: 'ok', color: '#2dce89' })
})
it('returns null when no row value maps to a known level', () => {
expect(rollupStatus({ status, _data: [{ statecolor: 'gray' }] })).toBeNull()
})
})
describe('rowAction (inline MTConnect toggle)', () => {
const panel = { id: 'endpoints-services', render: 'list', commandendpoint: '/api/endpoints/5/commands' }
it('returns null for rows without an action', () => {
expect(rowAction(panel, { name: 'Some Service' })).toBeNull()
expect(rowAction(panel, { name: 'x', action: { status: 'mtconnect_status' } })).toBeNull()
})
it('offers Stop when the row is running', () => {
const info = rowAction(panel, {
name: 'MTConnect',
action: { running: true, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' },
})
expect(info.running).toBe(true)
expect(info.toggle).toBe('mtconnect_stop')
expect(info.toggleLabel).toBe('Stop')
expect(info.endpoint).toBe('/api/endpoints/5/commands')
})
it('offers Start when the row is not running', () => {
const info = rowAction(panel, {
name: 'MTConnect',
action: { running: false, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' },
})
expect(info.running).toBe(false)
expect(info.toggle).toBe('mtconnect_start')
expect(info.toggleLabel).toBe('Start')
})
})

View File

@@ -1,5 +1,6 @@
"""Computers plugin API."""
from .routes import computers_bp
from . import policy_routes # noqa: F401 registers policy routes on computers_bp
__all__ = ['computers_bp']

View File

@@ -0,0 +1,406 @@
"""CRUD API for Configuration and Business Policies.
Registered on computers_bp under /api/computers/policies/... Kept in its own
module so the whole policy surface can be removed by not importing it - part of
the exit gate. Every route first checks the computers_configpolicies_enabled
switch and returns 404 while it is off, so enabling/disabling the feature never
half-exposes it.
Write-time validation lives here (not in the model): scope ASTs go through
validate_scope, and action names are checked against the endpoints command
allowlist, so a bad tree or an unknown action never reaches the database.
"""
from flask import request
from flask_jwt_extended import jwt_required
from shopdb.api import (db, AuditLog, success_response, error_response,
ErrorCodes, require_permission)
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
from ..policyflag import policy_flag_enabled
from ..services.policyscope import validate_scope, ScopeError
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
def _feature_gate():
"""Return a 404 response when the policy feature is off, else None."""
if not policy_flag_enabled():
return error_response(ErrorCodes.NOT_FOUND,
'Policy feature is not enabled', http_code=404)
return None
def _known_actions():
"""The code-level allowlist of action names a policy may reference."""
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return set(COMMAND_ALLOWLIST.keys())
def _configpolicy_dict(policy):
data = policy.to_dict()
data['actions'] = [a.to_dict() for a in policy.actions]
return data
def _businesspolicy_dict(policy):
data = policy.to_dict()
data['items'] = [{
'businesspolicyitemid': it.businesspolicyitemid,
'configurationpolicyid': it.configurationpolicyid,
'sortorder': it.sortorder,
'name': it.configurationpolicy.name if it.configurationpolicy else None,
} for it in policy.items]
return data
# =============================================================================
# Configuration Policies
# =============================================================================
@computers_bp.route('/policies/configuration', methods=['GET'])
@jwt_required(optional=True)
def list_configuration_policies():
gate = _feature_gate()
if gate:
return gate
policies = ConfigurationPolicy.query.order_by(ConfigurationPolicy.name).all()
return success_response([_configpolicy_dict(p) for p in policies])
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['GET'])
@jwt_required(optional=True)
def get_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
return success_response(_configpolicy_dict(policy))
def _apply_actions(policy, actions):
"""Replace a policy's actions from request data, validating each.
Returns an error_response on failure, else None.
"""
known = _known_actions()
policy.actions.clear()
for row in actions or []:
name = (row.get('action') or '').strip()
if name not in known:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action: {name!r}')
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
if mode not in _MODES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action mode: {mode!r}')
policy.actions.append(ConfigurationPolicyAction(
action=name,
mode=mode,
desiredstate=row.get('desiredstate'),
params=row.get('params'),
intervalminutes=row.get('intervalminutes'),
sortorder=row.get('sortorder', 0) or 0,
))
return None
@computers_bp.route('/policies/configuration', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_configuration_policy():
gate = _feature_gate()
if gate:
return gate
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
if ConfigurationPolicy.query.filter_by(name=name).first():
return error_response(ErrorCodes.CONFLICT,
f"Configuration policy '{name}' already exists",
http_code=409)
scope = data.get('scopejson')
if scope is not None:
try:
validate_scope(scope)
except ScopeError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
policy = ConfigurationPolicy(
name=name,
description=data.get('description'),
scopejson=scope,
source=POLICY_SOURCE_LOCAL,
)
err = _apply_actions(policy, data.get('actions'))
if err:
return err
db.session.add(policy)
AuditLog.log('created', 'ConfigurationPolicy', entityname=name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
message='Configuration policy created',
http_code=201)
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
if ConfigurationPolicy.query.filter_by(name=newname).first():
return error_response(ErrorCodes.CONFLICT,
f"Configuration policy '{newname}' already exists",
http_code=409)
policy.name = newname
if 'description' in data:
policy.description = data['description']
if 'isactive' in data:
policy.isactive = data['isactive']
if 'scopejson' in data:
scope = data['scopejson']
if scope is not None:
try:
validate_scope(scope)
except ScopeError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
policy.scopejson = scope
if 'actions' in data:
err = _apply_actions(policy, data['actions'])
if err:
return err
AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
message='Configuration policy updated')
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
inuse = BusinessPolicyItem.query.filter_by(
configurationpolicyid=policy_id).count()
if inuse:
return error_response(
ErrorCodes.CONFLICT,
f'Cannot delete: used by {inuse} business policy item(s)',
http_code=409)
db.session.delete(policy)
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(None, message='Configuration policy deleted')
# =============================================================================
# Business Policies
# =============================================================================
@computers_bp.route('/policies/business', methods=['GET'])
@jwt_required(optional=True)
def list_business_policies():
gate = _feature_gate()
if gate:
return gate
policies = (BusinessPolicy.query
.order_by(BusinessPolicy.priority, BusinessPolicy.businesspolicyid)
.all())
return success_response([_businesspolicy_dict(p) for p in policies])
@computers_bp.route('/policies/business/<int:policy_id>', methods=['GET'])
@jwt_required(optional=True)
def get_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
return success_response(_businesspolicy_dict(policy))
def _apply_items(policy, items):
"""Replace a business policy's configuration-policy items, validating each."""
policy.items.clear()
seen = set()
for order, row in enumerate(items or []):
cpid = row.get('configurationpolicyid')
if cpid is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'item requires configurationpolicyid')
if cpid in seen:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'duplicate configuration policy {cpid}')
if not db.session.get(ConfigurationPolicy, cpid):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'unknown configuration policy {cpid}')
seen.add(cpid)
policy.items.append(BusinessPolicyItem(
configurationpolicyid=cpid,
sortorder=row.get('sortorder', order),
))
return None
@computers_bp.route('/policies/business', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_business_policy():
gate = _feature_gate()
if gate:
return gate
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
if BusinessPolicy.query.filter_by(name=name).first():
return error_response(ErrorCodes.CONFLICT,
f"Business policy '{name}' already exists",
http_code=409)
policy = BusinessPolicy(
name=name,
description=data.get('description'),
priority=data.get('priority', 100),
source=POLICY_SOURCE_LOCAL,
)
err = _apply_items(policy, data.get('items'))
if err:
return err
db.session.add(policy)
AuditLog.log('created', 'BusinessPolicy', entityname=name)
db.session.commit()
return success_response(_businesspolicy_dict(policy),
message='Business policy created', http_code=201)
@computers_bp.route('/policies/business/<int:policy_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
if BusinessPolicy.query.filter_by(name=newname).first():
return error_response(ErrorCodes.CONFLICT,
f"Business policy '{newname}' already exists",
http_code=409)
policy.name = newname
if 'description' in data:
policy.description = data['description']
if 'priority' in data:
policy.priority = data['priority']
if 'isactive' in data:
policy.isactive = data['isactive']
if 'items' in data:
err = _apply_items(policy, data['items'])
if err:
return err
AuditLog.log('updated', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
return success_response(_businesspolicy_dict(policy),
message='Business policy updated')
@computers_bp.route('/policies/business/<int:policy_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
db.session.delete(policy)
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
return success_response(None, message='Business policy deleted')
# =============================================================================
# Action catalog (for the UI) and effective-policy preview
# =============================================================================
@computers_bp.route('/policies/actions', methods=['GET'])
@jwt_required(optional=True)
def list_policy_actions():
"""The allowlisted action names + labels a policy may reference."""
gate = _feature_gate()
if gate:
return gate
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return success_response([
{'action': name, 'label': meta.get('label'),
'description': meta.get('description')}
for name, meta in COMMAND_ALLOWLIST.items()
])
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_effective_policy(asset_id: int):
"""Preview the resolved effective policy for one endpoint (debug/UI)."""
gate = _feature_gate()
if gate:
return gate
from ..services.policyresolver import effective_policy_for
effective = effective_policy_for(asset_id)
return success_response(effective.to_dict())

View File

@@ -62,6 +62,18 @@ export default [
name: 'access-protocols',
component: () => import('./views/AccessProtocolsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/configuration-policies',
name: 'configuration-policies',
component: () => import('./views/ConfigurationPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/business-policies',
name: 'business-policies',
component: () => import('./views/BusinessPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
}
]

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Business Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Priority</th>
<th>Name</th>
<th>Description</th>
<th>Configuration policies</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.businesspolicyid">
<td>{{ p.priority }}</td>
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="it in p.items" :key="it.businesspolicyitemid" class="badge" style="margin-right:4px;">
{{ it.name }}
</span>
<span v-if="!p.items || p.items.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Policy' : 'Add Business Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Priority <span class="hint">(lower runs first; first match wins per action)</span></label>
<input v-model.number="form.priority" type="number" class="form-control" />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Configuration policies <span class="hint">(evaluated top to bottom)</span></label>
<div v-for="(it, index) in form.items" :key="index" class="item-row">
<span class="order-badge">{{ index + 1 }}</span>
<select v-model.number="it.configurationpolicyid" class="form-control">
<option :value="undefined" disabled>Select configuration policy...</option>
<option v-for="cp in configPolicies" :key="cp.configurationpolicyid" :value="cp.configurationpolicyid">
{{ cp.name }}
</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === 0" @click="moveUp(index)">Up</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === form.items.length - 1" @click="moveDown(index)">Down</button>
<button type="button" class="btn btn-danger btn-sm" @click="form.items.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addItem">+ Add configuration policy</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const policies = ref([])
const configPolicies = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', priority: 100, items: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [biz, config] = await Promise.all([
computersApi.policies.business.list(),
computersApi.policies.configuration.list()
])
policies.value = biz.data.data || []
configPolicies.value = config.data.data || []
} catch (err) {
console.error('Error loading business policies:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
priority: item.priority ?? 100,
items: (item.items || [])
.slice()
.sort((a, b) => a.sortorder - b.sortorder)
.map(it => ({ configurationpolicyid: it.configurationpolicyid }))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addItem() {
form.value.items.push({ configurationpolicyid: undefined })
}
function moveUp(index) {
const items = form.value.items
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
}
function moveDown(index) {
const items = form.value.items
;[items[index + 1], items[index]] = [items[index], items[index + 1]]
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
priority: form.value.priority,
items: form.value.items.map((it, order) => ({
configurationpolicyid: it.configurationpolicyid,
sortorder: order
}))
}
if (editing.value) {
await computersApi.policies.business.update(editing.value.businesspolicyid, payload)
} else {
await computersApi.policies.business.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save business policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete business policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.order-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 24px; border-radius: 12px;
background: var(--border-color, #444);
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Configuration Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Actions in policy</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.configurationpolicyid">
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
{{ a.action }} ({{ a.mode }})
</span>
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Configuration Policy' : 'Add Configuration Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Scope <span class="hint">(which endpoints this applies to)</span></label>
<div class="scope-builder">
<ScopeNodeEditor :node="form.scopejson" :pctypes="pctypes" :apps="apps" :removable="false" />
</div>
</div>
<div class="form-group">
<label>Actions</label>
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
<select v-model="a.action" class="form-control">
<option :value="''" disabled>Select action...</option>
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
{{ opt.label || opt.action }}
</option>
</select>
<select v-model="a.mode" class="form-control mode-select">
<option value="gate">Allow (gate)</option>
<option value="desired">Desired state</option>
</select>
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi, applicationsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import ScopeNodeEditor from './ScopeNodeEditor.vue'
const toast = useToast()
const policies = ref([])
const pctypes = ref([])
const apps = ref([])
const actionCatalog = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [pol, types, appList, actions] = await Promise.all([
computersApi.policies.configuration.list(),
computersApi.types.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.policies.actions()
])
policies.value = pol.data.data || []
pctypes.value = types.data.data || []
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
// axios response, so read it directly. Reading .data on the array throws and
// rejects the whole Promise.all, blanking every dropdown.
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
actionCatalog.value = actions.data.data || []
} catch (err) {
console.error('Error loading configuration policies:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
actions: (item.actions || []).map(a => ({
action: a.action, mode: a.mode || 'gate',
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
}))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addAction() {
form.value.actions.push({ action: '', mode: 'gate' })
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
scopejson: form.value.scopejson,
actions: form.value.actions
}
if (editing.value) {
await computersApi.policies.configuration.update(editing.value.configurationpolicyid, payload)
} else {
await computersApi.policies.configuration.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
try {
await computersApi.policies.configuration.remove(p.configurationpolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.scope-builder {
padding: 10px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
}
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.mode-select { max-width: 160px; }
</style>

View File

@@ -444,12 +444,31 @@ watch(() => form.value.serialnumber, (serial) => {
}
})
// Filter models by selected vendor and PC type
// Ensure a saved model id is present in models.value even when the filtered
// list (category PC) excluded it because it has no modeltype. Fetches the
// single row by id and appends it; a no-op when already present or blank.
async function ensureModelLoaded(modelId) {
if (!modelId) return
if (models.value.some(m => m.modelnumberid === modelId)) return
try {
const response = await modelsApi.get(modelId)
const model = response.data.data
if (model) models.value = [...models.value, model]
} catch (e) {
// Leave it absent; the select shows blank but we did not invent a value.
}
}
// Filter models by selected vendor and PC type. Keep the record's own saved
// model even if it is untyped or a different vendor, so editing an existing PC
// never renders a blank select that clears the field on save.
const filteredModels = computed(() => {
// filter by vendor only (PC type now maps to computertypeid, a different id
// space than a model's machinetypeid)
const savedId = form.value.modelnumberid
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
return models.value.filter(
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
})
// Only offer vendors that actually have PC-category models. models is already
@@ -520,6 +539,12 @@ onMounted(async () => {
levelid: pc.levelid ?? null,
ipaddress: primaryComm?.ipaddress || ''
}
// The model list is loaded filtered to category PC, which the backend
// inner-joins against ModelType - so an untyped model is absent. If this
// PC's saved model is one of those rows, fetch it by id and merge it in,
// or its dropdown would render blank and saving would clear the field.
await ensureModelLoaded(form.value.modelnumberid)
}
} catch (err) {
console.error('Error loading data:', err)

View File

@@ -0,0 +1,106 @@
<template>
<div class="scope-node">
<div class="scope-node-row">
<select v-model="node.type" class="form-control scope-type" @change="onTypeChange">
<option value="true">Matches everything</option>
<option value="pctype">PC type is</option>
<option value="app">App is installed</option>
<option value="and">ALL of (AND)</option>
<option value="or">ANY of (OR)</option>
<option value="not">NOT</option>
</select>
<select v-if="node.type === 'pctype'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select PC type...</option>
<option v-for="pt in pctypes" :key="pt.computertypeid" :value="pt.computertypeid">
{{ pt.computertype }}
</option>
</select>
<select v-if="node.type === 'app'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select app...</option>
<option v-for="app in apps" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<button v-if="removable" type="button" class="btn btn-danger btn-sm" @click="$emit('remove')">
Remove
</button>
</div>
<div v-if="node.type === 'and' || node.type === 'or'" class="scope-children">
<ScopeNodeEditor
v-for="(child, index) in node.nodes"
:key="index"
:node="child"
:pctypes="pctypes"
:apps="apps"
:removable="true"
@remove="removeChild(index)"
/>
<button type="button" class="btn btn-secondary btn-sm" @click="addChild">+ Add condition</button>
</div>
<div v-if="node.type === 'not'" class="scope-children">
<ScopeNodeEditor
:node="node.node"
:pctypes="pctypes"
:apps="apps"
:removable="false"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
node: { type: Object, required: true },
pctypes: { type: Array, default: () => [] },
apps: { type: Array, default: () => [] },
removable: { type: Boolean, default: false }
})
defineEmits(['remove'])
function onTypeChange() {
const node = props.node
// Reset payload keys so a switched node never carries stale fields from its
// previous type. Keep only what the new type needs.
delete node.id
delete node.nodes
delete node.node
if (node.type === 'and' || node.type === 'or') {
node.nodes = [{ type: 'true' }]
} else if (node.type === 'not') {
node.node = { type: 'true' }
}
}
function addChild() {
props.node.nodes.push({ type: 'true' })
}
function removeChild(index) {
props.node.nodes.splice(index, 1)
if (props.node.nodes.length === 0) {
props.node.nodes.push({ type: 'true' })
}
}
</script>
<script>
export default { name: 'ScopeNodeEditor' }
</script>
<style scoped>
.scope-node { margin: 4px 0; }
.scope-node-row { display: flex; gap: 8px; align-items: center; }
.scope-type { max-width: 220px; }
.scope-children {
margin-left: 24px;
padding-left: 12px;
border-left: 2px solid var(--border-color, #444);
margin-top: 6px;
}
</style>

View File

@@ -0,0 +1,154 @@
"""Add Configuration and Business Policy tables (RMM-style policies).
Creates configurationpolicies, configurationpolicyactions, businesspolicies,
and businesspolicyitems. These back the DT-authored policy feature that is gated
by the computers_configpolicies_enabled setting; the tables land regardless so
the switch can be flipped without a schema change, and the downgrade drops all
four for a clean rollback of the whole scope.
Idempotent: each create_table is skipped when the table already exists (e.g. a
test DB built by db.create_all()).
Revision ID: computers0004policies
Revises: computers0003azurefields
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0004policies'
down_revision = 'computers0003azurefields'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'configurationpolicies' not in existing:
op.create_table(
'configurationpolicies',
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('scopejson', sa.JSON(), nullable=True),
sa.Column('source', sa.String(length=50), nullable=False,
server_default='local'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('configurationpolicyid'),
sa.UniqueConstraint('name', name='uq_configurationpolicy_name'),
)
op.create_index('idx_configpolicy_source', 'configurationpolicies',
['source'])
op.create_index('idx_configpolicy_active', 'configurationpolicies',
['isactive'])
if 'configurationpolicyactions' not in existing:
op.create_table(
'configurationpolicyactions',
sa.Column('configurationpolicyactionid', sa.Integer(),
nullable=False),
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('mode', sa.String(length=20), nullable=False,
server_default='gate'),
sa.Column('desiredstate', sa.JSON(), nullable=True),
sa.Column('params', sa.JSON(), nullable=True),
sa.Column('intervalminutes', sa.Integer(), nullable=True),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(
['configurationpolicyid'],
['configurationpolicies.configurationpolicyid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('configurationpolicyactionid'),
sa.UniqueConstraint('configurationpolicyid', 'action',
name='uq_configpolicyaction'),
)
op.create_index('idx_configpolicyaction_policy',
'configurationpolicyactions',
['configurationpolicyid'])
if 'businesspolicies' not in existing:
op.create_table(
'businesspolicies',
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=False,
server_default='100'),
sa.Column('source', sa.String(length=50), nullable=False,
server_default='local'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('businesspolicyid'),
sa.UniqueConstraint('name', name='uq_businesspolicy_name'),
)
op.create_index('idx_businesspolicy_priority', 'businesspolicies',
['priority'])
op.create_index('idx_businesspolicy_active', 'businesspolicies',
['isactive'])
if 'businesspolicyitems' not in existing:
op.create_table(
'businesspolicyitems',
sa.Column('businesspolicyitemid', sa.Integer(), nullable=False),
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(
['businesspolicyid'], ['businesspolicies.businesspolicyid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(
['configurationpolicyid'],
['configurationpolicies.configurationpolicyid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('businesspolicyitemid'),
sa.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
name='uq_businesspolicyitem'),
)
op.create_index('idx_bpitem_business', 'businesspolicyitems',
['businesspolicyid'])
op.create_index('idx_bpitem_config', 'businesspolicyitems',
['configurationpolicyid'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'businesspolicyitems' in existing:
op.drop_index('idx_bpitem_config', table_name='businesspolicyitems')
op.drop_index('idx_bpitem_business', table_name='businesspolicyitems')
op.drop_table('businesspolicyitems')
if 'businesspolicies' in existing:
op.drop_index('idx_businesspolicy_active', table_name='businesspolicies')
op.drop_index('idx_businesspolicy_priority', table_name='businesspolicies')
op.drop_table('businesspolicies')
if 'configurationpolicyactions' in existing:
op.drop_index('idx_configpolicyaction_policy',
table_name='configurationpolicyactions')
op.drop_table('configurationpolicyactions')
if 'configurationpolicies' in existing:
op.drop_index('idx_configpolicy_active',
table_name='configurationpolicies')
op.drop_index('idx_configpolicy_source',
table_name='configurationpolicies')
op.drop_table('configurationpolicies')

View File

@@ -7,6 +7,15 @@ from .computer import (
AccessProtocol,
ComputerAccess,
)
from .policy import (
ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy,
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
)
__all__ = [
'Computer',
@@ -14,4 +23,11 @@ __all__ = [
'ComputerInstalledApp',
'AccessProtocol',
'ComputerAccess',
'ConfigurationPolicy',
'ConfigurationPolicyAction',
'BusinessPolicy',
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
]

View File

@@ -0,0 +1,197 @@
"""Configuration and business policy models (RMM-style).
DT staff author reusable Configuration Policies (a single rule binding
allowlisted endpoint actions to endpoints matched by a boolean scope) and group
them into ordered Business Policies assigned to a fleet. When an endpoint reports
in, the server folds the matching Business Policies in priority order to compute
that endpoint's effective policy (first match wins per action).
These models live in the computers plugin so policies sit next to ComputerType.
The action VOCABULARY stays authoritative in plugins/endpoints/commands.py; this
layer references action NAMES only. The `source` column is the seam for a future
GE-Enforce sync (local-authored rows vs imported/managed rows).
The whole surface is gated by the computers_configpolicies_enabled setting; the
models register unconditionally (SQLAlchemy needs them), but routes, panels, and
enforcement stay inert while the flag is off, and the migration downgrade drops
every table for a clean rollback.
"""
from shopdb.api import db, BaseModel
# Provenance of a policy row.
# leaves room for `geenforce` (or another manager) to own rows later without a
# schema change - an imported row is read-only in the UI and reconciled by its
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.
Scope is a boolean expression (AND/OR/NOT over PC types and applications)
stored as a small typed JSON AST in scopejson; see services/policyscope.py
for the evaluator. Actions live in ConfigurationPolicyAction rows.
"""
__tablename__ = 'configurationpolicies'
configurationpolicyid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
description = db.Column(db.Text)
# Boolean target AST: {"type":"and","nodes":[{"type":"pctype","id":3},
# {"type":"not","node":{"type":"app","id":12}}]}. Validated server-side
# (known node types, depth cap) before it is stored - see policyscope.
scopejson = db.Column(db.JSON, nullable=True)
# GE-Enforce interop seam (see POLICY_SOURCE_LOCAL).
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
# createddate, modifieddate, isactive come from BaseModel.
actions = db.relationship(
'ConfigurationPolicyAction',
back_populates='configurationpolicy',
cascade='all, delete-orphan',
)
__table_args__ = (
db.Index('idx_configpolicy_source', 'source'),
db.Index('idx_configpolicy_active', 'isactive'),
)
def __repr__(self):
return f"<ConfigurationPolicy {self.name}>"
# What a policy action does to a matched endpoint.
# desired: reconciled desired-state. desiredstate says what it should be
# (e.g. running=true for mtconnect); the server enqueues the
# drift-correcting command when telemetry disagrees.
# gate: allowlist-gating only. The action is PERMITTED (and shown) on
# matched endpoints; it constrains what may be enqueued, it does not
# drive anything on its own.
ACTION_MODE_DESIRED = 'desired'
ACTION_MODE_GATE = 'gate'
class ConfigurationPolicyAction(BaseModel):
"""One allowlisted action carried by a ConfigurationPolicy.
`action` is a name from plugins/endpoints/commands.py COMMAND_ALLOWLIST;
it is validated against that allowlist at write time. `mode` selects
desired-state reconciliation vs allowlist-gating.
"""
__tablename__ = 'configurationpolicyactions'
configurationpolicyactionid = db.Column(db.Integer, primary_key=True)
configurationpolicyid = db.Column(
db.Integer,
db.ForeignKey('configurationpolicies.configurationpolicyid',
ondelete='CASCADE'),
nullable=False,
)
# Allowlisted action name (endpoints commands.py). Not an FK - the allowlist
# is code, not a table - so it is validated in the API layer.
action = db.Column(db.String(100), nullable=False)
mode = db.Column(db.String(20), default=ACTION_MODE_GATE, nullable=False)
# Desired-state target for mode=desired, as JSON so different actions can
# express different targets (e.g. {"running": true}). Null for gate mode.
desiredstate = db.Column(db.JSON, nullable=True)
# Optional action parameters passed through to the command (JSON), and an
# optional reconcile cadence in minutes (null = reconcile on every report).
params = db.Column(db.JSON, nullable=True)
intervalminutes = db.Column(db.Integer, nullable=True)
sortorder = db.Column(db.Integer, default=0, nullable=False)
configurationpolicy = db.relationship(
'ConfigurationPolicy', back_populates='actions')
__table_args__ = (
db.UniqueConstraint('configurationpolicyid', 'action',
name='uq_configpolicyaction'),
db.Index('idx_configpolicyaction_policy', 'configurationpolicyid'),
)
def __repr__(self):
return f"<ConfigurationPolicyAction {self.action} mode={self.mode}>"
class BusinessPolicy(BaseModel):
"""An ordered collection of Configuration Policies assigned to a fleet.
Priority orders Business Policies against each other when several match one
endpoint: lower priority number is evaluated first, and the first policy to
speak on a given action name wins (first-match-wins per action).
"""
__tablename__ = 'businesspolicies'
businesspolicyid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
description = db.Column(db.Text)
# Lower runs first. Ties broken by businesspolicyid for a stable order.
priority = db.Column(db.Integer, default=100, nullable=False)
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
# createddate, modifieddate, isactive come from BaseModel.
items = db.relationship(
'BusinessPolicyItem',
back_populates='businesspolicy',
cascade='all, delete-orphan',
order_by='BusinessPolicyItem.sortorder',
)
__table_args__ = (
db.Index('idx_businesspolicy_priority', 'priority'),
db.Index('idx_businesspolicy_active', 'isactive'),
)
def __repr__(self):
return f"<BusinessPolicy {self.name} priority={self.priority}>"
class BusinessPolicyItem(BaseModel):
"""Membership of a Configuration Policy in a Business Policy, ordered.
sortorder sets the walk order WITHIN a Business Policy; combined with the
Business Policy priority it gives a fully deterministic fold order for the
effective-policy resolver.
"""
__tablename__ = 'businesspolicyitems'
businesspolicyitemid = db.Column(db.Integer, primary_key=True)
businesspolicyid = db.Column(
db.Integer,
db.ForeignKey('businesspolicies.businesspolicyid', ondelete='CASCADE'),
nullable=False,
)
configurationpolicyid = db.Column(
db.Integer,
db.ForeignKey('configurationpolicies.configurationpolicyid',
ondelete='CASCADE'),
nullable=False,
)
sortorder = db.Column(db.Integer, default=0, nullable=False)
businesspolicy = db.relationship('BusinessPolicy', back_populates='items')
configurationpolicy = db.relationship('ConfigurationPolicy')
__table_args__ = (
db.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
name='uq_businesspolicyitem'),
db.Index('idx_bpitem_business', 'businesspolicyid'),
db.Index('idx_bpitem_config', 'configurationpolicyid'),
)
def __repr__(self):
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
f"cp={self.configurationpolicyid}>")

View File

@@ -12,8 +12,16 @@ from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType
from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from .models import (
ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy,
BusinessPolicyItem,
)
from .api import computers_bp
from .policyflag import policy_flag_enabled as _policy_flag_enabled
logger = logging.getLogger(__name__)
# Marker stamped on PC->measuringtool "controls" links this collector creates
@@ -92,7 +100,8 @@ class ComputersPlugin(BasePlugin):
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess]
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess,
ConfigurationPolicy, ConfigurationPolicyAction, BusinessPolicy, BusinessPolicyItem]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
@@ -397,6 +406,36 @@ class ComputersPlugin(BasePlugin):
'site\'s URL and ranges',
'position': 26,
},
] + self._policy_settings_cards()
def _policy_settings_cards(self):
"""Policy admin cards, shown only while the feature flag is on.
Gating the cards on the switch keeps the settings menu honest: the
Configuration/Business Policy screens appear exactly when the feature is
enabled and vanish on rollback, matching the 404 the routes return.
"""
if not self._configpolicies_enabled_safe():
return []
return [
{
'group': 'Computers',
'to': '/settings/configuration-policies',
'icon': 'shield',
'title': 'Configuration policies',
'description': 'Reusable rules binding endpoint actions to PC '
'types and applications',
'position': 27,
},
{
'group': 'Computers',
'to': '/settings/business-policies',
'icon': 'layers',
'title': 'Business policies',
'description': 'Ordered collections of configuration policies '
'assigned by priority',
'position': 28,
},
]
def get_settings_defaults(self) -> List[dict]:
@@ -438,8 +477,41 @@ class ComputersPlugin(BasePlugin):
'Off while machine numbers are shared between '
'PCs; the collector response still warns.',
},
{
'key': 'computers_configpolicies_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'computers',
'description': 'Master switch for RMM-style Configuration and '
'Business Policies. Off by default: models exist '
'but the CRUD routes, WebUI, and endpoint '
'enforcement stay inert so the whole feature can '
'be rolled back without a schema change.',
},
]
def _configpolicies_enabled(self):
"""Whether the Configuration/Business Policy feature is turned on.
Off by default. This is the single exit gate for the policy scope: the
tables and models load regardless (SQLAlchemy needs them), but every
route, panel, and enforcement path checks this first and no-ops while it
is false, so the feature can be disabled without a migration.
"""
return _policy_flag_enabled()
def _configpolicies_enabled_safe(self):
"""Flag read that never raises when the settings table is absent.
get_settings_cards can be called in contexts without a database (the
plugin contract tests build hooks against a bare app), so a missing
settings table must read as "off" rather than propagate.
"""
try:
return _policy_flag_enabled()
except Exception:
return False
def _machinelink_alerts_enabled(self):
"""Whether the PC-to-machine alerts may be sent.

View File

@@ -0,0 +1,19 @@
"""Single source of truth for the Configuration/Business Policy feature gate.
Kept in its own module (not on the plugin class) so the CRUD routes, the panel
surface, and the endpoints enforcement path can all consult the same switch
without importing ComputersPlugin. The flag is the computers_configpolicies_enabled
Setting row seeded by the plugin's get_settings_defaults; while it is off, every
policy code path no-ops, which is the exit gate for rolling the feature back.
"""
def policy_flag_enabled():
"""True when computers_configpolicies_enabled is set truthy."""
from shopdb.api import Setting
setting = Setting.query.filter_by(
key='computers_configpolicies_enabled').first()
if not setting:
return False
return (setting.value or '').strip().lower() in ('true', '1', 'yes')

View File

@@ -0,0 +1 @@
"""Computers plugin services: pure policy logic (scope + resolution)."""

View File

@@ -0,0 +1,168 @@
"""Effective-policy resolution: the single swappable policy entry point.
This module answers one question for the rest of the system: "given an endpoint,
what actions apply and how?" It is the boundary the endpoints allowlist seam
calls - never the policy tables directly - so the policy ASSEMBLY ENGINE can be
swapped without touching enforcement. Today it folds the local computers-plugin
tables; a future GE-Enforce integration can replace build_effective_policy (or
feed it) and every consumer keeps working unchanged.
Resolution model (per the design):
- Build an endpoint context (PC type + installed appids) from the Computer row.
- Walk every active Business Policy in priority order (lower first, ties by id).
- Within each, walk its Configuration Policies in sortorder.
- A Configuration Policy contributes only if its scope matches the endpoint.
- For each action name, FIRST MATCH WINS: once an action is decided it is not
overridden by a later, lower-priority policy.
The result is an EffectivePolicy: a flat, resolved map of action name -> decision
(mode desired/gate, plus desiredstate/params), which is exactly what enforcement
and the allowlist need.
"""
from .policyscope import evaluate_scope
class EffectiveAction:
"""One resolved action decision for an endpoint."""
__slots__ = ('action', 'mode', 'desiredstate', 'params', 'intervalminutes',
'businesspolicyid', 'configurationpolicyid')
def __init__(self, action, mode, desiredstate, params, intervalminutes,
businesspolicyid, configurationpolicyid):
self.action = action
self.mode = mode
self.desiredstate = desiredstate
self.params = params
self.intervalminutes = intervalminutes
self.businesspolicyid = businesspolicyid
self.configurationpolicyid = configurationpolicyid
def to_dict(self):
return {
'action': self.action,
'mode': self.mode,
'desiredstate': self.desiredstate,
'params': self.params,
'intervalminutes': self.intervalminutes,
'businesspolicyid': self.businesspolicyid,
'configurationpolicyid': self.configurationpolicyid,
}
class EffectivePolicy:
"""The folded, resolved policy for a single endpoint.
actions maps action name -> EffectiveAction (first-match-wins already
applied). Convenience views expose the gate allowlist and desired-state set
that enforcement and the endpoints seam consume.
"""
def __init__(self, actions):
self.actions = actions
def allowed_actions(self):
"""Set of action names permitted on this endpoint (gate + desired)."""
return set(self.actions.keys())
def desired_actions(self):
"""action name -> EffectiveAction for mode == 'desired' only."""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired'}
def to_dict(self):
return {name: eff.to_dict() for name, eff in self.actions.items()}
def build_endpoint_context(computer):
"""Resolve a Computer row into the context the scope evaluator needs.
Returns {'computertypeid': int|None, 'appids': set[int]}.
"""
appids = set()
for link in getattr(computer, 'installedapps', None) or ():
if getattr(link, 'isactive', True) and link.appid is not None:
appids.add(link.appid)
return {
'computertypeid': getattr(computer, 'computertypeid', None),
'appids': appids,
}
def _iter_matching_configpolicies(business_policies, context):
"""Yield (businesspolicyid, ConfigurationPolicy) in deterministic order.
Business Policies by (priority, businesspolicyid); their items by sortorder.
Only active policies whose scope matches the context are yielded.
"""
ordered_bp = sorted(
business_policies,
key=lambda bp: (bp.priority, bp.businesspolicyid),
)
for business_policy in ordered_bp:
if not business_policy.isactive:
continue
for item in sorted(business_policy.items,
key=lambda it: (it.sortorder, it.businesspolicyitemid)):
config_policy = item.configurationpolicy
if config_policy is None or not config_policy.isactive:
continue
if evaluate_scope(config_policy.scopejson, context):
yield business_policy.businesspolicyid, config_policy
def build_effective_policy(computer, business_policies):
"""Fold matching policies into an EffectivePolicy for one endpoint.
computer: the Computer row. business_policies: iterable of BusinessPolicy
(with items/configurationpolicy loaded). Pure given its inputs so it stays
unit-testable and reusable by an alternate assembly engine.
"""
context = build_endpoint_context(computer)
resolved = {}
for businesspolicyid, config_policy in _iter_matching_configpolicies(
business_policies, context):
for action_row in config_policy.actions:
if not action_row.isactive:
continue
name = action_row.action
if name in resolved:
# First match wins - a higher-priority policy already decided
# this action; later policies do not override it.
continue
resolved[name] = EffectiveAction(
action=name,
mode=action_row.mode,
desiredstate=action_row.desiredstate,
params=action_row.params,
intervalminutes=action_row.intervalminutes,
businesspolicyid=businesspolicyid,
configurationpolicyid=config_policy.configurationpolicyid,
)
return EffectivePolicy(resolved)
def effective_policy_for(assetid):
"""Resolve the effective policy for an endpoint by its assetid.
This is the public entry point the endpoints plugin calls. It returns an
empty EffectivePolicy when the feature is off or the asset is not a known
computer, so callers get a safe "no policy" answer without special-casing.
NOTE for GE-Enforce interop: this function is the seam. To hand policy
assembly to GE-Enforce later, replace the body below (or dispatch on source)
while keeping this signature and the EffectivePolicy return type intact.
"""
from ..policyflag import policy_flag_enabled
if not policy_flag_enabled():
return EffectivePolicy({})
from ..models import Computer, BusinessPolicy
computer = Computer.query.filter_by(assetid=assetid).first()
if computer is None:
return EffectivePolicy({})
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)

View File

@@ -0,0 +1,96 @@
"""Boolean scope evaluation for Configuration Policies.
A policy's scopejson is a small typed boolean AST over two endpoint dimensions,
PC type and installed application. This module is the ONLY place that interprets
that AST: it validates the shape (known node types, bounded depth) and evaluates
a validated tree against a resolved endpoint context.
Node grammar (all dict nodes carry a "type"):
{"type": "and", "nodes": [<node>, ...]}
{"type": "or", "nodes": [<node>, ...]}
{"type": "not", "node": <node>}
{"type": "pctype", "id": <computertypeid:int>}
{"type": "app", "id": <appid:int>}
{"type": "true"} # matches everything (an unscoped/global policy)
Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested
and so a future GE-Enforce assembly engine can reuse the exact same evaluator on
the exact same AST when it composes policies.
"""
# Bound the tree so a hand-crafted or imported AST cannot cause pathological
# recursion. Ten levels is far past any policy a human would author.
MAX_SCOPE_DEPTH = 10
_LOGICAL = {'and', 'or', 'not'}
_LEAF = {'pctype', 'app', 'true'}
class ScopeError(ValueError):
"""Raised when a scope AST is malformed. Carries a human-readable reason."""
def validate_scope(node, _depth=0):
"""Validate a scope AST, raising ScopeError on any problem.
Returns None on success. Call this at write time (CRUD) so a bad tree never
reaches the database; evaluate_scope then trusts the stored shape.
"""
if _depth > MAX_SCOPE_DEPTH:
raise ScopeError(f'scope nested deeper than {MAX_SCOPE_DEPTH} levels')
if not isinstance(node, dict):
raise ScopeError('scope node must be an object')
ntype = node.get('type')
if ntype not in _LOGICAL and ntype not in _LEAF:
raise ScopeError(f'unknown scope node type: {ntype!r}')
if ntype in ('and', 'or'):
nodes = node.get('nodes')
if not isinstance(nodes, list) or not nodes:
raise ScopeError(f'{ntype} requires a non-empty "nodes" list')
for child in nodes:
validate_scope(child, _depth + 1)
elif ntype == 'not':
child = node.get('node')
if child is None:
raise ScopeError('not requires a "node"')
validate_scope(child, _depth + 1)
elif ntype in ('pctype', 'app'):
ident = node.get('id')
if not isinstance(ident, int) or isinstance(ident, bool):
raise ScopeError(f'{ntype} requires an integer "id"')
# 'true' carries no payload.
def evaluate_scope(node, context):
"""Evaluate a validated scope AST against an endpoint context.
context is expected to expose:
context['computertypeid'] -> int or None
context['appids'] -> set/collection of installed appids
Returns True when the endpoint falls in scope. An empty/None scope means the
policy is unscoped and matches everything (same as {"type": "true"}), which
keeps a freshly-created policy usable before a scope is drawn.
"""
if node is None:
return True
ntype = node['type']
if ntype == 'true':
return True
if ntype == 'and':
return all(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'or':
return any(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'not':
return not evaluate_scope(node['node'], context)
if ntype == 'pctype':
return context.get('computertypeid') == node['id']
if ntype == 'app':
return node['id'] in (context.get('appids') or ())
# validate_scope should have caught this; be strict rather than silently
# matching an endpoint against an unknown node.
raise ScopeError(f'unknown scope node type at evaluation: {ntype!r}')

View File

@@ -0,0 +1,274 @@
# Endpoint agent contract (C# build reference)
Companion to DESIGN-DECISIONS-agent.md. This is the concrete wire contract the
future C# agent targets, derived from `EndpointsPlugin.get_collector_schema()`
and the generic collector dispatcher in `shopdb/core/api/collector.py`. When the
schema changes, update this file in the same change.
## 1. Transport
- Method/URL: `POST {baseurl}/api/collector/endpoints`
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
key is a UI-managed, endpoints-only service token minted from Settings ->
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 6). No
querystring fallback.
- Body: `application/json`, a single flat object (NOT an array). One POST per
report per endpoint.
- Identity field: `hostname` (required, case-insensitive).
## 2. Request payload
Only `hostname` is required. Every other field is optional; the server upserts
only the keys that are present, so a lightweight report can omit inventory
arrays. `adopt` is first-contact only.
```jsonc
{
"hostname": "SHOP-PC-014", // required, resolved to computers.hostname
"adopt": true, // first run ONLY; delegates PC creation to computers plugin
"loggedinuser": "j.smith",
"serialnumber": "5CD1234ABC", // adoption subset (forwarded to computers)
"pctype": "CNC Controller", // adoption subset
"machinenumber": "12345", // adoption subset; "" or "9999" == none
"agentversion": "1.0.0",
"lastboottime": "2025-01-14T06:12:00Z", // ISO-8601 UTC
"uptimeseconds": 86400,
"idleseconds": 120,
"cpupercent": 12.5,
"memorypercent": 47.0,
"services": [ /* opaque array, stored as JSON */ ],
"ports": [ /* opaque array */ ],
"interfaces": [
{
"alias": "Intel(R) Ethernet Connection", // adapter description
"ipv4": "10.20.30.40", // "" if none
"mac": "AA:BB:CC:DD:EE:FF",
"gateway": "10.20.30.1", // "" for the static machine-IPS NIC
"subnet": "255.255.255.0",
"dhcp": false
}
],
"scheduledtasks": [ /* opaque array */ ],
"events": [
{
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
"eventchannel": "System",
"eventsource": "EventLog",
"eventtime": "2025-01-14T06:11:58Z",
"eventmessage": "The previous system shutdown was unexpected.",
"fields": { "any": "extra" } // opaque object, stored as JSON
}
]
}
```
Notes:
- `adopt=true` is sent ONLY by the installer's first run. Steady-state reports
omit it (or send `false`). After adoption, an unknown hostname is a hard
validation error again (DESIGN-DECISIONS-agent.md 1a).
- The adoption subset (`serialnumber`, `pctype`, `machinenumber`, plus
`loggedinuser`) is forwarded to the `computers` collector so the newly created
PC is not a bare shell. Send them on first contact.
- `services`/`ports`/`interfaces`/`scheduledtasks` are stored verbatim as JSON;
the server does not validate their inner shape yet. Keep them stable so the UI
can render them later. `interfaces` reports EVERY hardware NIC (not just the
primary) so the gatewayless static NIC that links a PC to its machine's IPS is
preserved (DESIGN-DECISIONS-agent.md 9.3); each entry is
`{alias, ipv4, mac, gateway, subnet, dhcp}`.
- `machinenumber` is discovered on-box by the agent from the eDNC registry
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
## 3. Response
Success (HTTP 200), ADR-006 envelope:
```jsonc
{
"status": "ok",
"action": "created", // "created" first time, "updated" thereafter
"assetid": 4821,
"identityvalue": "SHOP-PC-014",
"warnings": [
"adopted new computer for hostname: 'SHOP-PC-014'" // present only on adoption
]
}
```
Validation failure (HTTP 400) - e.g. unknown hostname with `adopt` absent:
```jsonc
{ "status": "error", "error": { "code": "VALIDATION_ERROR",
"message": "could not resolve hostname to a computer: 'SHOP-PC-014'" } }
```
Agent handling: treat non-200 as retryable except a `VALIDATION_ERROR` that
names an unresolved hostname on a NON-first-contact report - that means the PC
was removed server-side; surface it, do not spin.
## 4. C# DTOs
Match the JSON keys EXACTLY (lowercase, no underscores) to satisfy the naming
rule; use attributes so C# properties can stay PascalCase without a camelCase
serializer changing the wire keys.
```csharp
using System.Text.Json.Serialization;
public sealed class EndpointReport
{
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
[JsonPropertyName("pctype")] public string? PcType { get; set; }
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
[JsonPropertyName("services")] public object[]? Services { get; set; }
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
}
public sealed class CollectorResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("action")] public string? Action { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
}
```
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
## 5. "Build site agent" settings flow (build cadence)
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
knows its server URL and its collector key, needing no manual config on the
client.
1. Settings -> "Build site agent": operator picks/creates a collector-scoped
managed token (the same token accepted by `X-API-Key` on
`/api/collector/endpoints`). This binds the build to one ShopDB instance.
2. Server stamps a small config (base URL + token + reporting cadence) into the
agent build artifact. Instance-locked = the token only works against that
server, and the server can revoke it to kill a leaked build.
3. First run on a client:
- Collect the ShopDB PowerShell asset-collector data set (minus the CSV
emit): hostname, logged-in user, serial number, pc type, machine number,
boot time, plus the curated event tail.
- POST once with `adopt=true`. The endpoints plugin delegates PC creation to
the `computers` collector, then writes the first `EndpointState` in the
same commit. The endpoint appears in the Fleet UI immediately - no second
call, no separate enrollment endpoint.
4. Subsequent runs POST on the configured cadence with `adopt` omitted; strict
hostname resolution applies from then on.
Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
per-report flag or is promoted to a site setting (strict vs self-adopting
fleet). Default proposed: agent sends `adopt=true` on first run only.
## 6. UI-managed agent key (kill switch)
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
`collector.ingest.endpoints`, so rotating or revoking it stops all
endpoint-agent traffic without affecting the computers or geenforce collectors.
- Settings -> Endpoint agent key (admin only) generates the key. The full secret
is shown ONCE at generation and stored only as a hash afterwards; configure it
as the agent `X-API-Key`.
- Generating a key is a hard cutover: any previous endpoint key is revoked in the
same request, and the bootstrap `.env` fallback for endpoints is switched off
(`plugin.endpoints.envkeyfallback` -> false), so the managed key becomes the
sole credential.
- Regenerate to rotate (old key dies immediately). Revoke to stop all
endpoint-agent reporting until a new key is generated - the kill switch for a
leaked agent.
- Backend: `GET/POST /api/endpoints/agentkey`, `POST
/api/endpoints/agentkey/regenerate`, `DELETE /api/endpoints/agentkey`. These
use the `mint_service_token`/`list_service_tokens`/`revoke_service_tokens`
contract helpers (shopdb.api, contract 0.22.0).
The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
first-run path before a UI key exists; it cannot be the runtime kill switch
because container env is injected at boot and cannot be rotated live.
## 7. Command channel (WebUI -> agent)
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
no inbound port: the agent makes an outbound request the server holds open until
a command is queued, runs it locally, and posts the result back. All three
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
the command channel shares the kill switch and adds no second secret.
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
- `mtconnect_start` - run the MTConnect UDC agent start script
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
### 7.1 Claim the next command (long-poll)
`GET {baseurl}/api/endpoints/commands/next?hostname=<hostname>`
- Auth: `X-API-Key` (endpoints collector key).
- The server holds the request open up to ~25s. If a command is queued it is
atomically claimed (marked running) and returned; only one poll ever claims a
given command.
- Command queued (HTTP 200):
```jsonc
{ "status": "ok", "data": { "endpointcommandid": 91, "action": "mtconnect_start" } }
```
- Nothing queued within the hold window: HTTP 204 (no body). Re-poll.
### 7.2 Report a command result
`POST {baseurl}/api/endpoints/commands/<endpointcommandid>/result`
- Auth: `X-API-Key` (endpoints collector key).
- Body:
```jsonc
{
"succeeded": true, // required; false marks the command failed
"message": "MTConnect started", // short summary, optional (<= 2000 chars)
"mtconnectrunning": true, // optional; observed run state after the action
"detail": { "any": "extra" } // optional structured detail, stored as JSON
}
```
- When `mtconnectrunning` is present the server caches it on the endpoint's
`EndpointState` (`mtconnectrunning` + `mtconnectcheckedat`) so the WebUI toggle
shows a real state between polls. Every `mtconnect_*` action SHOULD probe and
report the resulting run state so the toggle never lies.
### 7.3 Enqueue (WebUI side, for reference)
`POST /api/endpoints/<assetid>/commands` with `{ "action": "mtconnect_start" }`,
gated by user JWT + `endpoints.command`. Only one command may be in flight per
endpoint (a second enqueue returns HTTP 409 until the first finalizes).

View File

@@ -22,10 +22,52 @@ agent changes.
computers reporter does: out of band, e.g. `HKLM:\SOFTWARE\GE\ShopDB
CollectorKey`. The key is never baked into a distributed script/binary.
- Identity field is `hostname` (case-insensitive). The server resolves it to an
existing computer asset via the `computers` plugin. An unknown hostname is
REJECTED with a validation error, never auto-created. Decision: the fleet is
provisioned in ShopDB first (imports/Azure device sync); the agent does not
get to invent assets. Revisit only if bulk self-registration becomes a need.
existing computer asset via the `computers` plugin. In STEADY STATE an unknown
hostname is REJECTED with a validation error, never auto-created by this
plugin. Decision: the fleet is provisioned in ShopDB first (imports/Azure
device sync) OR adopted at first contact via the delegation shim in section
1a; the `endpoints` plugin itself does not get to invent assets.
## 1a. First-contact adoption (delegation shim, not a design reversal)
The agent installer's first run reaches a PC that may not exist in ShopDB yet.
Rather than letting `endpoints` create assets (which would break ADR-001 and the
section 1 rule that this plugin never invents assets), first contact DELEGATES
adoption to the `computers` collector, which is already the sole authoritative
creator of computer Assets and PC rows.
Decision:
- The `endpoints` plugin NEVER constructs an `Asset` or a `Computer` row itself.
Section 1 stands. This is a clarification, not a reversal.
- When `apply_collector_payload` cannot resolve the hostname AND the payload
opts in to adoption (see the `adopt` flag below), the plugin calls the
`computers` plugin's `apply_collector_payload` with the PC-identifying subset
of the payload, flushes, then re-resolves the hostname and proceeds with
telemetry. All of this runs inside the ONE transaction the generic collector
dispatcher owns (`shopdb/core/api/collector.py`): a `ValueError` still rolls
the whole thing back, so a half-adopted PC can never be committed.
- Steady-state reports send `adopt=false` (or omit it once a site chooses strict
mode); only the installer's first run sends `adopt=true`. After adoption the
strict "must already resolve" invariant holds for every subsequent report.
First-contact payload (the PC-identifying subset the shim forwards to the
`computers` collector - every field except `hostname` is optional but recommended
so the adopted PC is not a bare shell):
- `hostname` (required), `loggedinuser`, `serialnumber`, `pctype`,
`machinenumber` (only when the PC is bound to a machine; `''`/`'9999'` are
treated as none by the computers collector).
Net effect for the agent:
- ONE POST to `/api/collector/endpoints` with `adopt=true` on first run creates
the Asset + PC (via the shim) and writes the first `EndpointState` in the same
commit, so the endpoint appears in the UI immediately (no second call, no
separate enrollment endpoint).
- OPEN: whether to keep `adopt` per-report or promote it to a site setting
(strict vs. self-adopting fleet). Default proposed: adoption allowed, agent
sends `adopt=true` only on first run.
## 2. Report shape (current server contract)
@@ -117,6 +159,31 @@ endpoint agree:
- Code signing and Intune deployment are agent-side concerns, out of scope for
the server plugin but must be planned before fleet rollout.
### 7a. Tray deployment and on-demand "Send Status" (built)
The tray icon is a PER-USER process, not a service. A Windows service runs in
session 0 and cannot draw a tray icon in the operator's session, so the tray
ships as a second executable (`shopdb-endpoint-tray.exe`) that the installer
copies next to `shopdbagent.exe`, registers for logon autostart via the HKCU
Run key (`ShopDbEndpointTray`), and launches for the current user. Because the
Run key is per-user, the installer must be run from the operator's own session;
`-NoTray` yields a headless, service-only install.
DECISION - "Send Status now" reuses `--once`, it does not reimplement collection.
The tray's Send Status action shells out to `shopdbagent.exe --once`, the same
production collect-and-report path the service uses on its cadence. Consequences:
the tray holds NO collector key (the agent exe reads it from registry/appsettings
itself), there is one collection code path to maintain, and an operator can force
the ShopDB monitor page to refresh immediately instead of waiting for the next
cadence tick. The tray surfaces the agent's exit code as a balloon tip (0 =
sent, non-zero = check the rolling agent log). Note: `--once` honors
`adoptonfirstrun`, so an on-demand send can carry `adopt=true`; the server dedups
on hostname, so this is safe.
The ServiceNow ticket action stays a scaffold (section 6). A future desktop
shortcut to the same tray exe is the intended explicitly-launchable entry point
for that workflow; nothing about the Send Status wiring blocks it.
## 8. Web UI display (server-side, built)
The collected data surfaces through ShopDB's existing declaration-driven UI
@@ -153,3 +220,224 @@ Agent implication: the agent does not drive any UI refresh. Its only job is to
report on its rhythm; the UI reflects whatever the last report wrote. The
`services` payload should send a per-service run STATE (e.g. running/stopped) so
the badge can color it; a missing/unknown state renders as a gray Unknown badge.
## 9. Shop-floor collection knowledge base (reference, NOT a schema change)
This section is a body of reference, not a change request. Nothing here alters
the shape of ShopDB as it stands. It records WHAT a shop-floor endpoint really
is, WHICH signals identify it, and WHY each signal matters to the enterprise, so
that whoever grows the collector understands the stakes behind each field before
touching the wire contract. The source material is the site's own extant
tooling: `scripts/site_imports/assetcollector/Collect-ShopDB.ps1` (the mature
CSV collector), the migration/assessment flow under `scripts/.temp`
(`Assess-Orchestrator.ps1`, `Setup_ShopPC.ps1`, `Setup_InspectionPC.ps1`,
`eDncRegToUDC.ps1`, `machinetypeselector.bat`), and the eDNC/UDC/MTConnect config
formats those scripts read. Treat it as the "why" that the terse contract in
sections 1 through 8 cannot carry.
### 9.0 Why this matters beyond one plugin
This collector is a wedge into an enterprise-wide body of knowledge. A shop-floor
PC is never just a PC: it is the human-facing half of a machine tool, the thing
that feeds NC programs to a controller and streams data back off it. Getting its
identity, hardware, network, and application picture right lets ShopDB answer
questions that today require a person to walk the floor: which controller does
this box talk to, is its data path healthy, is the hardware under warranty, what
is it actually running. Every signal below earns its place by answering one of
those questions.
### 9.1 The endpoint taxonomy (how the floor classifies its own PCs)
`machinetypeselector.bat` plus the two `Setup_*` scripts show the site already
thinks in three roles. The collector should be able to DERIVE the role from
on-box evidence rather than ask an operator:
- Shop PC: runs UDC (Universal Data Collector) and/or eDNC, wired to one CNC
machine (FANUC / Siemens / Okuma controller). This is the ~2/3 majority.
- Inspection PC: metrology stack. Marked by `C:\GE Aerospace\Genspect\genspect.true`
(a flag file `Setup_InspectionPC.ps1` writes) and/or `C:\GE Aerospace\Gagecal`
(GageCal 6.4).
- Non-shop-floor PC: office box, no machine link, minimal shop apps.
Role is derivable from application/marker presence (section 9.4), which is a more
honest signal than the raw WMI `Model` string the current `pctype` probe sends.
### 9.2 Hardware facts, and WHY
`Collect-ShopDB.ps1 :: Get-PcFacts` reads `Win32_ComputerSystem`,
`Win32_OperatingSystem`, and `Win32_BIOS` for: vendor/manufacturer, model, OS
caption, serial (BIOS serial first, ComputerSystem serial as fallback), last
boot, logged-in user.
Why hardware is not cosmetic:
- Dell Warranty API linkage. Vendor + model family + service tag (BIOS serial)
is exactly the tuple the Dell Warranty API keys on. Capturing it lets ShopDB
resolve warranty status and hardware line remotely, so a support tech can
diagnose a hardware-level fault (failing disk, EOL platform, out-of-warranty
chassis) without physically finding the machine. The serial is the join key;
the model family is the diagnostic context.
- Remote hardware diagnosis generally. Knowing the exact hardware line turns
"some PC is flaky" into "this is a known-bad model/BIOS combination," which is
an enterprise-scale pattern once the fleet reports uniformly.
Collection note: prefer BIOS `SerialNumber` (the Dell service tag) and capture
manufacturer + model as distinct fields, since the warranty join needs the raw
service tag, not a normalized `pctype`.
### 9.3 Network interfaces, and WHY (the second-NIC reality)
`Get-PcFacts` and `Assess-Orchestrator.ps1 :: Get-EthernetNetworkSnapshot`
enumerate hardware adapters only and capture alias, IPv4, MAC, gateway, and
subnet (the orchestrator even reconstructs the mask from prefix length).
Why the interface list is first-class, not trivia:
- The second interface IS the machine link. Upwards of two-thirds of shop-floor
PCs have a SECOND network interface with a STATIC, manually mapped IP address
whose sole purpose is to talk to the related machine's onboard IPS
(industrial PC / controller network). That private NIC is physical evidence of
the PC-to-machine relationship and often the only place the machine-side
address is recorded. Losing it loses the wiring diagram of the floor.
- MAC as stable identity. MAC is the most stable hardware identifier across
re-image and rename, which is why every extant script grabs it. It is the
durable anchor when hostname/asset number churn during migration.
Collection note: send ALL hardware interfaces (alias, IPv4, MAC, and where
available gateway/subnet), not just the primary. The primary/gateway NIC is the
corporate LAN; the gatewayless static NIC is very likely the machine IPS link and
is the interesting one.
### 9.4 Installed applications, and WHY (the extrapolation engine)
`Get-PcFacts` reads the registry Uninstall keys (name, version, publisher,
skipping `SystemComponent`). On these boxes the installed-app set is small and
highly diagnostic: they primarily run a handful of known apps.
Why the app inventory is the richest signal we have:
- Apps imply role (section 9.1): UDC/eDNC => Shop PC; Genspect/GageCal =>
Inspection PC; neither => office.
- Apps imply the MACHINE binding. This is the crux. If UDC or eDNC (or MTConnect)
is installed, its config file carries the four-digit number ShopDB calls the
Machine Number, and from it we can extrapolate the ShopDB `machines` entry the
PC should be related to. The same value appears under different names across
the app generations:
- eDNC `.reg` / live registry: `MachineNo` (under
`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General`).
- UDC `udc_settings_*.json`: `GeneralSettings.MachineNumber`.
- MTConnect: workstation / workstation ID / MachineNo, carried in the
adapter/agent config under the fixed directory tree documented in section
9.9.
Treat "MachineNo", "workstation", "workstation ID", and "MachineNumber" as the
same concept: the ShopDB Machine Number. Version and publisher are worth
keeping too (they date the app generation, which tells you which config format
to parse).
Collection note: the app inventory is not just an asset list; it is the pointer
that tells the collector WHICH config file to open next to find the machine
binding.
### 9.5 The machine binding (DNC/UDC/MTConnect config), and WHY
`Collect-ShopDB.ps1 :: Resolve-DncSource` resolves the machine config with a
SOURCE-PRIORITY chain, then three parsers normalize the formats into one shape:
- Priority: explicit JSON path -> explicit `.reg` path -> live registry
(`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg` export -> local
`udc_settings_*.json`. This ordering makes one probe work across both the
eDNC-era and UDC-era boxes.
- Parsers: `ConvertFrom-RegExport` (parses exported `.reg`, including
`dword:` decoding), `ConvertFrom-LiveRegistry`, `ConvertFrom-UdcJson`.
- Extracted: machine number, site, control type, DNC type, and connection detail
(TCP address/port for ethernet; COM port/baud for serial).
- Controller vendor is DERIVED, not stored raw: `Resolve-ControllerVendor` maps
`NcIF = EFOCAS -> FANUC`, `Cnc = SIEMENS -> Siemens`, `Cnc = OKUMA -> Okuma`.
`eDncRegToUDC.ps1` confirms the same mapping and the eFocas/PPDCS key layout.
Why it matters: this binding is what upgrades a bare endpoint into "the PC that
controls machine 1234 over eFOCAS at 10.x.x.x." That relationship is the point of
the whole exercise; the hardware and app facts are supporting evidence for it.
### 9.6 Printers, and WHY
`Assess-Orchestrator.ps1 :: Get-PrinterSnapshot` / `Get-PrinterPortSnapshot`
capture name, driver, port, host address, shared/published, and the default
flag, filtering out virtual outputs (Print to PDF, XPS, Fax, OneNote). Shop and
inspection PCs are tied to specific physical/label printers, so the printer set
is part of a workstation's operational identity and a common migration break
point (a replaced PC that lost its label printer is a floor stoppage).
### 9.7 Patterns from the extant tooling worth carrying forward
Two engineering habits in these scripts are worth honoring in the collector,
because they encode hard-won migration lessons:
- Field-level idempotent merge (`Merge-CsvStore`): an empty incoming cell NEVER
overwrites a previously captured value; later partial runs fill gaps without
wiping earlier data. The server's endpoints ingest already mirrors this instinct
(`if 'field' in payload`), and it should stay that way: a partial report means
"leave the rest alone," not "clear it."
- Normalized intermediate shape + sidecars: heterogeneous sources (registry,
`.reg`, JSON, WMI) all converge on one normalized record before import, with
phase-2 data (interfaces, installed apps, PC-to-machine link) written as JSON
sidecars. The same decoupling lets a future non-agent source (Intune/Azure
`Get-MgDevice`, a MES/CNC database) feed the same model without a new pipeline.
### 9.9 MTConnect footprint, and WHY (three parallel stacks per box)
Source: `scripts/.temp/Install_MTConnect_ExisDir.ps1`,
`Install_MTConnect_ExisDir_BatConvert.ps1`, its usage guide
`Guide-Install_MTConnect_ExisDir_BatConvert.md`, and
`Firewall_Rules_MTConnect_All.bat`. MTConnect is the standardized data-off-the-
machine layer, and the install scripts reveal its exact on-disk shape, which is
what a collector needs to detect it reliably.
The critical structural fact: an endpoint can host up to THREE parallel MTConnect
stacks, one per data path, each an Adapter (talks to the machine) plus an Agent
(serves the MTConnect HTTP feed):
- Machine-direct: `C:\MTConnect\Adapter` + `C:\MTConnect\Agent` (Agent exe
`C:\MTConnect\Agent\Agent.exe`).
- eDNC-fed: `C:\MTConnect_eDNC\Adapter` + `C:\MTConnect_eDNC\Agent`
(`...\Agent\agent.exe`). The eDNC adapter is `DNCAdapter.exe` driven by
`DNCAdapter.ini`, tying this path back to the eDNC config world in section 9.5.
- UDC-fed: `C:\ProgramData\udc\mtconnect_udc\agent` (`...\agent\agent.exe`,
launched via `run.bat`). This binds MTConnect to the UDC install and therefore
to the same `udc_settings_*.json` that carries `GeneralSettings.MachineNumber`.
Why the collector should care about these paths:
- Presence is role/path evidence. Which of the three trees exist tells you HOW the
machine's data is being lifted (direct vs eDNC vs UDC), which is a real
operational distinction, not a cosmetic one.
- The MTConnect Agent is a network service. The firewall bat and the
`Ensure-FirewallProgramRule` calls open inbound rules named
`Allow_MTConnect_Machine`, `Allow_MTConnect_UDC`, `Allow_MTConnect_eDNC` for
each `Agent.exe`. That means each agent listens on an HTTP port, so its live
MTConnect feed (which publishes a device UUID / station identity) is itself a
future data source the collector could probe, not just a process to count.
- Machine Number lives here too. Because the eDNC path is `DNCAdapter.ini` and the
UDC path is the UDC settings JSON, the MTConnect footprint is a second route to
the same four-digit Machine Number described in 9.4/9.5, cross-checkable against
the eDNC/UDC values.
Collection note: detect MTConnect by the three directory trees, capture which
Agent exes exist and whether their service/process is running (section 5 already
covers MTConnect as a monitored service), and record the data path
(direct/eDNC/UDC).
(`Install_MTConnect_ExisDir_BatConvert.ps1`) is a deployment concern, not a
collection concern, but it explains why these `install.bat`/`start.bat` files
were rewritten to self-elevate without a `pause`: so they can run unattended
under DSC on locked-down machines. Do not treat a self-elevating batch as
tampering.
### 9.8 Boundary (explicit)
None of section 9 is a ShopDB schema proposal. Where a signal above has no home
in the current model, that is fine: it stays reference until a separate, deliberate
decision (its own ADR / contract bump) decides to store it. The value here is
comprehension: anyone extending the collector should know that hardware feeds
warranty, the second NIC is the machine link, and the installed apps are the
thread that leads to the Machine Number. Build the agent with that understanding;
do not let this document, by itself, move the schema.

View File

@@ -7,17 +7,21 @@ These routes are the human-facing read surface, gated by user JWT + RBAC.
"""
import json
import time
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required, current_user
from shopdb.api import (
db, Asset, Setting, success_response, error_response, paginated_response,
ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, mint_service_token, list_service_tokens, revoke_service_tokens,
AuditLog, collector_authorized,
)
from ..models import EndpointState, EndpointEvent
from ..models import EndpointState, EndpointEvent, EndpointCommand
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
endpoints_bp = Blueprint('endpoints', __name__)
@@ -188,14 +192,27 @@ def panel_services(asset_id: int):
if isinstance(entry, dict):
name = entry.get('name') or entry.get('service') or 'Unknown'
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
app = entry.get('app')
kind = entry.get('kind')
else:
name, raw = str(entry), ''
name, raw, app, kind = str(entry), '', None, None
running = raw in ('running', 'started', 'up', 'ok', 'active')
rows.append({
row = {
'name': name,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
'statecolor': 'green' if running else ('red' if raw else 'gray'),
})
}
targets = row_actions_for(app, kind, name)
if targets:
row['action'] = {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
rows.append(row)
return success_response(rows)
@@ -223,6 +240,48 @@ def panel_recent_events(asset_id: int):
return success_response(rows)
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
@jwt_required()
@require_permission('endpoints.view')
def panel_interfaces(asset_id: int):
"""Table panel: every hardware NIC the agent last reported.
Reshapes the latest interfacesjson snapshot into one row per adapter with
the {alias, ipv4, mac, gateway, subnet, dhcp} shape the collector emits. A
gatewayless adapter is flagged as the likely machine-IPS link (see
DESIGN-DECISIONS-agent.md 9.3): those NICs speak to the machine, not the
corporate LAN. Tolerant of a missing or unexpected snapshot: an unreadable
payload yields no rows so the panel hides itself.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state or not state.interfacesjson:
return success_response([])
try:
interfaces = json.loads(state.interfacesjson)
except (TypeError, ValueError):
return success_response([])
rows = []
for entry in (interfaces or []):
if not isinstance(entry, dict):
continue
gateway = (entry.get('gateway') or '').strip()
ipv4 = (entry.get('ipv4') or '').strip()
rows.append({
'alias': entry.get('alias') or 'Unknown',
'ipv4': ipv4 or '-',
'mac': (entry.get('mac') or '').strip() or '-',
'gateway': gateway or '-',
'subnet': (entry.get('subnet') or '').strip() or '-',
'dhcp': 'DHCP' if entry.get('dhcp') else 'Static',
# A gatewayless NIC with an address is the machine link; the gateway
# NIC is the corporate LAN.
'role': 'Machine link' if (ipv4 and not gateway) else 'LAN',
})
return success_response(rows)
# =============================================================================
# Dashboard
# =============================================================================
@@ -348,3 +407,304 @@ def _quiet_hours() -> int:
except (TypeError, ValueError):
pass
return hours
# =============================================================================
# Endpoint agent key (UI-managed collector.ingest.endpoints kill switch)
# =============================================================================
# The single permission the endpoint agent key authorizes. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing else, so
# rotating/revoking it stops endpoint-agent traffic without touching the
# computers/geenforce collectors.
AGENT_KEY_SCOPE = 'collector.ingest.endpoints'
AGENT_KEY_NAME = 'Endpoint agent key'
def _agentkey_status_payload():
"""Current state of the endpoint agent key for the settings page.
Reports whether an active managed key exists (and its non-secret metadata)
plus whether the bootstrap .env fallback is still accepted. Never exposes a
secret; the full secret is only ever returned once, at regenerate time.
"""
tokens = list_service_tokens(AGENT_KEY_SCOPE)
active = tokens[0] if tokens else None
envfallback = Setting.get('plugin.endpoints.envkeyfallback', default=True)
provisioned = Setting.get('plugin.endpoints.agentkeyprovisioned',
default=False)
return {
'provisioned': bool(provisioned) or active is not None,
'envkeyfallback': bool(envfallback),
'key': active.to_dict() if active is not None else None,
}
@endpoints_bp.route('/agentkey', methods=['GET'])
@jwt_required()
@require_role('admin')
def get_agentkey():
"""Report the endpoint agent key state (no secret)."""
return success_response(_agentkey_status_payload())
@endpoints_bp.route('/agentkey/regenerate', methods=['POST'])
@jwt_required()
@require_role('admin')
def regenerate_agentkey():
"""Mint a fresh endpoint agent key, revoking any previous one.
Hard cutover: the old key is deactivated in the same call, and the .env
fallback is switched off, so the new managed token becomes the sole
credential and revoking it is a true kill switch. The full secret is
returned ONCE here and never again.
"""
try:
token, secret = mint_service_token(
AGENT_KEY_SCOPE, AGENT_KEY_NAME, current_user.userid)
except PermissionError as error:
return error_response(ErrorCodes.FORBIDDEN, str(error), http_code=403)
Setting.set('plugin.endpoints.agentkeyprovisioned', True,
valuetype='boolean', category='plugin.endpoints')
Setting.set('plugin.endpoints.envkeyfallback', False,
valuetype='boolean', category='plugin.endpoints')
result = _agentkey_status_payload()
# The secret appears here and NOWHERE else, ever. Not stored, not logged.
result['secret'] = secret
result['warning'] = ('Save this key now. It will not be shown again. '
'Configure it as the agent X-API-Key.')
return success_response(result, message='Endpoint agent key generated',
http_code=201)
@endpoints_bp.route('/agentkey', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def revoke_agentkey():
"""Revoke the endpoint agent key: stop all managed-token endpoint traffic.
Leaves envkeyfallback off (the kill switch stays armed). To restore
reporting an operator regenerates a new key.
"""
revoked = revoke_service_tokens(AGENT_KEY_SCOPE)
Setting.set('plugin.endpoints.agentkeyprovisioned', False,
valuetype='boolean', category='plugin.endpoints')
result = _agentkey_status_payload()
result['revoked'] = revoked
return success_response(result, message='Endpoint agent key revoked')
# =============================================================================
# Endpoint commands (WebUI -> agent, allowlisted actions only)
# =============================================================================
#
# Operators enqueue a named, server-allowlisted action for one endpoint (JWT +
# endpoints.command). The agent long-polls /commands/next with its collector
# token, claims the single next pending command, runs it locally, and reports
# the outcome to /commands/<id>/result. The wire never carries a command string
# - only an allowlisted action name - so a compromised WebUI cannot ask an
# endpoint to run something arbitrary.
# How long the agent claim poll is held open server-side before returning empty.
# Kept below the agent HttpClient timeout and typical proxy idle timeouts so a
# quiet fleet re-polls cleanly instead of tripping a read timeout.
_CLAIM_HOLD_SECONDS = 25
_CLAIM_POLL_INTERVAL = 1.0
def _collector_auth_or_error():
"""Authorize an agent-facing command route on the endpoints collector token.
Reuses the collector auth path so the command channel shares the endpoint
agent key (kill switch) and introduces no second secret.
"""
return collector_authorized('endpoints')
def _resolve_asset_for_hostname(hostname):
"""Resolve a reported hostname to its asset id via the computers plugin."""
from plugins.computers.models import Computer
computer = (Computer.query
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
return computer.assetid if computer is not None else None
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
@jwt_required()
@require_permission('endpoints.view')
def list_endpoint_commands(asset_id: int):
"""Recent commands for one endpoint, newest first (operator view)."""
rows = (EndpointCommand.query
.filter_by(assetid=asset_id)
.order_by(EndpointCommand.createdat.desc())
.limit(20)
.all())
return success_response([row.to_dict() for row in rows])
@endpoints_bp.route('/<int:asset_id>/commands', methods=['POST'])
@jwt_required()
@require_permission('endpoints.command')
def enqueue_endpoint_command(asset_id: int):
"""Queue an allowlisted command for one endpoint.
Rejects unknown actions and refuses to stack a second pending/running
command on the same endpoint, so the queue stays at-most-one-in-flight and
the WebUI toggle cannot race itself.
"""
payload = request.get_json(silent=True) or {}
action = (payload.get('action') or '').strip()
if not is_allowed_action(action):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown or disallowed action: {action!r}')
if action not in ALLOWED_ACTIONS_FOR(assetid=asset_id):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action not permitted by policy for this endpoint: {action!r}')
# Generic actions (service_* / process_*) must carry the subject to act on;
# fixed-target actions (mtconnect_*) must not. Validate both directions so a
# caller cannot send a targetless generic action (nothing to act on) or
# smuggle a target onto a pinned action.
target = (payload.get('target') or '').strip() or None
targetkind = action_targetkind(action)
if requires_target(action):
if not target:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} requires a target')
elif target is not None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} does not accept a target')
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No endpoint state for asset {asset_id}',
http_code=404)
pending = (EndpointCommand.query
.filter(EndpointCommand.assetid == asset_id,
EndpointCommand.status.in_((
EndpointCommand.STATUS_PENDING,
EndpointCommand.STATUS_RUNNING)))
.first())
if pending is not None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'A command is already in flight for this endpoint',
http_code=409)
command = EndpointCommand(
assetid=asset_id,
action=action,
target=target,
targetkind=targetkind if target else None,
status=EndpointCommand.STATUS_PENDING,
requestedby=getattr(current_user, 'userid', None),
)
db.session.add(command)
AuditLog.log('created', 'EndpointCommand',
entityname=action,
details=f'action={action} target={target} asset={asset_id}')
db.session.commit()
return success_response(command.to_dict(),
message='Command queued', http_code=201)
@endpoints_bp.route('/commands/next', methods=['GET'])
def claim_next_command():
"""Agent long-poll: claim the next pending command for a hostname.
Collector-token auth (not JWT). Holds the request open briefly and returns
the single oldest pending command for the caller's endpoint, atomically
marking it running so no other poll claims it. Returns 204 when nothing is
queued within the hold window - the agent simply re-polls.
"""
error = _collector_auth_or_error()
if error is not None:
return error
hostname = (request.args.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
asset_id = _resolve_asset_for_hostname(hostname)
if asset_id is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Unknown hostname: {hostname!r}', http_code=404)
deadline = time.monotonic() + _CLAIM_HOLD_SECONDS
while True:
command = (EndpointCommand.query
.filter_by(assetid=asset_id,
status=EndpointCommand.STATUS_PENDING)
.order_by(EndpointCommand.createdat.asc())
.first())
if command is not None:
command.status = EndpointCommand.STATUS_RUNNING
command.claimedat = _naive_utcnow()
db.session.commit()
return success_response({
'endpointcommandid': command.endpointcommandid,
'action': command.action,
'target': command.target,
'targetkind': command.targetkind,
})
# Nothing queued: release the session so a concurrent enqueue is visible
# on the next poll, wait a beat, then re-check until the hold expires.
db.session.rollback()
if time.monotonic() >= deadline:
return ('', 204)
time.sleep(_CLAIM_POLL_INTERVAL)
@endpoints_bp.route('/commands/<int:command_id>/result', methods=['POST'])
def report_command_result(command_id: int):
"""Agent reports a claimed command's outcome (collector-token auth).
Finalizes the command row and, for MTConnect actions that observed the run
state, caches it on the EndpointState so the WebUI toggle reflects reality
between polls. Both writes land in one commit.
"""
error = _collector_auth_or_error()
if error is not None:
return error
command = db.session.get(EndpointCommand, command_id)
if command is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No command {command_id}', http_code=404)
payload = request.get_json(silent=True) or {}
succeeded = bool(payload.get('succeeded'))
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
else EndpointCommand.STATUS_FAILED)
command.resultmessage = (payload.get('message') or '')[:2000] or None
detail = payload.get('detail')
command.resultjson = json.dumps(detail, separators=(',', ':')) \
if detail is not None else None
command.completedat = _naive_utcnow()
# Cache the observed MTConnect state so the toggle shows a real value. The
# agent sends mtconnectrunning on any mtconnect_* result that probed it.
if 'mtconnectrunning' in payload:
state = EndpointState.query.filter_by(assetid=command.assetid).first()
if state is not None:
running = payload.get('mtconnectrunning')
state.mtconnectrunning = bool(running) if running is not None else None
state.mtconnectcheckedat = _naive_utcnow()
db.session.commit()
return success_response(command.to_dict(), message='Result recorded')

View File

@@ -0,0 +1,177 @@
"""Server-side allowlist of endpoint agent commands.
The WebUI never sends an arbitrary command string. It sends an allowlisted
ACTION NAME; the concrete on-endpoint behavior (which script to run, how to stop
a process) lives in the agent. This module is the single source of truth for
which action names are legal and what they mean, so both the enqueue route and
the settings/UI surface validate against the same set.
The structure anticipates per-PC-type allowlists: today every computer may run
every action in COMMAND_ALLOWLIST, but ALLOWED_ACTIONS_FOR is the seam a later
change swaps for a type-scoped lookup (inspection PCs vs shopfloor PCs).
"""
# action name -> operator-facing metadata. label/description feed the UI; the
# agent maps the action name to its local handler (see CommandExecutor).
#
# Two families of action:
# * Named MTConnect actions - fixed target, no parameters. The original,
# bay-specific controls kept for the inline services-panel toggle.
# * Generic RMM actions (service_* / process_*) - the target is supplied by
# the policy action's params ('target' = a Windows service name or a process
# image like 'DncMain.exe'). This is what populates the Configuration Policy
# builder with reusable controls that any scope (PC type or application) can
# attach. The 'targetkind' hint tells the builder which target field to show.
# NOTE: agent-side execution of the generic actions is not wired yet; the
# server allowlist and policy plumbing land first (the whole feature is
# flag-gated), so a generic action saved today is authored and gated but is
# a no-op on the endpoint until the CommandExecutor handlers ship.
COMMAND_ALLOWLIST = {
'service_start': {
'label': 'Start service',
'description': 'Start a Windows service by name on the endpoint.',
'targetkind': 'service',
},
'service_stop': {
'label': 'Stop service',
'description': 'Stop a running Windows service by name on the endpoint.',
'targetkind': 'service',
},
'service_status': {
'label': 'Check service',
'description': 'Report whether a named Windows service is running.',
'targetkind': 'service',
},
'process_start': {
'label': 'Start process',
'description': 'Launch a process by executable path on the endpoint.',
'targetkind': 'process',
},
'process_stop': {
'label': 'Stop process',
'description': 'Stop a running process by image name on the endpoint.',
'targetkind': 'process',
},
'process_status': {
'label': 'Check process',
'description': 'Report whether a named process is running.',
'targetkind': 'process',
},
'mtconnect_start': {
'label': 'Start MTConnect',
'description': 'Run the MTConnect UDC agent start script on the endpoint.',
},
'mtconnect_stop': {
'label': 'Stop MTConnect',
'description': 'Stop the running MTConnect UDC agent on the endpoint.',
},
'mtconnect_status': {
'label': 'Check MTConnect',
'description': 'Report whether the MTConnect UDC agent is running.',
},
}
# Maps a monitored services/processes row to the start/stop/status actions that
# control it, so the services panel can render an inline toggle on exactly the
# rows that have a registered command action. The key is the agent-reported
# 'app' label; 'kind' pins the toggle to the authoritative row (for MTConnect
# the agent.exe PROCESS is the source of truth - run.bat launches it and the
# Windows service state is not reliable), so the service row stays badge-only.
ROW_COMMAND_TARGETS = {
('MTConnect', 'process'): {
'start': 'mtconnect_start',
'stop': 'mtconnect_stop',
'status': 'mtconnect_status',
},
}
def row_actions_for(app, kind, name=None):
"""Return the {start, stop, status, target, targetkind} map for a row.
Two paths:
* A row registered in ROW_COMMAND_TARGETS (MTConnect) keeps its fixed,
named actions with no wire target - agent.exe is the source of truth and
the behavior is pinned in the agent.
* Any other process/service row maps to the generic primitives
(process_* / service_*) with the row's OWN reported name as the wire
target. This is what lets a new app light up start/stop/status without
an agent rebuild: the target rides the wire.
Rows with no usable kind return None and render as a plain status badge.
"""
if not app and not name:
return None
fixed = ROW_COMMAND_TARGETS.get((app, (kind or '').lower()))
if fixed:
return {**fixed, 'target': None, 'targetkind': None}
normalized = (kind or '').lower()
if normalized == 'process' and name:
return {
'start': 'process_start',
'stop': 'process_stop',
'status': 'process_status',
'target': name,
'targetkind': 'process',
}
if normalized == 'service' and name:
return {
'start': 'service_start',
'stop': 'service_stop',
'status': 'service_status',
'target': name,
'targetkind': 'service',
}
return None
def is_allowed_action(action):
"""True when action is a known, allowlisted command name."""
return action in COMMAND_ALLOWLIST
def action_targetkind(action):
"""The targetkind an action requires ('service'/'process'), or None.
Generic actions declare a targetkind in COMMAND_ALLOWLIST; fixed-target
actions (mtconnect_*) declare none and must not carry a wire target.
"""
meta = COMMAND_ALLOWLIST.get(action) or {}
return meta.get('targetkind')
def requires_target(action):
"""True when the action is generic and needs a wire target."""
return action_targetkind(action) is not None
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
"""The action names an endpoint may run.
Base allowlist is flat (every action for every computer). When the computers
Configuration/Business Policy feature is enabled AND an assetid is given, the
result is narrowed to the actions the endpoint's effective policy permits -
this is the allowlist-gating half of the policy design. With the feature off
(or no assetid), it returns the full base allowlist, so nothing changes until
a site opts in. Policy resolution is imported lazily to keep this module free
of a hard dependency on the computers plugin.
"""
base = set(COMMAND_ALLOWLIST.keys())
if assetid is None:
return base
try:
from plugins.computers.services.policyresolver import effective_policy_for
except Exception:
return base
effective = effective_policy_for(assetid)
allowed = effective.allowed_actions()
if not allowed:
# No policy spoke for this endpoint. With the feature off the resolver
# returns empty and we fall back to the base allowlist (unchanged
# behavior). This is intentionally permissive; deny-by-default would be
# a separate, explicit setting.
return base
# Never widen past the code-level allowlist: policy can only gate within it.
return base & allowed

View File

@@ -11,6 +11,12 @@ export default [
name: 'endpoints',
component: () => import('./views/EndpointsList.vue'),
meta: { plugin: 'endpoints' }
},
{
path: 'settings/endpoints/agentkey',
name: 'endpoints-agentkey-settings',
component: () => import('./views/AgentKeySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
}
]

View File

@@ -0,0 +1,293 @@
<template>
<div>
<div class="page-header">
<h2>Endpoints</h2>
</div>
<p class="setting-description">
Manage how shop-floor endpoint agents authenticate to this server, build
the agent for this site, and set what it reports and how often.
</p>
<!-- ====================================================================
Key management
================================================================= -->
<section class="settings-section">
<h3>Key management</h3>
<p class="field-hint">
The endpoint agent reports with a single managed key scoped to endpoint
telemetry only. It is separate from every other collector, so rotating
or revoking it stops all endpoint-agent traffic immediately without
touching the PC or GE-Enforce collectors.
</p>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="loading" class="field-hint">Loading...</div>
<template v-else>
<div class="form-group">
<label>Status</label>
<p v-if="key" class="field-hint">
Active key <code class="mono">{{ key.tokenprefix }}...</code>,
created {{ formatDate(key.createddate) }}.
<template v-if="key.lastusedat">
Last used {{ formatDate(key.lastusedat) }}.
</template>
<template v-else>Not used yet.</template>
</p>
<p v-else class="field-hint">
No endpoint agent key is active. Generate one to let agents report
with a managed credential.
</p>
</div>
<div class="form-group">
<label>Bootstrap .env fallback</label>
<p class="field-hint">
<template v-if="envkeyfallback">
<strong>On.</strong> The bootstrap COLLECTOR_API_KEY still
authorizes endpoint reports. Generating a key turns this off so
the managed key becomes the only credential.
</template>
<template v-else>
<strong>Off.</strong> Only the managed key above authorizes
endpoint reports, so revoking it is an instant kill switch.
</template>
</p>
</div>
<div class="button-row">
<button class="btn btn-primary" @click="regenerate"
:disabled="busy">
{{ busy ? 'Working...' : (key ? 'Regenerate key' : 'Generate key') }}
</button>
<button v-if="key" class="btn btn-danger" @click="revoke"
:disabled="busy">
Revoke key
</button>
</div>
</template>
</div>
<div v-if="secret" class="card form-card secret-card">
<h4>Save this key now</h4>
<p class="field-hint">
It will not be shown again. Configure it as the agent
<code>X-API-Key</code> header value.
</p>
<p class="mono secret-value">{{ secret }}</p>
<button class="btn btn-secondary" @click="copySecret">
{{ copied ? 'Copied' : 'Copy to clipboard' }}
</button>
</div>
<div class="card form-card">
<h4>How it works</h4>
<ul class="field-hint info-list">
<li>
<strong>Generate</strong> mints a fresh key and, on the first
generation, turns off the bootstrap <code>.env</code> fallback so
the managed key becomes the only accepted credential. The full
secret is shown once - store it in the agent config as the
<code>X-API-Key</code> header.
</li>
<li>
<strong>Regenerate</strong> is a hard cutover: the previous key is
revoked in the same moment the new one is minted. Every deployed
agent must be reconfigured with the new key before it can report
again.
</li>
<li>
<strong>Revoke</strong> deactivates the key and leaves it off. All
endpoint-agent reporting stops until you generate a new key. This is
the kill switch.
</li>
</ul>
<h4>When to rotate</h4>
<ul class="field-hint info-list">
<li>
<strong>An installer or agent left the secure environment.</strong>
If a build carrying the key is copied off the shop floor - onto an
engineer's laptop, a USB stick, or a machine that leaves the site -
treat the key as compromised and regenerate immediately.
</li>
<li>
<strong>A device is lost, stolen, or decommissioned</strong> while
still holding the key, and you cannot confirm the key was wiped.
</li>
<li>
<strong>Staff offboarding.</strong> Someone who could read the key
from a config or build pipeline is leaving; rotate as part of the
offboarding checklist.
</li>
<li>
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
(for example quarterly) so a silently leaked key has a bounded
lifetime.
</li>
<li>
<strong>Suspicious traffic.</strong> The Fleet view shows reports
from a host you do not recognize - revoke now, investigate, then
regenerate and redeploy to known-good endpoints.
</li>
</ul>
</div>
</section>
<!-- ====================================================================
Build site agent (placeholder)
================================================================= -->
<section class="settings-section">
<h3>Build site agent</h3>
<div class="card form-card">
<p class="field-hint">
Produce an instance-locked agent executable stamped with this server's
URL, the endpoint agent key, and the reporting cadence, so it deploys
with no manual configuration on the client.
</p>
<p class="field-hint">
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
the C# agent runtime is still under development. Generate a key above
and configure it manually as the agent <code>X-API-Key</code> for now.
</p>
<button class="btn btn-secondary" type="button" disabled>
Build agent (coming soon)
</button>
</div>
</section>
<!-- ====================================================================
Agent configuration (placeholder)
================================================================= -->
<section class="settings-section">
<h3>Agent configuration</h3>
<div class="card form-card">
<p class="field-hint">
Define what the agent collects and how often: reporting cadence,
monitored processes and services, the curated Windows event list, and
whether unknown hosts self-adopt on first contact. These settings are
stamped into the build above so a deployed agent needs no local edits.
</p>
<p class="field-hint">
<strong>Coming soon.</strong> These controls arrive with the C# agent
runtime. Until then the agent uses its built-in defaults.
</p>
<button class="btn btn-secondary" type="button" disabled>
Edit configuration (coming soon)
</button>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const loading = ref(true)
const busy = ref(false)
const key = ref(null)
const envkeyfallback = ref(true)
const secret = ref('')
const copied = ref(false)
const message = ref('')
const error = ref('')
function formatDate(value) {
if (!value) return ''
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
}
function applyStatus(data) {
key.value = data.key || null
envkeyfallback.value = !!data.envkeyfallback
}
onMounted(async () => {
try {
const response = await api.get('/endpoints/agentkey')
applyStatus(response.data.data || {})
} catch (loadError) {
error.value = loadError.response?.status === 403
? 'Only an administrator can manage the endpoint agent key'
: 'Could not load the endpoint agent key status'
console.error(loadError)
} finally {
loading.value = false
}
})
async function regenerate() {
if (key.value &&
!window.confirm('Regenerate the key? The current key stops working '
+ 'immediately and every agent must be reconfigured.')) {
return
}
busy.value = true
message.value = ''
error.value = ''
secret.value = ''
copied.value = false
try {
const response = await api.post('/endpoints/agentkey/regenerate')
const data = response.data.data || {}
applyStatus(data)
secret.value = data.secret || ''
message.value = 'Endpoint agent key generated.'
} catch (regenError) {
error.value = regenError.response?.data?.data?.error?.message
|| 'Could not generate the endpoint agent key'
} finally {
busy.value = false
}
}
async function revoke() {
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
+ 'until a new key is generated.')) {
return
}
busy.value = true
message.value = ''
error.value = ''
secret.value = ''
try {
const response = await api.delete('/endpoints/agentkey')
applyStatus(response.data.data || {})
message.value = 'Endpoint agent key revoked.'
} catch (revokeError) {
error.value = revokeError.response?.data?.data?.error?.message
|| 'Could not revoke the endpoint agent key'
} finally {
busy.value = false
}
}
async function copySecret() {
try {
await navigator.clipboard.writeText(secret.value)
copied.value = true
} catch (copyError) {
console.error(copyError)
}
}
</script>
<style scoped>
.mono { font-family: monospace; word-break: break-all; }
.form-card h3 { margin-top: 0; }
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
.form-card h4:first-child { margin-top: 0; }
.button-row { display: flex; gap: 0.5rem; }
.secret-card { border: 1px solid var(--color-warning, #b8860b); }
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
.settings-section { margin-bottom: 2rem; }
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.info-list li { margin-bottom: 0.5rem; }
</style>

View File

@@ -0,0 +1,84 @@
"""endpoint commands queue + cached MTConnect state.
Adds the endpointcommands queue (one WebUI-issued, server-allowlisted action
per row, claimed and finalized by the agent long-poll) and two cached MTConnect
run-state columns on endpointstates so the WebUI toggle can show a real state
between command polls.
Idempotent: create_table_if_missing guards the new table, and the column adds
check the live schema first so a db.create_all() site (which already built the
columns) re-runs clean on strict MySQL 8.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0002commands'
down_revision = 'endpoints0001baseline'
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('endpointstates', 'mtconnectrunning'):
op.add_column('endpointstates',
sa.Column('mtconnectrunning', sa.Boolean(),
nullable=True))
if not _has_column('endpointstates', 'mtconnectcheckedat'):
op.add_column('endpointstates',
sa.Column('mtconnectcheckedat', sa.DateTime(),
nullable=True))
create_table_if_missing(
'endpointcommands',
sa.Column('endpointcommandid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=64), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('requestedby', sa.Integer(), nullable=True),
sa.Column('resultmessage', sa.Text(), nullable=True),
sa.Column('resultjson', sa.Text(length=16777215), nullable=True),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.Column('claimedat', sa.DateTime(), nullable=True),
sa.Column('completedat', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointcommandid'),
)
create_index_if_missing('ix_endpointcommands_assetid',
'endpointcommands', ['assetid'])
create_index_if_missing('ix_endpointcommands_status',
'endpointcommands', ['status'])
create_index_if_missing('idx_endpointcommands_assetstatus',
'endpointcommands', ['assetid', 'status'])
def downgrade():
op.drop_index('idx_endpointcommands_assetstatus',
table_name='endpointcommands')
op.drop_index('ix_endpointcommands_status',
table_name='endpointcommands')
op.drop_index('ix_endpointcommands_assetid',
table_name='endpointcommands')
op.drop_table('endpointcommands')
if _has_column('endpointstates', 'mtconnectcheckedat'):
op.drop_column('endpointstates', 'mtconnectcheckedat')
if _has_column('endpointstates', 'mtconnectrunning'):
op.drop_column('endpointstates', 'mtconnectrunning')

View File

@@ -0,0 +1,50 @@
"""endpoint command target for generic service/process actions.
Adds nullable target and targetkind columns to endpointcommands so generic
service_* / process_* actions can carry their subject (a Windows service name or
a process image like 'DncMain.exe') over the wire. Fixed-target actions
(mtconnect_*) leave both null. This is the schema half of making the agent
data-driven: a site builds the agent once and every future policy/app is server
data, no agent redistribution.
Idempotent: each add checks the live schema first so a db.create_all() site
(which already built the columns) re-runs clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0003cmdtarget'
down_revision = 'endpoints0002commands'
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', 'target'):
op.add_column('endpointcommands',
sa.Column('target', sa.String(length=255),
nullable=True))
if not _has_column('endpointcommands', 'targetkind'):
op.add_column('endpointcommands',
sa.Column('targetkind', sa.String(length=16),
nullable=True))
def downgrade():
if _has_column('endpointcommands', 'targetkind'):
op.drop_column('endpointcommands', 'targetkind')
if _has_column('endpointcommands', 'target'):
op.drop_column('endpointcommands', 'target')

View File

@@ -1,5 +1,5 @@
"""Endpoints plugin models."""
from .endpoint import EndpointState, EndpointEvent
from .endpoint import EndpointState, EndpointEvent, EndpointCommand
__all__ = ['EndpointState', 'EndpointEvent']
__all__ = ['EndpointState', 'EndpointEvent', 'EndpointCommand']

View File

@@ -75,6 +75,12 @@ class EndpointState(db.Model):
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
# Last-known MTConnect agent run state, cached so the WebUI toggle can show
# a real state between command polls. Set by the agent's mtconnect_status
# result. Null = never reported (unknown).
mtconnectrunning = db.Column(db.Boolean, nullable=True)
mtconnectcheckedat = db.Column(db.DateTime, nullable=True)
# When the agent last successfully reported. There is no heartbeat separate
# from the report, so silence past the quiet-hours window is the only
# evidence an endpoint has stopped checking in.
@@ -97,11 +103,94 @@ class EndpointState(db.Model):
'idleseconds': self.idleseconds,
'cpupercent': self.cpupercent,
'memorypercent': self.memorypercent,
'mtconnectrunning': self.mtconnectrunning,
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
'lastreportedat': _utciso(self.lastreportedat),
'createdat': _utciso(self.createdat),
}
class EndpointCommand(db.Model):
"""A queued operator command targeting one endpoint agent.
The WebUI enqueues a named action (server allowlist only, never an arbitrary
command string) for an asset. The agent claims the next pending command on
its long-poll, executes it locally, and reports the outcome back, which
moves the row to a terminal status in the same request. This is a small
at-most-one-in-flight queue, not a general job system.
"""
__tablename__ = 'endpointcommands'
# Lifecycle: pending -> running -> succeeded|failed. A command is claimed
# (pending -> running) by exactly one agent poll, then finalized by its
# result post.
STATUS_PENDING = 'pending'
STATUS_RUNNING = 'running'
STATUS_SUCCEEDED = 'succeeded'
STATUS_FAILED = 'failed'
endpointcommandid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
asset = db.relationship('Asset', lazy='joined', viewonly=True)
# The allowlisted action name (for example 'mtconnect_start'). Validated
# against the server allowlist at enqueue time, so only known names land.
action = db.Column(db.String(64), nullable=False)
# Generic actions (service_* / process_*) carry their subject over the wire:
# target is a Windows service name or a process image (for example
# 'DncMain.exe'); targetkind is 'service' or 'process'. Fixed-target actions
# (mtconnect_*) leave both null. This is what lets a site build the agent
# once and drive every future policy/app purely from server data.
target = db.Column(db.String(255), nullable=True)
targetkind = db.Column(db.String(16), nullable=True)
status = db.Column(db.String(20), nullable=False,
default=STATUS_PENDING, index=True)
# Who queued it (userid) for audit; the agent never sees this.
requestedby = db.Column(db.Integer, 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)
resultjson = db.Column(db.Text(length=16777215), nullable=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
claimedat = db.Column(db.DateTime, nullable=True)
completedat = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.Index('idx_endpointcommands_assetstatus', 'assetid', 'status'),
)
def __repr__(self):
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
def to_dict(self):
return {
'endpointcommandid': self.endpointcommandid,
'assetid': self.assetid,
'action': self.action,
'target': self.target,
'targetkind': self.targetkind,
'status': self.status,
'requestedby': self.requestedby,
'resultmessage': self.resultmessage,
'createdat': _utciso(self.createdat),
'claimedat': _utciso(self.claimedat),
'completedat': _utciso(self.completedat),
}
class EndpointEvent(db.Model):
"""A single curated Windows Event Log record forwarded by the agent."""

View File

@@ -26,7 +26,7 @@ from shopdb.api import db
from .api import endpoints_bp
from .eventcatalog import EVENT_CATALOG
from .models import EndpointState, EndpointEvent
from .models import EndpointState, EndpointEvent, EndpointCommand
logger = logging.getLogger(__name__)
@@ -91,7 +91,7 @@ class EndpointsPlugin(BasePlugin):
return endpoints_bp
def get_models(self) -> List[Type]:
return [EndpointState, EndpointEvent]
return [EndpointState, EndpointEvent, EndpointCommand]
def get_permissions(self) -> List:
"""RBAC for the human-facing read routes.
@@ -103,6 +103,70 @@ class EndpointsPlugin(BasePlugin):
"""
return [
('endpoints.view', 'View endpoint telemetry', 'endpoints'),
# Issue an allowlisted command to an endpoint agent (for example the
# MTConnect toggle). Separate from endpoints.view because acting on a
# shop-floor PC is a heavier grant than reading its telemetry.
('endpoints.command',
'Send allowlisted commands to endpoint agents', '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
# UI (see get_settings_cards). Admins hold it via the catalog.
('collector.ingest.endpoints',
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
]
def get_settings_defaults(self) -> List[Dict]:
"""Settings owned by the endpoints agent-key kill switch.
`envkeyfallback` gates the bootstrap .env collector key: while true the
old COLLECTOR_API_KEY[_ENDPOINTS] still authorizes (back-compat, and the
only path before a UI key exists). The regenerate flow flips it false so
the .env key stops working and the managed token is the sole credential,
making revocation an instant kill switch. `agentkeyprovisioned` records
that a UI-managed key has been minted so the settings page can render
the rotate/revoke state instead of the first-run generate state.
"""
return [
{
'key': 'plugin.endpoints.envkeyfallback',
'value': 'true',
'valuetype': 'boolean',
'category': 'plugin.endpoints',
'description': 'Accept the bootstrap .env collector key for '
'endpoints. Turned off once a UI-managed agent '
'key is provisioned so revocation is a real kill '
'switch.',
},
{
'key': 'plugin.endpoints.agentkeyprovisioned',
'value': 'false',
'valuetype': 'boolean',
'category': 'plugin.endpoints',
'description': 'True once an operator has minted a UI-managed '
'endpoint agent key from the settings page.',
},
]
def get_settings_cards(self) -> List[Dict]:
"""The endpoints management card on the global settings page.
A settings card rather than a docs page because it is the operator's
control surface: manage the agent key (the kill switch), build the site
agent, and configure what it reports. Admin only - key management mints
a collector service credential.
"""
return [
{
'group': 'Endpoints',
'to': '/settings/endpoints/agentkey',
'icon': 'key',
'title': 'Endpoints',
'description': 'Manage the endpoint agent key, build the site '
'agent, and configure what it reports.',
'position': 27,
},
]
def get_navigation_items(self) -> List[Dict]:
@@ -150,10 +214,22 @@ class EndpointsPlugin(BasePlugin):
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/services',
'render': 'list',
'collapsible': True,
'status': {
'key': 'statecolor',
'levels': {
'green': 'ok',
'yellow': 'warning',
'red': 'critical',
},
},
'map': {
'title': 'name',
'badge': {'label': 'statelabel', 'color': 'statecolor'},
'action': 'action',
},
'commandendpoint': '/api/endpoints/{assetid}/commands',
'permission': 'endpoints.command',
'empty': 'No monitored services reported.',
'position': 12,
},
@@ -163,6 +239,15 @@ class EndpointsPlugin(BasePlugin):
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
'render': 'list',
'collapsible': True,
'status': {
'key': 'severity',
'levels': {
'info': 'ok',
'warning': 'warning',
'critical': 'critical',
},
},
'map': {
'title': 'title',
'badge': {
@@ -180,6 +265,25 @@ class EndpointsPlugin(BasePlugin):
'empty': 'No recent events.',
'position': 14,
},
{
'id': 'endpoints-interfaces',
'title': 'Network interfaces',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/interfaces',
'render': 'table',
'collapsible': True,
'columns': [
{'key': 'alias', 'label': 'Adapter'},
{'key': 'ipv4', 'label': 'IPv4'},
{'key': 'mac', 'label': 'MAC'},
{'key': 'gateway', 'label': 'Gateway'},
{'key': 'subnet', 'label': 'Subnet'},
{'key': 'dhcp', 'label': 'Assignment'},
{'key': 'role', 'label': 'Role'},
],
'empty': 'No network interfaces reported.',
'position': 16,
},
]
def get_dashboard_widgets(self) -> List[Dict]:
@@ -240,6 +344,14 @@ class EndpointsPlugin(BasePlugin):
'description': 'Endpoint hostname. Resolved against '
'the computers plugin (computers.hostname).',
},
'adopt': {
'type': 'boolean',
'description': 'First-contact only. When true and the '
'hostname does not resolve, adoption is '
'delegated to the computers collector '
'(see DESIGN-DECISIONS-agent.md 1a). This '
'plugin never creates assets itself.',
},
'loggedinuser': {'type': 'string'},
'agentversion': {'type': 'string'},
'lastboottime': {'type': 'string', 'format': 'date-time'},
@@ -286,6 +398,56 @@ class EndpointsPlugin(BasePlugin):
},
}
# PC-identifying and hardware fields forwarded to the computers collector,
# which owns the Asset/Computer row and renders the Hardware and Network
# cards. The endpoints plugin only caches telemetry, so these must be
# relayed or vendor/model/OS/IP never land on the PC (see
# DESIGN-DECISIONS-agent.md 1a).
_COMPUTER_HANDOFF_FIELDS = (
'loggedinuser', 'serialnumber', 'pctype', 'machinenumber',
'vendorname', 'modelnumber', 'osname', 'ipaddress', 'lastboottime',
)
def _forward_to_computers(self, hostname: str, payload: Dict):
"""Return the computers plugin and the PC subset to hand off, or None.
Both adoption and steady-state reports relay the same hardware subset,
so the Hardware and Network cards fill in on every report, not just at
first contact.
"""
from flask import current_app
manager = current_app.extensions.get('plugin_manager')
computers = manager.get_plugin('computers') if manager else None
if computers is None:
return None, None
subset = {'hostname': hostname}
for field in self._COMPUTER_HANDOFF_FIELDS:
if payload.get(field):
subset[field] = payload.get(field)
return computers, subset
def _adopt_computer(self, hostname: str, payload: Dict,
warnings: List[str]) -> None:
"""Delegate first-contact adoption to the computers collector.
This plugin never creates Asset or Computer rows itself (see
DESIGN-DECISIONS-agent.md 1a). The computers plugin is the sole
authority for that, so on an unknown hostname we forward the
PC-identifying subset of the payload to its collector. It runs inside
the same transaction, so a later failure rolls the adoption back.
"""
computers, adoptpayload = self._forward_to_computers(hostname, payload)
if computers is None:
raise ValueError(
'cannot adopt {!r}: computers plugin is not available'
.format(hostname))
computers.apply_collector_payload(adoptpayload)
db.session.flush()
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotently upsert one endpoint's state and append its events.
@@ -304,9 +466,30 @@ class EndpointsPlugin(BasePlugin):
computer = (Computer.query
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
if computer is None:
# First-contact adoption is delegated to the computers collector,
# which is the only authority allowed to create Asset + Computer
# rows (DESIGN-DECISIONS-agent.md 1a). This plugin never invents
# assets itself. Adoption runs in the dispatcher's transaction, so a
# later ValueError rolls the whole report back.
if payload.get('adopt'):
self._adopt_computer(hostname, payload, warnings)
computer = (Computer.query
.filter(db.func.lower(Computer.hostname)
== hostname.lower())
.first())
if computer is None:
raise ValueError(
'could not resolve hostname to a computer: {!r}'.format(hostname))
else:
# Already adopted: relay the hardware/identity subset on every
# report so the computers plugin keeps vendor/model/OS/IP current.
# Without this, Hardware and Network stay blank after adoption
# because this plugin only caches telemetry.
computers, subset = self._forward_to_computers(hostname, payload)
if computers is not None and len(subset) > 1:
computers.apply_collector_payload(subset)
db.session.flush()
assetid = computer.assetid

View File

@@ -446,20 +446,44 @@ const existingRelationshipId = ref(null)
const loadedControllingPcId = ref(null)
const currentMachine = ref(null)
// Filter models by selected machine vendor. Controller-type models are offered
// only in the controller dropdown, so keep them out of the machine list.
// Ensure a saved model id is present in models.value even when the filtered
// list (category Equipment) excluded it because it has no modeltype. Fetches
// the single row by id and appends it; a no-op when already present or blank.
async function ensureModelLoaded(modelId) {
if (!modelId) return
if (models.value.some(m => m.modelnumberid === modelId)) return
try {
const response = await modelsApi.get(modelId)
const model = response.data.data
if (model) models.value = [...models.value, model]
} catch (e) {
// Leave it absent; the select shows blank but we did not invent a value.
}
}
// Filter models by selected machine vendor.
// only in the controller dropdown, so keep them out of the machine list. Keep
// the record's own saved model even if it is untyped or a different vendor, so
// editing a machine never renders a blank select that clears the field on save.
const filteredModels = computed(() => {
const machineModels = models.value.filter(m => m.modeltype !== 'Controller')
const savedId = form.value.modelnumberid
const machineModels = models.value.filter(
m => m.modeltype !== 'Controller' || m.modelnumberid === savedId)
if (!form.value.vendorid) return machineModels
return machineModels.filter(m => m.vendorid === form.value.vendorid)
return machineModels.filter(
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
})
// Filter models by selected controller vendor. Only Controller-type models are
// valid here.
// valid here. Keep the record's own saved controller model even if it is untyped
// or a different vendor, for the same reason as filteredModels above.
const filteredControllerModels = computed(() => {
const controllerModels = models.value.filter(m => m.modeltype === 'Controller')
const savedId = form.value.controllermodelid
const controllerModels = models.value.filter(
m => m.modeltype === 'Controller' || m.modelnumberid === savedId)
if (!form.value.controllervendorid) return controllerModels
return controllerModels.filter(m => m.vendorid === form.value.controllervendorid)
return controllerModels.filter(
m => m.vendorid === form.value.controllervendorid || m.modelnumberid === savedId)
})
// Only offer vendors that have machine (non-controller Equipment) models. Keep
@@ -595,6 +619,14 @@ onMounted(async () => {
levelid: data.levelid ?? null,
}
// The model list is loaded filtered to category Equipment, which the
// backend inner-joins against ModelType - so a model with no modeltype is
// absent from models.value. If this machine's saved model or controller
// model is one of those untyped rows, fetch it by id and merge it in, or
// its dropdown would render blank and saving would clear the field.
await ensureModelLoaded(form.value.modelnumberid)
await ensureModelLoaded(form.value.controllermodelid)
// Load existing relationships to find controlling PC
if (data.assetid) {
try {

View File

@@ -370,9 +370,28 @@ const vendors = ref([])
const models = ref([])
const locations = ref([])
// Ensure a saved model id is present in models.value even when the filtered
// list (category Printer) excluded it because it has no modeltype. Fetches the
// single row by id and appends it; a no-op when already present or blank.
async function ensureModelLoaded(modelId) {
if (!modelId) return
if (models.value.some(m => m.modelnumberid === modelId)) return
try {
const response = await modelsApi.get(modelId)
const model = response.data.data
if (model) models.value = [...models.value, model]
} catch (e) {
// Leave it absent; the select shows blank but we did not invent a value.
}
}
// Filter models by selected vendor and printer type
const filteredModels = computed(() => {
const savedId = form.value.modelnumberid
return models.value.filter(m => {
// Always keep the record's own saved model, so editing a printer never
// renders a blank select that clears the field on save.
if (m.modelnumberid === savedId) return true
// Filter by vendor if selected
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
@@ -550,6 +569,12 @@ onMounted(async () => {
pin: ext.pin || ''
}
// The model list is loaded filtered to category Printer, which the backend
// inner-joins against ModelType - so an untyped model is absent. If this
// printer's saved model is one of those rows, fetch it by id and merge it
// in, or its dropdown would render blank and saving would clear the field.
await ensureModelLoaded(form.value.modelnumberid)
// Don't auto-generate for existing printers
manualWindowsName.value = true
manualHostname.value = true

View File

@@ -0,0 +1,102 @@
"""Seed the org's shared shopfloor applications (DEV ONLY).
Inserts the applications that ship as common across the organization so the
Configuration Policy builder has real apps to scope against and the software
inventory has canonical rows to match collected installs to. Each app records
its executable and (where relevant) its Windows service name in
applicationnotes, since the Application model has no dedicated process/service
column - these identifiers are what a generic service_*/process_* policy action
targets.
Shipped set:
* UDC - UDC.exe
* MTConnect - agent.exe process, "MTConnect UDC Agent" service
* eDNC - DncMain.exe process
Idempotent: matches on appname and updates in place, so re-running does not
duplicate. Marked installable so they appear in installable-app pickers.
DEV ONLY: lives under scripts/dev/ and must never be imported by application
code. Run inside the API container.
Usage (in the API container):
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py --clear
"""
import argparse
import os
import sys
# Repo root on sys.path so `shopdb` imports when run as a script.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
from shopdb import create_app # noqa: E402
from shopdb.api import db # noqa: E402
# appname -> seed fields. 'notes' captures the process image and service name so
# a generic policy action (params.target) has a canonical identifier to bind to.
SHARED_APPS = [
{
'appname': 'UDC',
'appdescription': 'Universal Data Collector',
'notes': 'process=UDC.exe',
},
{
'appname': 'MTConnect',
'appdescription': 'MTConnect UDC agent',
'notes': 'process=agent.exe; service=MTConnect UDC Agent',
},
{
'appname': 'eDNC',
'appdescription': 'eDNC distributed NC file transfer',
'notes': 'process=DncMain.exe',
},
]
def _seed(Application):
created = 0
updated = 0
for spec in SHARED_APPS:
app_row = Application.query.filter_by(appname=spec['appname']).first()
if app_row is None:
app_row = Application(appname=spec['appname'])
db.session.add(app_row)
created += 1
else:
updated += 1
app_row.appdescription = spec['appdescription']
app_row.applicationnotes = spec['notes']
app_row.isinstallable = True
db.session.commit()
print('Seeded shared applications: {} created, {} updated.'
.format(created, updated))
def _clear(Application):
names = [spec['appname'] for spec in SHARED_APPS]
removed = (Application.query
.filter(Application.appname.in_(names))
.delete(synchronize_session=False))
db.session.commit()
print('Removed {} shared application row(s).'.format(removed))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--clear', action='store_true',
help='Remove the seeded shared applications.')
args = parser.parse_args()
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
from shopdb.api import Application
if args.clear:
_clear(Application)
else:
_seed(Application)
if __name__ == '__main__':
main()

View File

@@ -65,7 +65,18 @@ from .plugins import plugin_manager
# 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.
__contract_version__ = '0.21.0'
# 0.22.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.23.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.
__contract_version__ = '0.23.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -75,8 +75,28 @@ from shopdb.utils.authz import require_permission, require_role
# internals.
from shopdb.utils.apitoken_auth import (
service_token_authorized, authorized_service_token,
mint_service_token, list_service_tokens, revoke_service_tokens,
)
# Collector-token authorization for agent-facing plugin routes that live outside
# the generic collector blueprint (e.g. the endpoints command claim/result
# callbacks). Reuses the same per-plugin collector key + managed-token check the
# ingest route uses, so a plugin never imports core collector internals.
def collector_authorized(pluginname: str):
"""Authorize an agent-facing route on a plugin's collector token.
Returns None when the caller presented a valid collector credential for
this plugin (the per-plugin env key or a collector.ingest[.<plugin>]-scoped
managed token), else the error_response to return. Lets a plugin gate an
agent callback on the same key and kill switch as its ingest route without
reaching into core collector internals.
Imported lazily: shopdb.core.api imports back into shopdb.api (send_upload),
so a module-level import here would be a circular import at load time.
"""
from shopdb.core.api.collector import _check_collector_auth, _plugin_api_key
return _check_collector_auth(_plugin_api_key(pluginname), pluginname)
# Serving an uploaded file safely. A plugin that serves user-supplied bytes must
# not have to remember the headers that keep an SVG or an HTML file from running
# as script in this origin - see shopdb/utils/uploads.py.
@@ -313,6 +333,10 @@ __all__ = [
'require_role',
'service_token_authorized',
'authorized_service_token',
'mint_service_token',
'list_service_tokens',
'revoke_service_tokens',
'collector_authorized',
'SupportTeam',
# Serving uploads
'send_upload',

View File

@@ -39,17 +39,56 @@ def _computer_models():
return None
def _token_is_collector(token):
"""True when this managed token is a collector service token: scoped for
collector.ingest AND its owner actually holds that permission (admins do)."""
def _collector_scopes_for(pluginname=None):
"""The scope names that authorize a collector request.
The generic scope `collector.ingest` authorizes every collector. A plugin
MAY also mint a token scoped to `collector.ingest.<pluginname>` so that
rotating/revoking it stops only that plugin's collector traffic (endpoints
uses this for its regenerable agent kill switch). Both are accepted.
"""
scopes = {COLLECTOR_SCOPE}
if pluginname:
scopes.add(f'{COLLECTOR_SCOPE}.{pluginname}')
return scopes
def _env_fallback_disabled(pluginname=None):
"""True when a plugin has switched OFF the bootstrap env-key fallback.
Once an operator provisions a UI-managed collector token, the plugin sets
its `plugin.<pluginname>.envkeyfallback` setting to false so the old .env
key stops authorizing. That turns key revocation into a real kill switch:
rotating or revoking the managed token cannot be bypassed by a lingering env
key. Absent
setting = fallback stays ON (back-compat, so nothing breaks on upgrade).
"""
if not pluginname:
return False
from shopdb.core.models.setting import Setting
key = f'plugin.{pluginname}.envkeyfallback'
return Setting.get(key, default=True) is False
def _token_is_collector(token, pluginname=None):
"""True when this managed token is a collector service token: scoped for a
collector scope AND its owner actually holds that permission (admins do).
Accepts the generic `collector.ingest` or, when pluginname is given, the
plugin-specific `collector.ingest.<pluginname>` scope.
"""
from shopdb.core.models import User
scopelist = token.scopelist
if not scopelist or COLLECTOR_SCOPE not in scopelist:
if not scopelist:
return False
allowed = _collector_scopes_for(pluginname)
held = [s for s in scopelist if s in allowed]
if not held:
return False
user = db.session.get(User, token.userid)
return (user is not None and user.isactive
and user.haspermission(COLLECTOR_SCOPE))
and any(user.haspermission(s) for s in held))
def _collector_managed_token(api_key):
@@ -73,24 +112,28 @@ def _collector_managed_token(api_key):
return None
def _check_collector_auth(expected_key):
def _check_collector_auth(expected_key, pluginname=None):
"""Authorize a collector request. Returns None when authorized, else the
error_response to return.
Accepts a collector-scoped managed token (Bearer or X-API-Key) OR the env
key via X-API-Key (legacy/bootstrap fallback). Env keys stay supported so
nothing breaks; a valid managed token works even with no env key set.
When pluginname is given, a token scoped to collector.ingest.<pluginname>
also authorizes (in addition to the generic collector.ingest scope).
"""
from shopdb.utils.apitoken_auth import touch_apitoken_lastused
api_key = request.headers.get('X-API-Key')
token = _collector_managed_token(api_key)
if token is not None and _token_is_collector(token):
if token is not None and _token_is_collector(token, pluginname):
touch_apitoken_lastused(token)
return None
if expected_key and api_key == expected_key:
if expected_key and api_key == expected_key \
and not _env_fallback_disabled(pluginname):
return None
# Fail-closed only on pure server misconfiguration: no env key AND the
@@ -200,7 +243,7 @@ def generic_collect(pluginname):
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header
# only (querystring fallback dropped, see require_api_key).
error = _check_collector_auth(_plugin_api_key(pluginname))
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
if error is not None:
return error

View File

@@ -879,10 +879,10 @@ def build_default_settings():
},
{
'key': 'asset_import_reconcile_threshold',
'value': '0.85',
'value': '0',
'valuetype': 'string',
'category': 'site',
'description': 'Similarity (0 to 1) a fuzzy lookup match must reach for the asset importer to reconcile a near-miss name (e.g. "Dell Inc." to an existing "Dell") onto an existing vendor, model, type, status, business unit, or location. Reconciled matches are applied but reported for review. 0 disables reconciliation. Operating systems and asset rows are never reconciled.'
'description': 'Similarity (0 to 1) a fuzzy lookup match must reach for the asset importer to reconcile a near-miss name (e.g. "Dell Inc." to an existing "Dell") onto an existing vendor, model, type, status, business unit, or location. Reconciled matches are applied but reported for review. 0 (the default) disables fuzzy reconciliation - names must match exactly after normalization. Labels whose digit runs differ (e.g. "Bay 310" vs "Bay 311") are never fuzzy-matched. Operating systems and asset rows are never reconciled.'
},
]

View File

@@ -172,8 +172,11 @@ CORPORATE_SUFFIXES = {
}
# Default similarity a fuzzy match must reach, 0..1. Also the value the settings
# default is seeded with; 0 disables reconciliation entirely.
DEFAULT_RECONCILE_THRESHOLD = 0.85
# default is seeded with; 0 disables reconciliation entirely. Fuzzy matching is
# opt-in: it ships OFF (0) because a high similarity score is precisely the
# dangerous case - 'Bay 310' and 'Bay 311' score 0.857 and would silently merge.
# A site turns it on deliberately once it understands its own vocabulary.
DEFAULT_RECONCILE_THRESHOLD = 0.0
# -- plugin import hooks (inversion of control) ---------------------------------
@@ -259,6 +262,19 @@ def normalize_label(value):
return ' '.join(words)
def digit_runs(value):
"""Return the ordered list of digit runs in a normalized label.
'bay 310' -> ['310'], 'precision 3660' -> ['3660'], 'dell' -> []. Two labels
that share a fuzzy word shape but differ in any digit run ('bay 310' vs
'bay 311') must NOT reconcile: a single differing digit is a small fraction
of a long string, so SequenceMatcher scores it above threshold even though
the labels name two distinct things. Comparing digit runs exactly closes
that gap while still fuzzing the surrounding words.
"""
return re.findall(r'\d+', value or '')
def table_for(name):
table = db.metadata.tables.get(name)
if table is None:
@@ -451,6 +467,7 @@ class Resolver:
wanted = normalize_label(value)
if not wanted:
return None
wanted_digits = digit_runs(wanted)
best_id = None
best_label = None
@@ -463,6 +480,12 @@ class Resolver:
if normalized == wanted:
score = 1.0
else:
# Never fuzzy-match two labels whose digit runs differ. 'Bay 310'
# and 'Bay 311' share every word and score 0.857, but the digits
# name distinct things; refuse rather than silently merge. The
# normalized-exact tier above (score 1.0) is unaffected.
if digit_runs(normalized) != wanted_digits:
continue
score = difflib.SequenceMatcher(None, wanted, normalized).ratio()
if score > best_score:
best_score = score

View File

@@ -49,9 +49,11 @@ logger = logging.getLogger('alembic.env.plugin')
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'backups': ('backuprevisions',),
'computers': ('computertypes', 'computers', 'computerinstalledapps',
'accessprotocols', 'computeraccess'),
'accessprotocols', 'computeraccess',
'configurationpolicies', 'configurationpolicyactions',
'businesspolicies', 'businesspolicyitems'),
'employees': ('directoryemployees',),
'endpoints': ('endpointstates', 'endpointevents'),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands'),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses',

View File

@@ -107,6 +107,82 @@ def authorized_service_token(scope):
return token
def mint_service_token(scope, name, owner_userid, revoke_existing=True):
"""Mint a managed service token scoped to exactly ONE permission.
The contract-surface way for a plugin to provision an unattended service
credential (an endpoint agent key, a collector key) without importing core
token internals. The returned secret is the ONLY time the full secret is
available - callers reveal it once and store only the prefix afterwards.
scope the single permission the token authorizes (e.g.
collector.ingest.endpoints). The owner must hold it.
name human label shown in the token list UI.
owner_userid the acting admin who owns (and can be audited for) the key.
revoke_existing when True (default), every currently-active token scoped to
EXACTLY this one permission is deactivated first, so
regenerating is a hard cutover: the old agent key stops
working the instant the new one is minted.
Returns (token, secret). Commits.
"""
from shopdb.core.models import User
owner = db.session.get(User, owner_userid)
if owner is None or not owner.isactive or not owner.haspermission(scope):
raise PermissionError(
'Token owner must be an active user holding ' + scope)
if revoke_existing:
for existing in list_service_tokens(scope):
existing.isactive = False
secret = ApiToken.generate_secret()
token = ApiToken(
userid=owner_userid,
name=name,
tokenprefix=ApiToken.prefix_of(secret),
tokenhash=ApiToken.hash_secret(secret),
)
token.scopelist = [scope]
db.session.add(token)
db.session.commit()
return token, secret
def list_service_tokens(scope, active_only=True):
"""Return managed tokens scoped to EXACTLY the single permission `scope`.
Used by a plugin settings page to render the current agent-key state
(prefix, created, last-used) without exposing token internals. Filters in
Python because scopes are stored as a JSON blob, not a queryable column.
"""
query = ApiToken.query
if active_only:
query = query.filter_by(isactive=True)
matches = []
for token in query.order_by(ApiToken.createddate.desc()).all():
scopelist = token.scopelist
if scopelist and len(scopelist) == 1 and scopelist[0] == scope:
matches.append(token)
return matches
def revoke_service_tokens(scope):
"""Deactivate every active managed token scoped to EXACTLY `scope`.
The kill switch: revoking the endpoint agent key stops all endpoint-agent
traffic authorized by a managed token. Returns the count revoked. Commits.
"""
revoked = 0
for token in list_service_tokens(scope):
token.isactive = False
revoked += 1
if revoked:
db.session.commit()
return revoked
def service_token_authorized(scope):
"""True when the current request carries a managed token scoped for `scope`
whose owner is active and holds that permission. Accepts X-API-Key or a

View File

@@ -62,7 +62,15 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# GET urls and a preview must not consume a one-view link.
# CREATING one is permission-gated and IS swept.
# Justification in plugins/tools/README.md.
'tools.redeem_secret'}
'tools.redeem_secret',
# Agent-facing command result callback. Authenticated by the
# endpoints collector API key (same shape as the exempt
# collector blueprint), not JWT+role: the endpoint agent has
# no user account, the collector key is its credential and
# kill switch. Enqueue (WebUI -> operator) IS permission-gated
# on endpoints.command and IS swept. Contract in
# plugins/endpoints/AGENT-CONTRACT.md section 7.
'endpoints.report_command_result'}
@pytest.fixture(autouse=True)

View File

@@ -12,6 +12,7 @@ from shopdb.core.services.csvimport import (
IMPORTABLE, ImportError_, Resolver, generate_template, import_csv,
importable_columns, required_columns,
)
from shopdb.core.services.csvimport import digit_runs, normalize_label
from shopdb.extensions import db as _db
@@ -280,3 +281,57 @@ def test_template_example_set_imports_cleanly(db):
table, [str(p) for p in result.problems])
_db.session.rollback()
# -- fuzzy reconciliation safety (review item 1.1) -----------------------------
#
# A high similarity score is the DANGEROUS case: 'Bay 310' and 'Bay 311' share
# every word and score ~0.857, so a naive threshold silently merges two distinct
# locations. Reconciliation now ships OFF (threshold 0) and, even when a site
# turns it on, refuses any pair whose digit runs differ.
def test_digit_runs_extracts_number_groups():
assert digit_runs(normalize_label('Bay 310')) == ['310']
assert digit_runs(normalize_label('Precision 3660')) == ['3660']
assert digit_runs(normalize_label('Dell Inc.')) == []
def test_reconciliation_is_off_by_default(db):
"""A fresh Resolver does not fuzzy-match: an unknown numbered bay is a row
error, not a silent bind onto its neighbour."""
resolver = Resolver()
seed_reference(resolver)
# 'Bay 3' exists from the reference seed; 'Bay 8' does not.
result = import_csv(
'assets',
'assetnumber,assettypeid,locationid\nA-1,Machine,Bay 8\n',
resolver=resolver)
assert not result.ok
assert not resolver.events
def test_reconcile_refuses_when_digit_runs_differ(db):
"""Even with fuzzy matching turned on, 'Bay 311' must not bind to the
existing 'Bay 3': the digit runs differ, so it is a distinct location."""
resolver = Resolver(reconcile_threshold=0.5)
seed_reference(resolver)
result = import_csv(
'assets',
'assetnumber,assettypeid,locationid\nB-1,Machine,Bay 311\n',
resolver=resolver)
assert not result.ok
assert not resolver.events
def test_reconcile_still_binds_normalized_exact(db):
"""The normalized-exact tier is untouched: 'bay 3' folds to the existing
'Bay 3' regardless of threshold, and is reported as an event."""
resolver = Resolver(reconcile_threshold=0.9)
seed_reference(resolver)
result = import_csv(
'assets',
'assetnumber,assettypeid,locationid\nC-1,Machine,BAY 3\n',
resolver=resolver)
assert result.ok, [str(p) for p in result.problems]
_db.session.rollback()

View File

@@ -47,9 +47,9 @@ CUTOVER_PLUGINS = (
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# computers adds the Entra/Azure device identity columns (azuredeviceid, then
# azureobjectid + azuredevicecategory) on top of its anchor, so its head is not
# the f-string default.
EXPECTED_HEAD_REVISION['computers'] = 'computers0003azurefields'
# azureobjectid + azuredevicecategory), then the Configuration/Business policy
# tables, on top of its anchor, so its head is not the f-string default.
EXPECTED_HEAD_REVISION['computers'] = 'computers0004policies'
# backups is also post-cutover: its 0001 really creates backuprevisions, and
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
# otherwise throws away.
@@ -88,8 +88,10 @@ EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
# warranty adds the proof-of-cover document columns on top of its anchor.
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
# endpointstates + endpointevents tables the core chain never owned.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0001baseline'
# endpointstates + endpointevents tables the core chain never owned, 0002
# adds the endpointcommands queue plus the MTConnect state columns, and 0003
# adds the generic command target/targetkind columns.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0003cmdtarget'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.