ALL:Authoritative plugin fields

This commit is contained in:
Jokob @NetAlertX
2026-01-19 11:28:37 +00:00
parent 1e289e94e3
commit 3b203536b8
61 changed files with 5018 additions and 154 deletions

View File

@@ -0,0 +1,241 @@
"""
Authoritative field update handler for NetAlertX.
This module enforces source-tracking policies when plugins or users update device fields.
It prevents overwrites when fields are marked as USER or LOCKED, and tracks the source
of each field value.
Author: NetAlertX Core
License: GNU GPLv3
"""
import sys
import os
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/server"])
from logger import mylog # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
# Map of field to its source tracking field
FIELD_SOURCE_MAP = {
"devMac": "devMacSource",
"devName": "devNameSource",
"devFQDN": "devFqdnSource",
"devLastIP": "devLastIpSource",
"devVendor": "devVendorSource",
"devSSID": "devSsidSource",
"devParentMAC": "devParentMacSource",
"devParentPort": "devParentPortSource",
"devParentRelType": "devParentRelTypeSource",
"devVlan": "devVlanSource",
}
# Fields that support source tracking
TRACKED_FIELDS = set(FIELD_SOURCE_MAP.keys())
def get_plugin_authoritative_settings(plugin_prefix):
"""
Get SET_ALWAYS and SET_EMPTY settings for a plugin.
Args:
plugin_prefix: The unique prefix of the plugin (e.g., "UNIFIAPI").
Returns:
dict: {
"set_always": [list of fields],
"set_empty": [list of fields]
}
"""
try:
set_always_key = f"{plugin_prefix}_SET_ALWAYS"
set_empty_key = f"{plugin_prefix}_SET_EMPTY"
set_always = get_setting_value(set_always_key) or []
set_empty = get_setting_value(set_empty_key) or []
# Normalize to list of strings if they aren't already
if isinstance(set_always, str):
set_always = [set_always]
if isinstance(set_empty, str):
set_empty = [set_empty]
return {
"set_always": list(set_always) if set_always else [],
"set_empty": list(set_empty) if set_empty else [],
}
except Exception as e:
mylog("debug", [f"[authoritative_handler] Failed to get settings for {plugin_prefix}: {e}"])
return {"set_always": [], "set_empty": []}
def can_overwrite_field(field_name, current_source, plugin_prefix, plugin_settings, field_value):
"""
Determine if a plugin can overwrite a field.
Rules:
- If current_source is USER or LOCKED, cannot overwrite.
- If field_value is empty/None, cannot overwrite.
- If field is in SET_ALWAYS, can overwrite.
- If field is in SET_EMPTY AND current value is empty, can overwrite.
- If neither SET_ALWAYS nor SET_EMPTY apply, can overwrite empty fields only.
Args:
field_name: The field being updated (e.g., "devName").
current_source: The current source value (e.g., "USER", "LOCKED", "ARPSCAN", "NEWDEV", "").
plugin_prefix: The unique prefix of the overwriting plugin.
plugin_settings: dict with "set_always" and "set_empty" lists.
field_value: The new value the plugin wants to write.
Returns:
bool: True if the overwrite is allowed, False otherwise.
"""
# Rule 1: USER and LOCKED are protected
if current_source in ("USER", "LOCKED"):
return False
# Rule 2: Plugin must provide a non-empty value
if not field_value or (isinstance(field_value, str) and not field_value.strip()):
return False
# Rule 3: SET_ALWAYS takes precedence
set_always = plugin_settings.get("set_always", [])
if field_name in set_always:
return True
# Rule 4: SET_EMPTY allows overwriting only if field is empty
set_empty = plugin_settings.get("set_empty", [])
if field_name in set_empty:
# Check if field is "empty" (no current source or NEWDEV)
return not current_source or current_source == "NEWDEV"
# Rule 5: Default behavior - overwrite if field is empty/NEWDEV
return not current_source or current_source == "NEWDEV"
def get_source_for_field_update(field_name, plugin_prefix, is_user_override=False):
"""
Determine what source value should be set when a field is updated.
Args:
field_name: The field being updated.
plugin_prefix: The unique prefix of the plugin writing (e.g., "UNIFIAPI").
Ignored if is_user_override is True.
is_user_override: If True, return "USER"; if False, return plugin_prefix.
Returns:
str: The source value to set for the *Source field.
"""
if is_user_override:
return "USER"
return plugin_prefix
def enforce_source_on_user_update(devMac, updates_dict, conn):
"""
When a user updates device fields, enforce source tracking.
For each field with a corresponding *Source field:
- If the field value is being changed, set the *Source to "USER".
- If user explicitly locks a field, set the *Source to "LOCKED".
Args:
devMac: The MAC address of the device being updated.
updates_dict: Dict of field -> value being updated.
conn: Database connection object.
"""
cur = conn.cursor()
# Check if field has a corresponding source and should be updated
updates_to_apply = {}
for field_name, new_value in updates_dict.items():
if field_name in FIELD_SOURCE_MAP:
source_field = FIELD_SOURCE_MAP[field_name]
# User is updating this field, so mark it as USER
updates_to_apply[source_field] = "USER"
if not updates_to_apply:
return
# Build SET clause
set_clause = ", ".join([f"{k}=?" for k in updates_to_apply.keys()])
values = list(updates_to_apply.values())
values.append(devMac)
sql = f"UPDATE Devices SET {set_clause} WHERE devMac = ?"
try:
cur.execute(sql, values)
conn.commit()
mylog(
"debug",
[f"[enforce_source_on_user_update] Updated sources for {devMac}: {updates_to_apply}"],
)
except Exception as e:
mylog("none", [f"[enforce_source_on_user_update] ERROR: {e}"])
conn.rollback()
raise
def lock_field(devMac, field_name, conn):
"""
Lock a field so it won't be overwritten by plugins.
Args:
devMac: The MAC address of the device.
field_name: The field to lock.
conn: Database connection object.
"""
if field_name not in FIELD_SOURCE_MAP:
mylog("debug", [f"[lock_field] Field {field_name} does not support locking"])
return
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
sql = f"UPDATE Devices SET {source_field}='LOCKED' WHERE devMac = ?"
try:
cur.execute(sql, (devMac,))
conn.commit()
mylog("debug", [f"[lock_field] Locked {field_name} for {devMac}"])
except Exception as e:
mylog("none", [f"[lock_field] ERROR: {e}"])
conn.rollback()
raise
def unlock_field(devMac, field_name, conn):
"""
Unlock a field so plugins can overwrite it again.
Args:
devMac: The MAC address of the device.
field_name: The field to unlock.
conn: Database connection object.
"""
if field_name not in FIELD_SOURCE_MAP:
mylog("debug", [f"[unlock_field] Field {field_name} does not support unlocking"])
return
source_field = FIELD_SOURCE_MAP[field_name]
cur = conn.cursor()
# Unlock by resetting to empty (allows overwrite)
sql = f"UPDATE Devices SET {source_field}='' WHERE devMac = ?"
try:
cur.execute(sql, (devMac,))
conn.commit()
mylog("debug", [f"[unlock_field] Unlocked {field_name} for {devMac}"])
except Exception as e:
mylog("none", [f"[unlock_field] ERROR: {e}"])
conn.rollback()
raise

View File

@@ -9,6 +9,59 @@ from logger import mylog # noqa: E402 [flake8 lint suppression]
from messaging.in_app import write_notification # noqa: E402 [flake8 lint suppression]
# Define the expected Devices table columns (hardcoded base schema) [v25.5.24]
EXPECTED_DEVICES_COLUMNS = [
"devMac",
"devName",
"devOwner",
"devType",
"devVendor",
"devFavorite",
"devGroup",
"devComments",
"devFirstConnection",
"devLastConnection",
"devLastIP",
"devFQDN",
"devPrimaryIPv4",
"devPrimaryIPv6",
"devVlan",
"devForceStatus",
"devStaticIP",
"devScan",
"devLogEvents",
"devAlertEvents",
"devAlertDown",
"devSkipRepeated",
"devLastNotification",
"devPresentLastScan",
"devIsNew",
"devLocation",
"devIsArchived",
"devParentMAC",
"devParentPort",
"devParentRelType",
"devReqNicsOnline",
"devIcon",
"devGUID",
"devSite",
"devSSID",
"devSyncHubNode",
"devSourcePlugin",
"devMacSource",
"devNameSource",
"devFqdnSource",
"devLastIpSource",
"devVendorSource",
"devSsidSource",
"devParentMacSource",
"devParentPortSource",
"devParentRelTypeSource",
"devVlanSource",
"devCustomProps",
]
def ensure_column(sql, table: str, column_name: str, column_type: str) -> bool:
"""
Ensures a column exists in the specified table. If missing, attempts to add it.
@@ -30,63 +83,18 @@ def ensure_column(sql, table: str, column_name: str, column_type: str) -> bool:
if column_name in actual_columns:
return True # Already exists
# Define the expected columns (hardcoded base schema) [v25.5.24] - available in the default app.db
expected_columns = [
"devMac",
"devName",
"devOwner",
"devType",
"devVendor",
"devFavorite",
"devGroup",
"devComments",
"devFirstConnection",
"devLastConnection",
"devLastIP",
"devStaticIP",
"devScan",
"devLogEvents",
"devAlertEvents",
"devAlertDown",
"devSkipRepeated",
"devLastNotification",
"devPresentLastScan",
"devIsNew",
"devLocation",
"devIsArchived",
"devParentMAC",
"devParentPort",
"devIcon",
"devGUID",
"devSite",
"devSSID",
"devSyncHubNode",
"devSourcePlugin",
"devCustomProps",
]
# Check for mismatches in base schema
missing = set(expected_columns) - set(actual_columns)
extra = set(actual_columns) - set(expected_columns)
if missing:
# Validate that this column is in the expected schema
expected = EXPECTED_DEVICES_COLUMNS if table == "Devices" else []
if not expected or column_name not in expected:
msg = (
f"[db_upgrade] ⚠ ERROR: Unexpected DB structure "
f"(missing: {', '.join(missing) if missing else 'none'}, "
f"extra: {', '.join(extra) if extra else 'none'}) - "
"aborting schema change to prevent corruption. "
f"[db_upgrade] ⚠ ERROR: Column '{column_name}' is not in expected schema - "
f"aborting to prevent corruption. "
"Check https://docs.netalertx.com/UPDATES"
)
mylog("none", [msg])
write_notification(msg)
return False
if extra:
msg = (
f"[db_upgrade] Extra DB columns detected in {table}: {', '.join(extra)}"
)
mylog("none", [msg])
# Add missing column
mylog("verbose", [f"[db_upgrade] Adding '{column_name}' ({column_type}) to {table} table"],)
sql.execute(f'ALTER TABLE "{table}" ADD "{column_name}" {column_type}')
@@ -263,6 +271,7 @@ def ensure_CurrentScan(sql) -> bool:
cur_SyncHubNodeName STRING(50),
cur_NetworkSite STRING(250),
cur_SSID STRING(250),
cur_devVlan STRING(250),
cur_NetworkNodeMAC STRING(250),
cur_PORT STRING(250),
cur_Type STRING(250)