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
### Tracked Fields
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
- `devLastIP` - Last known IP address
- `devName` - Device name/hostname
- `devVendor` - Device vendor/manufacturer
- `devFQDN` - Fully qualified domain name
- `devSSID` - Network SSID
@@ -20,14 +20,18 @@ Only certain device fields support locking. These are the fields that can be mod
- `devVlan` - VLAN identifier
### Field Source Tracking
Every tracked field has an associated `*Source` field that indicates where the current value originated:
- `NEWDEV` - Created via the UI as a new device
- `USER` - Manually edited by a user
- `LOCKED` - Field is locked; prevents any plugin overwrites
- Plugin name (e.g., `UNIFIAPI`, `PIHOLE`) - Last updated by this plugin
### Locking Mechanism
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
2. Authoritative handler checks field's `*Source` value
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
### Lock or Unlock a Field
```
POST /device/{mac}/field/lock
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)
### Source Indicator Colors
- Red (USER): Field was manually edited by a user
- Orange (LOCKED): Field is locked and protected from overwrites
- 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
### Locking a Field via UI
1. Navigate to Device Details
2. Find the field you want to protect
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
### Unlocking a Field via UI
1. Find the locked field (button shows 🔓)
2. Click the unlock button
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()`
- **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
The authoritative field update logic prevents plugin overwrites:
1. Plugin provides new value for field via plugin config `SET_ALWAYS`/`SET_EMPTY`
2. Authoritative handler (in DeviceInstance) checks `{field}Source` value
3. If source is `LOCKED` or `USER`, plugin update is rejected
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
- [API Device Endpoints Documentation](API_DEVICE.md)
- [Authoritative Field Updates System](../docs/PLUGINS_DEV.md#authoritative-fields)
- [Plugin Configuration Reference](../docs/PLUGINS_DEV_CONFIG.md)
- [API Device Endpoints Documentation](./API_DEVICE.md)
- [Authoritative Field Updates System](./PLUGINS_DEV.md#authoritative-fields)
- [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:
- `"1"` - Plugin can always overwrite this field when authorized (subject to source-based permissions)
- `"0"` - Plugin doesn't use this field
- `["devName", "devLastIP"]` - Plugin can always overwrite this field when authorized (subject to source-based permissions)
**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 `LOCKED` source (user locked field)
- 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:**
```json
{
"setKey": "NEWDEV_devName",
"displayName": "Device Name",
"SET_ALWAYS": "1"
"SET_ALWAYS": ["devName", "devLastIP"]
}
```
@@ -210,50 +210,18 @@ Controls whether a plugin field is enabled:
Restricts when a plugin can update a field:
- `"1"` - Overwrite only if current value is empty OR source is NEWDEV (conservative mode)
- `"0"` - No extra restriction; respect authorization logic (default)
- `"SET_EMPTY": ["devName", "devLastIP"]` - Overwrite these fields only if current value is empty OR source is `NEWDEV`
**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
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
3. **SET_EMPTY check:** Is SET_EMPTY enabled AND field non-empty+non-NEWDEV? → REJECT
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. **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
### 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.
---

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:
1. devMac - Device MAC address
2. devName - Device hostname/alias
3. devLastIP - Last known IP address
4. devVendor - Device manufacturer
5. devFQDN - Fully qualified domain name
6. devSSID - WiFi network name
7. devParentMAC - Parent/gateway MAC
8. devParentPort - Parent device port
9. devParentRelType - Relationship type (e.g., "gateway")
10. devVlan - VLAN identifier
- devName - Device hostname/alias
- devVendor - Device manufacturer
- devFQDN - Fully qualified domain name
- devSSID - WiFi network name
- devParentMAC - Parent/gateway MAC
- devParentPort - Parent device port
- devParentRelType - Relationship type (e.g., "gateway")
- devVlan - VLAN identifier
## 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? |
|-----------|---------|---|
| 🔒 **LOCKED** (red badge) | You locked this field | No, until you unlock it |
| ✏️ **USER** (orange badge) | You edited this field | No, plugins can't overwrite |
| 📡 **NEWDEV** (gray badge) | Default/unset value | Yes, plugins can update |
| 📡 **Plugin name** (gray badge) | Last updated by a plugin (e.g., UNIFIAPI) | Yes, plugins can update |
| 🔒 **LOCKED** | You locked this field | No, until you unlock it |
| ✏️ **USER** | You edited this field | No, plugins can't overwrite |
| 📡 **NEWDEV** | Default/unset value | 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
@@ -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
2. Find the field you want to protect (e.g., device name)
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
### Unlock a Field (Allow Plugin Updates)
1. Go to **Device Details**
2. Find the locked field (shows 🔓 in red)
2. Find the locked field (shows 🔓)
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
## 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
- ✅ 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
- ✅ Other users can see it's locked (red indicator)
- ✅ Other users can see it's locked
## 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 |
|---------|--------------|-----------|
| "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 |
| 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 (🔓) |
@@ -121,7 +119,7 @@ Each locked field has a "source" indicator that shows you why the value is prote
## Troubleshooting
**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)
- 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
**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
- Check if you accidentally unlocked it
- Contact support if it persists
- pen an issue if it persists
## For More Information

View File

@@ -102,7 +102,7 @@ def process_scan(db):
# Clear current scan as processed
# 🐛 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
db.commitDB()

View File

@@ -15,61 +15,61 @@ class TestCanOverwriteField:
def test_user_source_prevents_overwrite(self):
"""USER source should prevent any overwrite."""
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):
"""LOCKED source should prevent any overwrite."""
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):
"""Empty/None values should prevent overwrite."""
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(
"devName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, None
"devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, None
)
def test_set_always_allows_overwrite(self):
"""SET_ALWAYS should allow overwrite regardless of current source."""
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(
"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):
"""SET_EMPTY should allow overwrite only if field is empty or NEWDEV."""
assert can_overwrite_field(
"devName", "", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
"devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
)
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(
"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):
"""Without SET_ALWAYS/SET_EMPTY, should overwrite only empty fields."""
assert can_overwrite_field(
"devName", "", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
"devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
)
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(
"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):
"""Whitespace-only values should be treated as empty."""
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"
can_overwrite = can_overwrite_field(
"devName",
device_data.get("devName"),
device_data.get("devNameSource"),
plugin_prefix,
plugin_settings,