feat: authoritative plugin fields

Signed-off-by: jokob-sk <jokob.sk@gmail.com>
This commit is contained in:
jokob-sk
2026-01-25 00:04:06 +11:00
parent 899017fdd8
commit 8ea84a22e9
6 changed files with 62 additions and 130 deletions

View File

@@ -7,10 +7,10 @@ The Device Field Lock/Unlock feature allows users to lock specific device fields
## Concepts ## Concepts
### Tracked Fields ### Tracked Fields
Only certain device fields support locking. These are the fields that can be modified by both plugins and users: Only certain device fields support locking. These are the fields that can be modified by both plugins and users:
- `devMac` - Device MAC address
- `devName` - Device name/hostname - `devName` - Device name/hostname
- `devLastIP` - Last known IP address
- `devVendor` - Device vendor/manufacturer - `devVendor` - Device vendor/manufacturer
- `devFQDN` - Fully qualified domain name - `devFQDN` - Fully qualified domain name
- `devSSID` - Network SSID - `devSSID` - Network SSID
@@ -20,14 +20,18 @@ Only certain device fields support locking. These are the fields that can be mod
- `devVlan` - VLAN identifier - `devVlan` - VLAN identifier
### Field Source Tracking ### Field Source Tracking
Every tracked field has an associated `*Source` field that indicates where the current value originated: Every tracked field has an associated `*Source` field that indicates where the current value originated:
- `NEWDEV` - Created via the UI as a new device - `NEWDEV` - Created via the UI as a new device
- `USER` - Manually edited by a user - `USER` - Manually edited by a user
- `LOCKED` - Field is locked; prevents any plugin overwrites - `LOCKED` - Field is locked; prevents any plugin overwrites
- Plugin name (e.g., `UNIFIAPI`, `PIHOLE`) - Last updated by this plugin - Plugin name (e.g., `UNIFIAPI`, `PIHOLE`) - Last updated by this plugin
### Locking Mechanism ### Locking Mechanism
When a field is **locked**, its source is set to `LOCKED`. This prevents plugin overwrites based on the authorization logic: When a field is **locked**, its source is set to `LOCKED`. This prevents plugin overwrites based on the authorization logic:
1. Plugin wants to update field 1. Plugin wants to update field
2. Authoritative handler checks field's `*Source` value 2. Authoritative handler checks field's `*Source` value
3. If `*Source` == `LOCKED`, plugin update is rejected 3. If `*Source` == `LOCKED`, plugin update is rejected
@@ -38,6 +42,7 @@ When a field is **unlocked**, its source is set to `NEWDEV`, allowing plugins to
## Endpoints ## Endpoints
### Lock or Unlock a Field ### Lock or Unlock a Field
``` ```
POST /device/{mac}/field/lock POST /device/{mac}/field/lock
Authorization: Bearer {API_TOKEN} Authorization: Bearer {API_TOKEN}
@@ -134,6 +139,7 @@ The Device Edit form displays lock/unlock buttons for all tracked fields:
3. **Source Indicator**: Shows current field source (USER, LOCKED, NEWDEV, or plugin name) 3. **Source Indicator**: Shows current field source (USER, LOCKED, NEWDEV, or plugin name)
### Source Indicator Colors ### Source Indicator Colors
- Red (USER): Field was manually edited by a user - Red (USER): Field was manually edited by a user
- Orange (LOCKED): Field is locked and protected from overwrites - Orange (LOCKED): Field is locked and protected from overwrites
- Gray (NEWDEV/Plugin): Field value came from automatic discovery - Gray (NEWDEV/Plugin): Field value came from automatic discovery
@@ -141,6 +147,7 @@ The Device Edit form displays lock/unlock buttons for all tracked fields:
## UI Workflow ## UI Workflow
### Locking a Field via UI ### Locking a Field via UI
1. Navigate to Device Details 1. Navigate to Device Details
2. Find the field you want to protect 2. Find the field you want to protect
3. Click the lock button (🔒) next to the field 3. Click the lock button (🔒) next to the field
@@ -148,6 +155,7 @@ The Device Edit form displays lock/unlock buttons for all tracked fields:
5. Field is now protected from plugin overwrites 5. Field is now protected from plugin overwrites
### Unlocking a Field via UI ### Unlocking a Field via UI
1. Find the locked field (button shows 🔓) 1. Find the locked field (button shows 🔓)
2. Click the unlock button 2. Click the unlock button
3. Button changes back to lock (🔒) and source resets to NEWDEV 3. Button changes back to lock (🔒) and source resets to NEWDEV
@@ -167,59 +175,16 @@ The lock/unlock feature is implemented in:
- **Data Model**: `/server/models/device_instance.py` - Authorization checks in `setDeviceData()` - **Data Model**: `/server/models/device_instance.py` - Authorization checks in `setDeviceData()`
- **Database**: Devices table with `*Source` columns tracking field origins - **Database**: Devices table with `*Source` columns tracking field origins
### Frontend Logic
The lock/unlock UI is implemented in:
- **Device Edit Form**: `/front/deviceDetailsEdit.php`
- Form rendering with lock/unlock buttons
- JavaScript function `toggleFieldLock()` for API calls
- Source indicator display
- **Styling**: `/front/css/app.css` - Lock button and source indicator styles
### Authorization Handler ### Authorization Handler
The authoritative field update logic prevents plugin overwrites: The authoritative field update logic prevents plugin overwrites:
1. Plugin provides new value for field via plugin config `SET_ALWAYS`/`SET_EMPTY` 1. Plugin provides new value for field via plugin config `SET_ALWAYS`/`SET_EMPTY`
2. Authoritative handler (in DeviceInstance) checks `{field}Source` value 2. Authoritative handler (in DeviceInstance) checks `{field}Source` value
3. If source is `LOCKED` or `USER`, plugin update is rejected 3. If source is `LOCKED` or `USER`, plugin update is rejected
4. If source is `NEWDEV` or plugin name, plugin update is accepted 4. If source is `NEWDEV` or plugin name, plugin update is accepted
## Best Practices
### When to Lock Fields
- Device names that you've customized
- Static IP addresses or important identifiers
- Device vendor information you've corrected
- Fields prone to incorrect plugin updates
### When to Keep Unlocked
- Fields that plugins actively maintain (MAC, IP address)
- Fields you want auto-updated by discovery plugins
- Fields that may change frequently in your network
### Bulk Operations
The field lock/unlock feature is currently per-device. For bulk locking:
1. Use Multi-Edit to update device fields
2. Then use individual lock operations via API script
3. Or contact support for bulk lock endpoint
## Troubleshooting
### Lock Button Not Visible
- Device must be saved/created first (not "new" device)
- Field must be one of the 10 tracked fields
- Check browser console for JavaScript errors
### Lock Operation Failed
- Verify API token is valid
- Check device MAC address is correct
- Ensure device exists in database
### Field Still Updating After Lock
- Verify lock was successful (check API response)
- Reload device details page
- Check plugin logs to see if plugin is providing the field
- Look for authorization errors in NetAlertX logs
## See Also ## See Also
- [API Device Endpoints Documentation](API_DEVICE.md) - [API Device Endpoints Documentation](./API_DEVICE.md)
- [Authoritative Field Updates System](../docs/PLUGINS_DEV.md#authoritative-fields) - [Authoritative Field Updates System](./PLUGINS_DEV.md#authoritative-fields)
- [Plugin Configuration Reference](../docs/PLUGINS_DEV_CONFIG.md) - [Plugin Configuration Reference](./PLUGINS_DEV_CONFIG.md)

View File

@@ -187,20 +187,20 @@ For tracked fields (devMac, devName, devLastIP, devVendor, devFQDN, devSSID, dev
Controls whether a plugin field is enabled: Controls whether a plugin field is enabled:
- `"1"` - Plugin can always overwrite this field when authorized (subject to source-based permissions) - `["devName", "devLastIP"]` - Plugin can always overwrite this field when authorized (subject to source-based permissions)
- `"0"` - Plugin doesn't use this field
**Authorization logic:** Even with a field listed in `SET_ALWAYS`, the plugin respects source-based permissions:
**Authorization logic:** Even with `SET_ALWAYS: "1"`, the plugin respects source-based permissions:
- Cannot overwrite `USER` source (user manually edited) - Cannot overwrite `USER` source (user manually edited)
- Cannot overwrite `LOCKED` source (user locked field) - Cannot overwrite `LOCKED` source (user locked field)
- Can overwrite `NEWDEV` or plugin-owned sources (if plugin has SET_ALWAYS enabled) - Can overwrite `NEWDEV` or plugin-owned sources (if plugin has SET_ALWAYS enabled)
- Will update plugin-owned sources if value the same
**Example in config.json:** **Example in config.json:**
```json ```json
{ {
"setKey": "NEWDEV_devName", "SET_ALWAYS": ["devName", "devLastIP"]
"displayName": "Device Name",
"SET_ALWAYS": "1"
} }
``` ```
@@ -210,50 +210,18 @@ Controls whether a plugin field is enabled:
Restricts when a plugin can update a field: Restricts when a plugin can update a field:
- `"1"` - Overwrite only if current value is empty OR source is NEWDEV (conservative mode) - `"SET_EMPTY": ["devName", "devLastIP"]` - Overwrite these fields only if current value is empty OR source is `NEWDEV`
- `"0"` - No extra restriction; respect authorization logic (default)
**Use case:** Some plugins discover optional enrichment data (like vendor/hostname) that shouldn't override user-set or existing values. Use `SET_EMPTY: "1"` to be less aggressive. **Use case:** Some plugins discover optional enrichment data (like vendor/hostname) that shouldn't override user-set or existing values. Use `SET_EMPTY` to be less aggressive.
**Example in config.json:**
```json
{
"setKey": "NEWDEV_devVendor",
"displayName": "Device Vendor",
"SET_ALWAYS": "1",
"SET_EMPTY": "1"
}
```
### Authorization Decision Flow ### Authorization Decision Flow
1. **Source check:** Is field LOCKED or USER? → REJECT (protected) 1. **Source check:** Is field LOCKED or USER? → REJECT (protected)
2. **SET_ALWAYS check:** Is SET_ALWAYS enabled for this plugin+field? → YES: ALLOW (can overwrite empty values, NEWDEV, plugin sources, etc.) | NO: Continue to step 3 2. **Field in SET_ALWAYS check:** Is SET_ALWAYS enabled for this plugin+field? → YES: ALLOW (can overwrite empty values, NEWDEV, plugin sources, etc.) | NO: Continue to step 3
3. **SET_EMPTY check:** Is SET_EMPTY enabled AND field non-empty+non-NEWDEV? → REJECT 3. **Field in SET_EMPTY check:** Is SET_EMPTY enabled AND field non-empty+non-NEWDEV? → REJECT
4. **Default behavior:** Allow overwrite if field empty or NEWDEV source 4. **Default behavior:** Allow overwrite if field empty or NEWDEV source
### Plugin Field Mappings Reference
This table shows all device discovery and enrichment plugins and their tracked field configuration:
| Plugin | Tracked Fields | Behavior |
|--------|---|---|
| ARPSCAN | devMac, devLastIP | SET_ALWAYS for both |
| IPNEIGH | devMac, devLastIP | SET_ALWAYS for both |
| DHCPLSS | devMac, devLastIP | SET_ALWAYS for both |
| ASUSWRT | devMac, devLastIP | SET_ALWAYS for both |
| LUCIRPC | devMac, devLastIP | SET_ALWAYS for both |
| PIHOLE | devMac, devLastIP, devName, devVendor | SET_ALWAYS for MAC/IP |
| PIHOLEAPI | devMac, devLastIP, devName, devVendor | SET_ALWAYS for MAC/IP, SET_EMPTY for name/vendor |
| NBTSCAN | devName | SET_ALWAYS |
| DIGSCAN | devName, devFQDN | SET_ALWAYS |
| NSLOOKUP | devName, devFQDN | SET_ALWAYS |
| AVAHISCAN | devName | SET_ALWAYS |
| VNDRPDT | devMac, devVendor | SET_ALWAYS for both |
| SNMPDSC | devMac, devLastIP | SET_ALWAYS for both |
| UNIFIMP | devMac, devLastIP, devName, devVendor, devSSID, devParentMAC, devParentPort | SET_ALWAYS for MAC/IP |
| UNIFIAPI | devMac, devLastIP, devName, devParentMAC | SET_ALWAYS for MAC/IP |
**Note:** Check each plugin's `config.json` manifest for its specific SET_ALWAYS/SET_EMPTY configuration. **Note:** Check each plugin's `config.json` manifest for its specific SET_ALWAYS/SET_EMPTY configuration.
--- ---

View File

@@ -10,16 +10,14 @@ The device field lock/unlock system allows you to protect specific device fields
These are the ONLY fields that can be locked: These are the ONLY fields that can be locked:
1. devMac - Device MAC address - devName - Device hostname/alias
2. devName - Device hostname/alias - devVendor - Device manufacturer
3. devLastIP - Last known IP address - devFQDN - Fully qualified domain name
4. devVendor - Device manufacturer - devSSID - WiFi network name
5. devFQDN - Fully qualified domain name - devParentMAC - Parent/gateway MAC
6. devSSID - WiFi network name - devParentPort - Parent device port
7. devParentMAC - Parent/gateway MAC - devParentRelType - Relationship type (e.g., "gateway")
8. devParentPort - Parent device port - devVlan - VLAN identifier
9. devParentRelType - Relationship type (e.g., "gateway")
10. devVlan - VLAN identifier
## Source Values Explained ## Source Values Explained
@@ -27,10 +25,10 @@ Each locked field has a "source" indicator that shows you why the value is prote
| Indicator | Meaning | Can It Change? | | Indicator | Meaning | Can It Change? |
|-----------|---------|---| |-----------|---------|---|
| 🔒 **LOCKED** (red badge) | You locked this field | No, until you unlock it | | 🔒 **LOCKED** | You locked this field | No, until you unlock it |
| ✏️ **USER** (orange badge) | You edited this field | No, plugins can't overwrite | | ✏️ **USER** | You edited this field | No, plugins can't overwrite |
| 📡 **NEWDEV** (gray badge) | Default/unset value | Yes, plugins can update | | 📡 **NEWDEV** | Default/unset value | Yes, plugins can update |
| 📡 **Plugin name** (gray badge) | Last updated by a plugin (e.g., UNIFIAPI) | Yes, plugins can update | | 📡 **Plugin name** | Last updated by a plugin (e.g., UNIFIAPI) | Yes, plugins can update if field in SET_ALWAYS |
## How to Use ## How to Use
@@ -39,15 +37,15 @@ Each locked field has a "source" indicator that shows you why the value is prote
1. Navigate to **Device Details** for the device 1. Navigate to **Device Details** for the device
2. Find the field you want to protect (e.g., device name) 2. Find the field you want to protect (e.g., device name)
3. Click the **lock button** (🔒) next to the field 3. Click the **lock button** (🔒) next to the field
4. The button changes to **unlock** (🔓) and turns red 4. The button changes to **unlock** (🔓)
5. That field is now protected 5. That field is now protected
### Unlock a Field (Allow Plugin Updates) ### Unlock a Field (Allow Plugin Updates)
1. Go to **Device Details** 1. Go to **Device Details**
2. Find the locked field (shows 🔓 in red) 2. Find the locked field (shows 🔓)
3. Click the **unlock button** (🔓) 3. Click the **unlock button** (🔓)
4. The button changes back to **lock** (🔒) and turns gray 4. The button changes back to **lock** (🔒)
5. Plugins can now update that field again 5. Plugins can now update that field again
## Common Scenarios ## Common Scenarios
@@ -77,9 +75,9 @@ Each locked field has a "source" indicator that shows you why the value is prote
- ✅ Your custom value is kept - ✅ Your custom value is kept
- ✅ Future plugin scans won't overwrite it - ✅ Future plugin scans won't overwrite it
- ✅ You can still manually edit it anytime - ✅ You can still manually edit it anytime after unlocking
- ✅ Lock persists across plugin runs - ✅ Lock persists across plugin runs
- ✅ Other users can see it's locked (red indicator) - ✅ Other users can see it's locked
## What Happens When You Unlock a Field ## What Happens When You Unlock a Field
@@ -92,7 +90,7 @@ Each locked field has a "source" indicator that shows you why the value is prote
| Message | What It Means | What to Do | | Message | What It Means | What to Do |
|---------|--------------|-----------| |---------|--------------|-----------|
| "Field cannot be locked" | You tried to lock a field that doesn't support locking | Only lock the 10 fields listed above | | "Field cannot be locked" | You tried to lock a field that doesn't support locking | Only lock the fields listed above |
| "Device not found" | The device MAC address doesn't exist | Verify the device hasn't been deleted | | "Device not found" | The device MAC address doesn't exist | Verify the device hasn't been deleted |
| Lock button doesn't work | Network or permission issue | Refresh the page and try again | | Lock button doesn't work | Network or permission issue | Refresh the page and try again |
| Unexpected field changed | Field might have been unlocked | Check if field shows unlock icon (🔓) | | Unexpected field changed | Field might have been unlocked | Check if field shows unlock icon (🔓) |
@@ -121,7 +119,7 @@ Each locked field has a "source" indicator that shows you why the value is prote
## Troubleshooting ## Troubleshooting
**Lock button not appearing:** **Lock button not appearing:**
- Confirm the field is one of the 10 tracked fields (see list above) - Confirm the field is one of the tracked fields (see list above)
- Confirm the device is already saved (new devices don't show lock buttons) - Confirm the device is already saved (new devices don't show lock buttons)
- Refresh the page - Refresh the page
@@ -132,10 +130,10 @@ Each locked field has a "source" indicator that shows you why the value is prote
- Try again in a few seconds - Try again in a few seconds
**Field still changes after locking:** **Field still changes after locking:**
- Double-check the lock icon shows (red indicator) - Double-check the lock icon shows
- Reload the page—the change might be a display issue - Reload the page—the change might be a display issue
- Check if you accidentally unlocked it - Check if you accidentally unlocked it
- Contact support if it persists - pen an issue if it persists
## For More Information ## For More Information

View File

@@ -102,7 +102,7 @@ def process_scan(db):
# Clear current scan as processed # Clear current scan as processed
# 🐛 CurrentScan DEBUG: comment out below when debugging to keep the CurrentScan table after restarts/scan finishes # 🐛 CurrentScan DEBUG: comment out below when debugging to keep the CurrentScan table after restarts/scan finishes
# db.sql.execute("DELETE FROM CurrentScan") db.sql.execute("DELETE FROM CurrentScan")
# Commit changes # Commit changes
db.commitDB() db.commitDB()

View File

@@ -15,61 +15,61 @@ class TestCanOverwriteField:
def test_user_source_prevents_overwrite(self): def test_user_source_prevents_overwrite(self):
"""USER source should prevent any overwrite.""" """USER source should prevent any overwrite."""
assert not can_overwrite_field( assert not can_overwrite_field(
"devName", "USER", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName" "devName", "OldName", "USER", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
) )
def test_locked_source_prevents_overwrite(self): def test_locked_source_prevents_overwrite(self):
"""LOCKED source should prevent any overwrite.""" """LOCKED source should prevent any overwrite."""
assert not can_overwrite_field( assert not can_overwrite_field(
"devName", "LOCKED", "ARPSCAN", {"set_always": [], "set_empty": []}, "NewName" "devName", "OldName", "LOCKED", "ARPSCAN", {"set_always": [], "set_empty": []}, "NewName"
) )
def test_empty_value_prevents_overwrite(self): def test_empty_value_prevents_overwrite(self):
"""Empty/None values should prevent overwrite.""" """Empty/None values should prevent overwrite."""
assert not can_overwrite_field( assert not can_overwrite_field(
"devName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "" "devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, ""
) )
assert not can_overwrite_field( assert not can_overwrite_field(
"devName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, None "devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, None
) )
def test_set_always_allows_overwrite(self): def test_set_always_allows_overwrite(self):
"""SET_ALWAYS should allow overwrite regardless of current source.""" """SET_ALWAYS should allow overwrite regardless of current source."""
assert can_overwrite_field( assert can_overwrite_field(
"devName", "ARPSCAN", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName" "devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
) )
assert can_overwrite_field( assert can_overwrite_field(
"devName", "NEWDEV", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName" "devName", "", "NEWDEV", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
) )
def test_set_empty_allows_overwrite_only_when_empty(self): def test_set_empty_allows_overwrite_only_when_empty(self):
"""SET_EMPTY should allow overwrite only if field is empty or NEWDEV.""" """SET_EMPTY should allow overwrite only if field is empty or NEWDEV."""
assert can_overwrite_field( assert can_overwrite_field(
"devName", "", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName" "devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
) )
assert can_overwrite_field( assert can_overwrite_field(
"devName", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName" "devName", "", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
) )
assert not can_overwrite_field( assert not can_overwrite_field(
"devName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName" "devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
) )
def test_default_behavior_overwrites_empty_fields(self): def test_default_behavior_overwrites_empty_fields(self):
"""Without SET_ALWAYS/SET_EMPTY, should overwrite only empty fields.""" """Without SET_ALWAYS/SET_EMPTY, should overwrite only empty fields."""
assert can_overwrite_field( assert can_overwrite_field(
"devName", "", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName" "devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
) )
assert can_overwrite_field( assert can_overwrite_field(
"devName", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName" "devName", "", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
) )
assert not can_overwrite_field( assert not can_overwrite_field(
"devName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName" "devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
) )
def test_whitespace_value_treated_as_empty(self): def test_whitespace_value_treated_as_empty(self):
"""Whitespace-only values should be treated as empty.""" """Whitespace-only values should be treated as empty."""
assert not can_overwrite_field( assert not can_overwrite_field(
"devName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, " " "devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, " "
) )

View File

@@ -355,6 +355,7 @@ class TestFieldLockIntegration:
proposed_value = "Plugin Name" proposed_value = "Plugin Name"
can_overwrite = can_overwrite_field( can_overwrite = can_overwrite_field(
"devName", "devName",
device_data.get("devName"),
device_data.get("devNameSource"), device_data.get("devNameSource"),
plugin_prefix, plugin_prefix,
plugin_settings, plugin_settings,