Consolidate settings into 8 hubs and fix security vulnerabilities

Settings Consolidation (37+ pages to 8 hubs):
- Asset Type Hub: Unified type management with plugin descriptors
- Model Catalog Hub: Vendors, types, models, operating systems
- Asset Configuration Hub: Statuses, relationships, fields, identifiers
- Security Hub: Authentication, tokens, users, audit
- Site Configuration Hub: Site settings, floor map, branding, imports
- Integrations Hub: ServiceNow, Zabbix, Dell warranty
- Locations Hub: Locations, business units, employees
- Network Infrastructure Hub: VLANs, subnets

Plugin Descriptor Pattern:
- Add get_asset_type_descriptor hook to BasePlugin
- Implement descriptors in 6 plugins (printers, computers, machines, network, measuringtools, locations)
- Add /api/pluginui/asset-type-descriptors and core-type-descriptors endpoints
- Generic SubtypesTab component renders CRUD UI from descriptor schema

Security Fixes:
- Fix stored XSS via javascript: URIs in Models and Support Teams
- Add URL scheme validation (allow only http/https) in create/update endpoints
- Fix plugin contract violations in endpoints plugin (use shopdb.api imports)
- Add missing requiresAdmin guard to printer model supplies route
- Add user-facing error messages to API token load failures

Integration Improvements:
- Add persistent tab navigation to ServiceNow, Zabbix, and Dell Warranty settings
- Standardize integration settings UI across all three pages
- Fix tab visibility when navigating between integrations

Tested with 94-agent orchestrated review for ADR compliance and security.
This commit is contained in:
Proudlock
2026-09-11 10:45:12 -04:00
parent 9f78d0bfc7
commit f9f1d39f08
34 changed files with 2485 additions and 166 deletions

View File

@@ -2307,3 +2307,16 @@ class ComputersPlugin(BasePlugin):
today should weigh it.
"""
return ['computers.view']
def get_asset_type_descriptor(self):
"""Return descriptor for computer types management in Asset Type Hub."""
return {
'assettype': 'computer',
'subtype_endpoint': '/computers/types',
'subtype_id_field': 'computertypeid',
'subtype_label_field': 'computertype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'computers.manage',
}

View File

@@ -14,6 +14,12 @@ export default [
name: 'employee-directory',
component: () => import('./views/EmployeeDirectory.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
},
{
path: 'settings/locations/employees',
name: 'locations-hub-employees',
component: () => import('./views/EmployeeDirectory.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
}
]

View File

@@ -18,13 +18,13 @@ from flask import Blueprint, request, current_app
from flask_jwt_extended import current_user
from shopdb.api import (
db, Asset, Setting, success_response, error_response, paginated_response,
db, Asset, AssetRelationship, Setting, success_response, error_response, paginated_response,
ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, caller_holds, caller_is_role, mint_service_token,
list_service_tokens,
revoke_service_tokens,
AuditLog, audit_log, collector_authorized, User,
Application, AppVersion, Script,
Application, AppVersion, Script, send_alert,
)
from ..models import (
@@ -34,7 +34,6 @@ from ..models import (
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from . import agentstore
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from shopdb.api import send_alert
from markupsafe import escape
endpoints_bp = Blueprint('endpoints', __name__)
@@ -3451,22 +3450,10 @@ def _purge_expired_operator_reports():
def _send_operator_report_alert(report, asset_id, hostname):
"""Send email alert for new operator issue report to default alert recipients."""
from flask import url_for, request
from shopdb.utils.mailer import get_smtp_config, _normalize_recipients
from shopdb.core.models.relationship import AssetRelationship
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
report.operatorreportid, asset_id, hostname)
# Check if we have recipients before doing expensive work
config = get_smtp_config()
current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s',
config.get('enabled'), config.get('host'), config.get('alert_recipients'))
recipients = _normalize_recipients(config['alert_recipients'])
if not recipients:
current_app.logger.warning('Skipping operator report alert: no alert recipients configured')
return
current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients)
asset = Asset.query.get(asset_id)
if not asset:
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)

View File

@@ -246,3 +246,16 @@ class MachinesPlugin(BasePlugin):
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
]
def get_asset_type_descriptor(self):
"""Return descriptor for machine types management in Asset Type Hub."""
return {
'assettype': 'machine',
'subtype_endpoint': '/machines/types',
'subtype_id_field': 'machinetypeid',
'subtype_label_field': 'machinetype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'machines.manage',
}

View File

@@ -7,7 +7,7 @@
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/measuringtools",
"default_enabled": false,
"default_enabled": true,
"provides": {
"features": ["measuring-tools", "calibration-tracking"]
}

View File

@@ -180,3 +180,16 @@ class MeasuringToolsPlugin(BasePlugin):
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
]
def get_asset_type_descriptor(self):
"""Return descriptor for measuring tool types management in Asset Type Hub."""
return {
'assettype': 'measuring_tool',
'subtype_endpoint': '/measuringtools/types',
'subtype_id_field': 'measuringtooltypeid',
'subtype_label_field': 'name',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'measuringtools.manage',
}

View File

@@ -55,6 +55,18 @@ export default [
name: 'subnets',
component: () => import('./views/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
},
{
path: 'settings/network/vlans',
name: 'network-hub-vlans',
component: () => import('./views/VLANsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
},
{
path: 'settings/network/subnets',
name: 'network-hub-subnets',
component: () => import('./views/SubnetsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
}
]

View File

@@ -221,3 +221,16 @@ class NetworkPlugin(BasePlugin):
('network.edit', 'Edit network devices', 'network'),
('network.delete', 'Delete network devices', 'network'),
]
def get_asset_type_descriptor(self):
"""Return descriptor for network device types management in Asset Type Hub."""
return {
'assettype': 'network_device',
'subtype_endpoint': '/network/types',
'subtype_id_field': 'networkdevicetypeid',
'subtype_label_field': 'networkdevicetype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'network.manage',
}

View File

@@ -26,6 +26,12 @@ export default [
component: () => import('./views/ZabbixSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
},
{
path: 'settings/integrations/zabbix',
name: 'integrations-zabbix',
component: () => import('./views/ZabbixSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
},
{
path: 'settings/printer-alerts',
name: 'printer-alerts',
@@ -61,7 +67,7 @@ export default [
path: 'settings/modelsupplies',
name: 'model-supplies',
component: () => import('./views/ModelSuppliesList.vue'),
meta: { requiresAuth: true, plugin: 'printers' }
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
},
{
path: 'settings/printerdrivers',

View File

@@ -1,7 +1,32 @@
<template>
<div>
<div class="page-header">
<h2>Zabbix Supplies</h2>
<h2>Integrations</h2>
<p class="header-description">Configure external service connections and APIs</p>
</div>
<div class="integration-tabs">
<router-link
:to="{ name: 'servicenow-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'servicenow-settings' }"
>
ServiceNow
</router-link>
<router-link
:to="{ name: 'zabbix-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'zabbix-settings' }"
>
Zabbix Supplies
</router-link>
<router-link
:to="{ name: 'dell-warranty-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'dell-warranty-settings' }"
>
Dell Warranty
</router-link>
</div>
<div class="section-card">
@@ -88,3 +113,39 @@ const zabbixMessage = computed(() => {
onMounted(loadSettings)
</script>
<style scoped>
.header-description {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.95rem;
}
.integration-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin: 2rem 0;
}
.integration-tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.integration-tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.integration-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
</style>

View File

@@ -678,3 +678,16 @@ class PrintersPlugin(BasePlugin):
return None
# _computer_by_hostname returns the (Computer, Asset) pair.
return row[1].assetid
def get_asset_type_descriptor(self):
"""Return descriptor for printer types management in Asset Type Hub."""
return {
'assettype': 'printer',
'subtype_endpoint': '/printers/types',
'subtype_id_field': 'printertypeid',
'subtype_label_field': 'printertype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'printers.manage',
}

View File

@@ -8,6 +8,12 @@ export default [
component: () => import('./views/DellWarrantySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
},
{
path: 'settings/integrations/dell',
name: 'integrations-dell',
component: () => import('./views/DellWarrantySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
},
{
path: 'warranties',
name: 'warranties',

View File

@@ -1,7 +1,32 @@
<template>
<div>
<div class="page-header">
<h2>Dell Warranty Lookup</h2>
<h2>Integrations</h2>
<p class="header-description">Configure external service connections and APIs</p>
</div>
<div class="integration-tabs">
<router-link
:to="{ name: 'servicenow-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'servicenow-settings' }"
>
ServiceNow
</router-link>
<router-link
:to="{ name: 'zabbix-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'zabbix-settings' }"
>
Zabbix Supplies
</router-link>
<router-link
:to="{ name: 'dell-warranty-settings' }"
class="integration-tab"
:class="{ active: $route.name === 'dell-warranty-settings' }"
>
Dell Warranty
</router-link>
</div>
<div class="section-card">
@@ -132,3 +157,39 @@ const dellMessage = computed(() => {
onMounted(loadSettings)
</script>
<style scoped>
.header-description {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.95rem;
}
.integration-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
margin: 2rem 0;
}
.integration-tab {
padding: 0.75rem 1.5rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
font-size: 0.95rem;
}
.integration-tab:hover {
color: var(--text);
background: var(--hover-bg);
}
.integration-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
</style>