Files
shopdb-flask/shopdb/core/api/pluginui.py
Proudlock f9f1d39f08 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.
2026-09-11 10:45:12 -04:00

235 lines
9.2 KiB
Python

"""Plugin frontend-contribution API endpoints (ADR-010).
Consumers for the four data-only presentation hooks on BasePlugin:
get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation.
Every endpoint copies the dashboard-widgets consumer semantics: skip disabled
plugins, tag each contributed dict with its originating plugin name, fail loud
in dev/test, isolate a broken plugin in prod. All routes are GET + jwt-optional,
matching the other read-only core endpoints; they expose data, not mutations.
"""
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
pluginui_bp = Blueprint('pluginui', __name__)
# The tabs an asset detail page offers, and so the only values a panel's
# 'group' may take. Kept here rather than in the frontend because the server
# resolves the group before the client sees it - see asset_panels below.
#
# 'details' is first and is the fallback: it holds what an asset IS, which is
# what a panel declaring nothing almost always contributes.
#
# This tuple is a THIRD copy of the tab vocabulary - RENDERED_GROUPS in
# PluginAssetPanels.vue and the tab list in PCDetail.vue are the other two.
# Adding a group means touching all three: a value missing here is rewritten to
# 'details' before the client ever sees it, so the panels quietly render in the
# wrong tab and the intended tab never appears. No error, no log line.
ASSET_PANEL_GROUPS = ('details', 'monitors', 'serviceprocess', 'reports',
'software', 'activity')
def _collect(hookname):
"""Aggregate a data-only hook across enabled plugins.
Same access pattern as dashboard.get_widgets: skip disabled plugins, inject
the plugin name on every returned dict, re-raise in dev/test, log-and-isolate
a broken plugin in prod. Returns the merged list (unsorted).
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return []
merged = []
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
for entry in getattr(plugin, hookname)() or []:
entry['plugin'] = name
merged.append(entry)
except Exception:
# fail loud in dev/test, isolate broke plugin in prod
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception('Plugin %s %s failed', name, hookname)
return merged
@pluginui_bp.route('/settings-cards', methods=['GET'])
@require_permission('assets.view')
def settings_cards():
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
The frontend merges these into the core settingsNav catalog and renders
them in the settings rail + landing overview.
"""
cards = _collect('get_settings_cards')
cards.sort(key=lambda card: card.get('position', 99))
return success_response(cards)
@pluginui_bp.route('/asset-panels', methods=['GET'])
@require_permission('assets.view')
def asset_panels():
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
Query param assetid is required. A panel matches when its assettypes list
contains the asset's type key or the wildcard '*'.
Every panel comes back carrying a 'group', naming the detail-page tab it
belongs to. It is resolved HERE rather than in the client so a plugin that
predates tabs, or names a group that does not exist, still lands somewhere a
person can see instead of vanishing from a page that only renders known
groups. Unknown and absent both mean 'details'.
"""
assetid = request.args.get('assetid', type=int)
if not assetid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid is required')
asset = db.session.get(Asset, assetid)
if not asset:
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
assettype = asset.assettype.assettype if asset.assettype else None
matched = []
for panel in _collect('get_asset_panels'):
assettypes = panel.get('assettypes') or []
if '*' in assettypes or (assettype and assettype in assettypes):
panel = dict(panel)
group = str(panel.get('group') or '').strip().lower()
panel['group'] = group if group in ASSET_PANEL_GROUPS else 'details'
matched.append(panel)
matched.sort(key=lambda panel: panel.get('position', 99))
return success_response(matched)
@pluginui_bp.route('/map-overlays', methods=['GET'])
@require_permission('assets.view')
def map_overlays():
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
The map fetches each overlay endpoint and decorates already-placed markers.
"""
overlays = _collect('get_map_overlays')
overlays.sort(key=lambda overlay: overlay.get('position', 99))
return success_response(overlays)
@pluginui_bp.route('/asset-presentation', methods=['GET'])
@require_permission('assets.view')
def asset_presentation():
"""Merge enabled plugins' asset-type presentation entries (hook).
Search rows and cross-links use these to pick a type's icon and detail route.
"""
entries = _collect('get_asset_presentation')
return success_response(entries)
@pluginui_bp.route('/asset-type-descriptors', methods=['GET'])
@require_permission('assets.view')
def asset_type_descriptors():
"""Merge enabled plugins' asset-type subtype descriptors.
The Asset Type Hub uses these to render generic CRUD interfaces for
each plugin's subtype table (printertypes, machinetypes, etc.) without
importing plugin models or views.
Each descriptor maps to ONE asset type (from assettypes table) and
tells core:
- Which API endpoint handles the subtype CRUD
- Field names for ID, label, description, color
- Required permission
- Optional plugin-specific tabs (e.g., 'supplies' for printers)
Returns: List[Dict] sorted by assettype name
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
descriptors = []
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
desc = plugin.get_asset_type_descriptor()
if desc is None:
continue
desc['plugin'] = name
descriptors.append(desc)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception('Plugin %s get_asset_type_descriptor failed', name)
# Validation: each descriptor must have required keys
required_keys = {'assettype', 'subtype_endpoint', 'subtype_id_field',
'subtype_label_field', 'subtype_description_field',
'subtype_color_field', 'edit_permission'}
validated = []
for desc in descriptors:
missing = required_keys - set(desc.keys())
if missing:
current_app.logger.error(
'Plugin %s descriptor missing keys: %s',
desc.get('plugin', 'unknown'), missing
)
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise ValueError(f"Descriptor missing keys: {missing}")
continue
validated.append(desc)
validated.sort(key=lambda d: d.get('assettype', ''))
return success_response(validated)
@pluginui_bp.route('/core-type-descriptors', methods=['GET'])
@require_permission('assets.view')
def core_type_descriptors():
"""Return type descriptors for core-owned non-asset types.
These are types managed by core (not plugins) that fit the same
management pattern as asset subtypes: location types, notification types, etc.
Returns same descriptor format as asset-type-descriptors, with optional
'custom_component' field for types that need specialized UI.
"""
descriptors = [
{
'category': 'location',
'type_name': 'Location Type',
'type_name_plural': 'Location Types',
'subtype_endpoint': '/locations/types',
'subtype_id_field': 'locationtypeid',
'subtype_label_field': 'locationtype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'admin',
'group': 'other',
},
{
'category': 'notification',
'type_name': 'Notification Type',
'type_name_plural': 'Notification Types',
'subtype_endpoint': '/notifications/types',
'subtype_id_field': 'notificationtypeid',
'subtype_label_field': 'typename',
'subtype_description_field': 'typedescription',
'subtype_color_field': 'typecolor',
'optional_tabs': [],
'edit_permission': 'admin',
'group': 'other',
'custom_component': 'NotificationTypesTab',
},
]
return success_response(descriptors)