diff --git a/.env.example b/.env.example index a2689518..68e9ebbe 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,7 @@ ZABBIX_TOKEN= # COLLECTOR_API_KEY_ 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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fefd16a4..77f2f109 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5f591efe..b8d54715 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e13ef02e..31daa2b3 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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}`) + } + } } } diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 8362bc9f..4a4d1725 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -4,7 +4,30 @@ on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
{{ panel.empty || 'Nothing to show.' }} @@ -90,6 +123,7 @@ {{ b.label }}
+ @@ -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); } diff --git a/frontend/src/components/pluginAssetPanels.js b/frontend/src/components/pluginAssetPanels.js index 7aebfa4b..c19144c3 100644 --- a/frontend/src/components/pluginAssetPanels.js +++ b/frontend/src/components/pluginAssetPanels.js @@ -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: { : '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 diff --git a/frontend/src/components/pluginAssetPanels.spec.js b/frontend/src/components/pluginAssetPanels.spec.js index c48b00be..3f4e89b8 100644 --- a/frontend/src/components/pluginAssetPanels.spec.js +++ b/frontend/src/components/pluginAssetPanels.spec.js @@ -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') + }) +}) diff --git a/plugins/computers/api/__init__.py b/plugins/computers/api/__init__.py index 0bea25bc..5e14bb0f 100644 --- a/plugins/computers/api/__init__.py +++ b/plugins/computers/api/__init__.py @@ -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'] diff --git a/plugins/computers/api/policy_routes.py b/plugins/computers/api/policy_routes.py new file mode 100644 index 00000000..c62f2d8d --- /dev/null +++ b/plugins/computers/api/policy_routes.py @@ -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/', 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/', 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/', 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/', 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/', 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/', 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/', 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()) diff --git a/plugins/computers/frontend/routes.js b/plugins/computers/frontend/routes.js index 828d9122..3d7d0895 100644 --- a/plugins/computers/frontend/routes.js +++ b/plugins/computers/frontend/routes.js @@ -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' } } ] diff --git a/plugins/computers/frontend/views/BusinessPoliciesList.vue b/plugins/computers/frontend/views/BusinessPoliciesList.vue new file mode 100644 index 00000000..4a6b20ae --- /dev/null +++ b/plugins/computers/frontend/views/BusinessPoliciesList.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/plugins/computers/frontend/views/ConfigurationPoliciesList.vue b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue new file mode 100644 index 00000000..17a2fc8f --- /dev/null +++ b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue index ea7c8581..2b5e9b7f 100644 --- a/plugins/computers/frontend/views/PCForm.vue +++ b/plugins/computers/frontend/views/PCForm.vue @@ -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) diff --git a/plugins/computers/frontend/views/ScopeNodeEditor.vue b/plugins/computers/frontend/views/ScopeNodeEditor.vue new file mode 100644 index 00000000..439a43ed --- /dev/null +++ b/plugins/computers/frontend/views/ScopeNodeEditor.vue @@ -0,0 +1,106 @@ + + + + + + + diff --git a/plugins/computers/migrations/versions/0004_policies.py b/plugins/computers/migrations/versions/0004_policies.py new file mode 100644 index 00000000..d5c7f737 --- /dev/null +++ b/plugins/computers/migrations/versions/0004_policies.py @@ -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') diff --git a/plugins/computers/models/__init__.py b/plugins/computers/models/__init__.py index eee75210..d5813968 100644 --- a/plugins/computers/models/__init__.py +++ b/plugins/computers/models/__init__.py @@ -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', ] diff --git a/plugins/computers/models/policy.py b/plugins/computers/models/policy.py new file mode 100644 index 00000000..a95ea5a4 --- /dev/null +++ b/plugins/computers/models/policy.py @@ -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"" + + +# 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"" + + +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"" + + +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"") diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index b78866a9..b91e8cb8 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -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. diff --git a/plugins/computers/policyflag.py b/plugins/computers/policyflag.py new file mode 100644 index 00000000..98d132ad --- /dev/null +++ b/plugins/computers/policyflag.py @@ -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') diff --git a/plugins/computers/services/__init__.py b/plugins/computers/services/__init__.py new file mode 100644 index 00000000..c43ac045 --- /dev/null +++ b/plugins/computers/services/__init__.py @@ -0,0 +1 @@ +"""Computers plugin services: pure policy logic (scope + resolution).""" diff --git a/plugins/computers/services/policyresolver.py b/plugins/computers/services/policyresolver.py new file mode 100644 index 00000000..b71592b6 --- /dev/null +++ b/plugins/computers/services/policyresolver.py @@ -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) diff --git a/plugins/computers/services/policyscope.py b/plugins/computers/services/policyscope.py new file mode 100644 index 00000000..049c272d --- /dev/null +++ b/plugins/computers/services/policyscope.py @@ -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": [, ...]} + {"type": "or", "nodes": [, ...]} + {"type": "not", "node": } + {"type": "pctype", "id": } + {"type": "app", "id": } + {"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}') diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md new file mode 100644 index 00000000..6c6b2764 --- /dev/null +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -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: ` (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? 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=` + +- 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//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//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). + diff --git a/plugins/endpoints/DESIGN-DECISIONS-agent.md b/plugins/endpoints/DESIGN-DECISIONS-agent.md index 67e2da4b..ab50920b 100644 --- a/plugins/endpoints/DESIGN-DECISIONS-agent.md +++ b/plugins/endpoints/DESIGN-DECISIONS-agent.md @@ -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. diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 650ad040..14f84474 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -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//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//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('//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('//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//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') + diff --git a/plugins/endpoints/commands.py b/plugins/endpoints/commands.py new file mode 100644 index 00000000..a44569a2 --- /dev/null +++ b/plugins/endpoints/commands.py @@ -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 + diff --git a/plugins/endpoints/frontend/routes.js b/plugins/endpoints/frontend/routes.js index 1d51f0e2..3e07f25d 100644 --- a/plugins/endpoints/frontend/routes.js +++ b/plugins/endpoints/frontend/routes.js @@ -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' } } ] diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue new file mode 100644 index 00000000..8e67d7e5 --- /dev/null +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/plugins/endpoints/migrations/versions/0002_endpoint_commands.py b/plugins/endpoints/migrations/versions/0002_endpoint_commands.py new file mode 100644 index 00000000..d157e4cc --- /dev/null +++ b/plugins/endpoints/migrations/versions/0002_endpoint_commands.py @@ -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') diff --git a/plugins/endpoints/migrations/versions/0003_endpoint_command_target.py b/plugins/endpoints/migrations/versions/0003_endpoint_command_target.py new file mode 100644 index 00000000..60b86257 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0003_endpoint_command_target.py @@ -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') diff --git a/plugins/endpoints/models/__init__.py b/plugins/endpoints/models/__init__.py index f641e95b..1e1fabaa 100644 --- a/plugins/endpoints/models/__init__.py +++ b/plugins/endpoints/models/__init__.py @@ -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'] diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index aa0bead7..0b0e4593 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -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"" + + 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.""" diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 35d71cc8..263aa4e0 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -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. @@ -305,8 +467,29 @@ class EndpointsPlugin(BasePlugin): .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)) + # 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 @@ -373,4 +556,4 @@ class EndpointsPlugin(BasePlugin): 'identityvalue': hostname, 'storedevents': stored_events, 'warnings': warnings, - } + } \ No newline at end of file diff --git a/plugins/machines/frontend/views/MachineForm.vue b/plugins/machines/frontend/views/MachineForm.vue index 646a5795..15e603e8 100644 --- a/plugins/machines/frontend/views/MachineForm.vue +++ b/plugins/machines/frontend/views/MachineForm.vue @@ -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 { diff --git a/plugins/printers/frontend/views/PrinterForm.vue b/plugins/printers/frontend/views/PrinterForm.vue index caa8084a..65670487 100644 --- a/plugins/printers/frontend/views/PrinterForm.vue +++ b/plugins/printers/frontend/views/PrinterForm.vue @@ -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 diff --git a/scripts/dev/seed_shared_applications.py b/scripts/dev/seed_shared_applications.py new file mode 100644 index 00000000..2798db84 --- /dev/null +++ b/scripts/dev/seed_shared_applications.py @@ -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() diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 3047076c..18f8f943 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -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 diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index f802e387..4c0af379 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -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[.]-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', diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py index 1b0bd654..6d6093b3 100644 --- a/shopdb/core/api/collector.py +++ b/shopdb/core/api/collector.py @@ -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.` 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..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.` 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. + 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 diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index a0098079..84d51c42 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -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.' }, ] diff --git a/shopdb/core/services/csvimport.py b/shopdb/core/services/csvimport.py index 0adeb3a2..c5ebe904 100644 --- a/shopdb/core/services/csvimport.py +++ b/shopdb/core/services/csvimport.py @@ -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 diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 43854d77..657297e9 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -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', diff --git a/shopdb/utils/apitoken_auth.py b/shopdb/utils/apitoken_auth.py index ceb6b630..e39e9e96 100644 --- a/shopdb/utils/apitoken_auth.py +++ b/shopdb/utils/apitoken_auth.py @@ -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 diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 476bb0f8..61e3d427 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -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) diff --git a/tests/test_csv_import.py b/tests/test_csv_import.py index ec1a1dfc..0cd4956d 100644 --- a/tests/test_csv_import.py +++ b/tests/test_csv_import.py @@ -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() + + diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index dfaa8220..28f01be4 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -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.