Skip to content

Commit 8ea84a2

Browse files
committed
feat: authoritative plugin fields
Signed-off-by: jokob-sk <jokob.sk@gmail.com>
1 parent 899017f commit 8ea84a2

File tree

6 files changed

+62
-130
lines changed

6 files changed

+62
-130
lines changed

docs/API_DEVICE_FIELD_LOCK.md

Lines changed: 16 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ The Device Field Lock/Unlock feature allows users to lock specific device fields
77
## Concepts
88

99
### Tracked Fields
10+
1011
Only certain device fields support locking. These are the fields that can be modified by both plugins and users:
11-
- `devMac` - Device MAC address
12-
- `devName` - Device name/hostname
13-
- `devLastIP` - Last known IP address
12+
13+
- `devName` - Device name/hostname
1414
- `devVendor` - Device vendor/manufacturer
1515
- `devFQDN` - Fully qualified domain name
1616
- `devSSID` - Network SSID
@@ -20,14 +20,18 @@ Only certain device fields support locking. These are the fields that can be mod
2020
- `devVlan` - VLAN identifier
2121

2222
### Field Source Tracking
23+
2324
Every tracked field has an associated `*Source` field that indicates where the current value originated:
25+
2426
- `NEWDEV` - Created via the UI as a new device
2527
- `USER` - Manually edited by a user
2628
- `LOCKED` - Field is locked; prevents any plugin overwrites
2729
- Plugin name (e.g., `UNIFIAPI`, `PIHOLE`) - Last updated by this plugin
2830

2931
### Locking Mechanism
32+
3033
When a field is **locked**, its source is set to `LOCKED`. This prevents plugin overwrites based on the authorization logic:
34+
3135
1. Plugin wants to update field
3236
2. Authoritative handler checks field's `*Source` value
3337
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
3842
## Endpoints
3943

4044
### Lock or Unlock a Field
45+
4146
```
4247
POST /device/{mac}/field/lock
4348
Authorization: Bearer {API_TOKEN}
@@ -134,20 +139,23 @@ The Device Edit form displays lock/unlock buttons for all tracked fields:
134139
3. **Source Indicator**: Shows current field source (USER, LOCKED, NEWDEV, or plugin name)
135140

136141
### Source Indicator Colors
142+
137143
- Red (USER): Field was manually edited by a user
138144
- Orange (LOCKED): Field is locked and protected from overwrites
139145
- Gray (NEWDEV/Plugin): Field value came from automatic discovery
140146

141147
## UI Workflow
142148

143149
### Locking a Field via UI
150+
144151
1. Navigate to Device Details
145152
2. Find the field you want to protect
146153
3. Click the lock button (🔒) next to the field
147154
4. Button changes to unlock (🔓) and source indicator turns red (LOCKED)
148155
5. Field is now protected from plugin overwrites
149156

150157
### Unlocking a Field via UI
158+
151159
1. Find the locked field (button shows 🔓)
152160
2. Click the unlock button
153161
3. Button changes back to lock (🔒) and source resets to NEWDEV
@@ -167,59 +175,16 @@ The lock/unlock feature is implemented in:
167175
- **Data Model**: `/server/models/device_instance.py` - Authorization checks in `setDeviceData()`
168176
- **Database**: Devices table with `*Source` columns tracking field origins
169177

170-
### Frontend Logic
171-
The lock/unlock UI is implemented in:
172-
- **Device Edit Form**: `/front/deviceDetailsEdit.php`
173-
- Form rendering with lock/unlock buttons
174-
- JavaScript function `toggleFieldLock()` for API calls
175-
- Source indicator display
176-
- **Styling**: `/front/css/app.css` - Lock button and source indicator styles
177-
178178
### Authorization Handler
179+
179180
The authoritative field update logic prevents plugin overwrites:
181+
180182
1. Plugin provides new value for field via plugin config `SET_ALWAYS`/`SET_EMPTY`
181183
2. Authoritative handler (in DeviceInstance) checks `{field}Source` value
182184
3. If source is `LOCKED` or `USER`, plugin update is rejected
183185
4. If source is `NEWDEV` or plugin name, plugin update is accepted
184186

185-
## Best Practices
186-
187-
### When to Lock Fields
188-
- Device names that you've customized
189-
- Static IP addresses or important identifiers
190-
- Device vendor information you've corrected
191-
- Fields prone to incorrect plugin updates
192-
193-
### When to Keep Unlocked
194-
- Fields that plugins actively maintain (MAC, IP address)
195-
- Fields you want auto-updated by discovery plugins
196-
- Fields that may change frequently in your network
197-
198-
### Bulk Operations
199-
The field lock/unlock feature is currently per-device. For bulk locking:
200-
1. Use Multi-Edit to update device fields
201-
2. Then use individual lock operations via API script
202-
3. Or contact support for bulk lock endpoint
203-
204-
## Troubleshooting
205-
206-
### Lock Button Not Visible
207-
- Device must be saved/created first (not "new" device)
208-
- Field must be one of the 10 tracked fields
209-
- Check browser console for JavaScript errors
210-
211-
### Lock Operation Failed
212-
- Verify API token is valid
213-
- Check device MAC address is correct
214-
- Ensure device exists in database
215-
216-
### Field Still Updating After Lock
217-
- Verify lock was successful (check API response)
218-
- Reload device details page
219-
- Check plugin logs to see if plugin is providing the field
220-
- Look for authorization errors in NetAlertX logs
221-
222187
## See Also
223-
- [API Device Endpoints Documentation](API_DEVICE.md)
224-
- [Authoritative Field Updates System](../docs/PLUGINS_DEV.md#authoritative-fields)
225-
- [Plugin Configuration Reference](../docs/PLUGINS_DEV_CONFIG.md)
188+
- [API Device Endpoints Documentation](./API_DEVICE.md)
189+
- [Authoritative Field Updates System](./PLUGINS_DEV.md#authoritative-fields)
190+
- [Plugin Configuration Reference](./PLUGINS_DEV_CONFIG.md)

docs/PLUGINS_DEV_CONFIG.md

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -187,20 +187,20 @@ For tracked fields (devMac, devName, devLastIP, devVendor, devFQDN, devSSID, dev
187187

188188
Controls whether a plugin field is enabled:
189189

190-
- `"1"` - Plugin can always overwrite this field when authorized (subject to source-based permissions)
191-
- `"0"` - Plugin doesn't use this field
190+
- `["devName", "devLastIP"]` - Plugin can always overwrite this field when authorized (subject to source-based permissions)
191+
192+
**Authorization logic:** Even with a field listed in `SET_ALWAYS`, the plugin respects source-based permissions:
192193

193-
**Authorization logic:** Even with `SET_ALWAYS: "1"`, the plugin respects source-based permissions:
194194
- Cannot overwrite `USER` source (user manually edited)
195195
- Cannot overwrite `LOCKED` source (user locked field)
196196
- Can overwrite `NEWDEV` or plugin-owned sources (if plugin has SET_ALWAYS enabled)
197+
- Will update plugin-owned sources if value the same
197198

198199
**Example in config.json:**
200+
199201
```json
200202
{
201-
"setKey": "NEWDEV_devName",
202-
"displayName": "Device Name",
203-
"SET_ALWAYS": "1"
203+
"SET_ALWAYS": ["devName", "devLastIP"]
204204
}
205205
```
206206

@@ -210,50 +210,18 @@ Controls whether a plugin field is enabled:
210210

211211
Restricts when a plugin can update a field:
212212

213-
- `"1"` - Overwrite only if current value is empty OR source is NEWDEV (conservative mode)
214-
- `"0"` - No extra restriction; respect authorization logic (default)
213+
- `"SET_EMPTY": ["devName", "devLastIP"]` - Overwrite these fields only if current value is empty OR source is `NEWDEV`
215214

216-
**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.
215+
**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.
217216

218-
**Example in config.json:**
219-
```json
220-
{
221-
"setKey": "NEWDEV_devVendor",
222-
"displayName": "Device Vendor",
223-
"SET_ALWAYS": "1",
224-
"SET_EMPTY": "1"
225-
}
226-
```
227217

228218
### Authorization Decision Flow
229219

230220
1. **Source check:** Is field LOCKED or USER? → REJECT (protected)
231-
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
232-
3. **SET_EMPTY check:** Is SET_EMPTY enabled AND field non-empty+non-NEWDEV? → REJECT
221+
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
222+
3. **Field in SET_EMPTY check:** Is SET_EMPTY enabled AND field non-empty+non-NEWDEV? → REJECT
233223
4. **Default behavior:** Allow overwrite if field empty or NEWDEV source
234224

235-
### Plugin Field Mappings Reference
236-
237-
This table shows all device discovery and enrichment plugins and their tracked field configuration:
238-
239-
| Plugin | Tracked Fields | Behavior |
240-
|--------|---|---|
241-
| ARPSCAN | devMac, devLastIP | SET_ALWAYS for both |
242-
| IPNEIGH | devMac, devLastIP | SET_ALWAYS for both |
243-
| DHCPLSS | devMac, devLastIP | SET_ALWAYS for both |
244-
| ASUSWRT | devMac, devLastIP | SET_ALWAYS for both |
245-
| LUCIRPC | devMac, devLastIP | SET_ALWAYS for both |
246-
| PIHOLE | devMac, devLastIP, devName, devVendor | SET_ALWAYS for MAC/IP |
247-
| PIHOLEAPI | devMac, devLastIP, devName, devVendor | SET_ALWAYS for MAC/IP, SET_EMPTY for name/vendor |
248-
| NBTSCAN | devName | SET_ALWAYS |
249-
| DIGSCAN | devName, devFQDN | SET_ALWAYS |
250-
| NSLOOKUP | devName, devFQDN | SET_ALWAYS |
251-
| AVAHISCAN | devName | SET_ALWAYS |
252-
| VNDRPDT | devMac, devVendor | SET_ALWAYS for both |
253-
| SNMPDSC | devMac, devLastIP | SET_ALWAYS for both |
254-
| UNIFIMP | devMac, devLastIP, devName, devVendor, devSSID, devParentMAC, devParentPort | SET_ALWAYS for MAC/IP |
255-
| UNIFIAPI | devMac, devLastIP, devName, devParentMAC | SET_ALWAYS for MAC/IP |
256-
257225
**Note:** Check each plugin's `config.json` manifest for its specific SET_ALWAYS/SET_EMPTY configuration.
258226

259227
---

docs/QUICK_REFERENCE_FIELD_LOCK.md

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,25 @@ The device field lock/unlock system allows you to protect specific device fields
1010

1111
These are the ONLY fields that can be locked:
1212

13-
1. devMac - Device MAC address
14-
2. devName - Device hostname/alias
15-
3. devLastIP - Last known IP address
16-
4. devVendor - Device manufacturer
17-
5. devFQDN - Fully qualified domain name
18-
6. devSSID - WiFi network name
19-
7. devParentMAC - Parent/gateway MAC
20-
8. devParentPort - Parent device port
21-
9. devParentRelType - Relationship type (e.g., "gateway")
22-
10. devVlan - VLAN identifier
13+
- devName - Device hostname/alias
14+
- devVendor - Device manufacturer
15+
- devFQDN - Fully qualified domain name
16+
- devSSID - WiFi network name
17+
- devParentMAC - Parent/gateway MAC
18+
- devParentPort - Parent device port
19+
- devParentRelType - Relationship type (e.g., "gateway")
20+
- devVlan - VLAN identifier
2321

2422
## Source Values Explained
2523

2624
Each locked field has a "source" indicator that shows you why the value is protected:
2725

2826
| Indicator | Meaning | Can It Change? |
2927
|-----------|---------|---|
30-
| 🔒 **LOCKED** (red badge) | You locked this field | No, until you unlock it |
31-
| ✏️ **USER** (orange badge) | You edited this field | No, plugins can't overwrite |
32-
| 📡 **NEWDEV** (gray badge) | Default/unset value | Yes, plugins can update |
33-
| 📡 **Plugin name** (gray badge) | Last updated by a plugin (e.g., UNIFIAPI) | Yes, plugins can update |
28+
| 🔒 **LOCKED** | You locked this field | No, until you unlock it |
29+
| ✏️ **USER** | You edited this field | No, plugins can't overwrite |
30+
| 📡 **NEWDEV** | Default/unset value | Yes, plugins can update |
31+
| 📡 **Plugin name** | Last updated by a plugin (e.g., UNIFIAPI) | Yes, plugins can update if field in SET_ALWAYS |
3432

3533
## How to Use
3634

@@ -39,15 +37,15 @@ Each locked field has a "source" indicator that shows you why the value is prote
3937
1. Navigate to **Device Details** for the device
4038
2. Find the field you want to protect (e.g., device name)
4139
3. Click the **lock button** (🔒) next to the field
42-
4. The button changes to **unlock** (🔓) and turns red
40+
4. The button changes to **unlock** (🔓)
4341
5. That field is now protected
4442

4543
### Unlock a Field (Allow Plugin Updates)
4644

4745
1. Go to **Device Details**
48-
2. Find the locked field (shows 🔓 in red)
46+
2. Find the locked field (shows 🔓)
4947
3. Click the **unlock button** (🔓)
50-
4. The button changes back to **lock** (🔒) and turns gray
48+
4. The button changes back to **lock** (🔒)
5149
5. Plugins can now update that field again
5250

5351
## Common Scenarios
@@ -77,9 +75,9 @@ Each locked field has a "source" indicator that shows you why the value is prote
7775

7876
- ✅ Your custom value is kept
7977
- ✅ Future plugin scans won't overwrite it
80-
- ✅ You can still manually edit it anytime
78+
- ✅ You can still manually edit it anytime after unlocking
8179
- ✅ Lock persists across plugin runs
82-
- ✅ Other users can see it's locked (red indicator)
80+
- ✅ Other users can see it's locked
8381

8482
## What Happens When You Unlock a Field
8583

@@ -92,7 +90,7 @@ Each locked field has a "source" indicator that shows you why the value is prote
9290

9391
| Message | What It Means | What to Do |
9492
|---------|--------------|-----------|
95-
| "Field cannot be locked" | You tried to lock a field that doesn't support locking | Only lock the 10 fields listed above |
93+
| "Field cannot be locked" | You tried to lock a field that doesn't support locking | Only lock the fields listed above |
9694
| "Device not found" | The device MAC address doesn't exist | Verify the device hasn't been deleted |
9795
| Lock button doesn't work | Network or permission issue | Refresh the page and try again |
9896
| 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
121119
## Troubleshooting
122120

123121
**Lock button not appearing:**
124-
- Confirm the field is one of the 10 tracked fields (see list above)
122+
- Confirm the field is one of the tracked fields (see list above)
125123
- Confirm the device is already saved (new devices don't show lock buttons)
126124
- Refresh the page
127125

@@ -132,10 +130,10 @@ Each locked field has a "source" indicator that shows you why the value is prote
132130
- Try again in a few seconds
133131

134132
**Field still changes after locking:**
135-
- Double-check the lock icon shows (red indicator)
133+
- Double-check the lock icon shows
136134
- Reload the page—the change might be a display issue
137135
- Check if you accidentally unlocked it
138-
- Contact support if it persists
136+
- pen an issue if it persists
139137

140138
## For More Information
141139

server/scan/session_events.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def process_scan(db):
102102

103103
# Clear current scan as processed
104104
# 🐛 CurrentScan DEBUG: comment out below when debugging to keep the CurrentScan table after restarts/scan finishes
105-
# db.sql.execute("DELETE FROM CurrentScan")
105+
db.sql.execute("DELETE FROM CurrentScan")
106106

107107
# Commit changes
108108
db.commitDB()

test/authoritative_fields/test_authoritative_handler.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,61 +15,61 @@ class TestCanOverwriteField:
1515
def test_user_source_prevents_overwrite(self):
1616
"""USER source should prevent any overwrite."""
1717
assert not can_overwrite_field(
18-
"devName", "USER", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
18+
"devName", "OldName", "USER", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
1919
)
2020

2121
def test_locked_source_prevents_overwrite(self):
2222
"""LOCKED source should prevent any overwrite."""
2323
assert not can_overwrite_field(
24-
"devName", "LOCKED", "ARPSCAN", {"set_always": [], "set_empty": []}, "NewName"
24+
"devName", "OldName", "LOCKED", "ARPSCAN", {"set_always": [], "set_empty": []}, "NewName"
2525
)
2626

2727
def test_empty_value_prevents_overwrite(self):
2828
"""Empty/None values should prevent overwrite."""
2929
assert not can_overwrite_field(
30-
"devName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, ""
30+
"devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, ""
3131
)
3232
assert not can_overwrite_field(
33-
"devName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, None
33+
"devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, None
3434
)
3535

3636
def test_set_always_allows_overwrite(self):
3737
"""SET_ALWAYS should allow overwrite regardless of current source."""
3838
assert can_overwrite_field(
39-
"devName", "ARPSCAN", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
39+
"devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
4040
)
4141
assert can_overwrite_field(
42-
"devName", "NEWDEV", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
42+
"devName", "", "NEWDEV", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, "NewName"
4343
)
4444

4545
def test_set_empty_allows_overwrite_only_when_empty(self):
4646
"""SET_EMPTY should allow overwrite only if field is empty or NEWDEV."""
4747
assert can_overwrite_field(
48-
"devName", "", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
48+
"devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
4949
)
5050
assert can_overwrite_field(
51-
"devName", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
51+
"devName", "", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
5252
)
5353
assert not can_overwrite_field(
54-
"devName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
54+
"devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": ["devName"]}, "NewName"
5555
)
5656

5757
def test_default_behavior_overwrites_empty_fields(self):
5858
"""Without SET_ALWAYS/SET_EMPTY, should overwrite only empty fields."""
5959
assert can_overwrite_field(
60-
"devName", "", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
60+
"devName", "", "", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
6161
)
6262
assert can_overwrite_field(
63-
"devName", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
63+
"devName", "", "NEWDEV", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
6464
)
6565
assert not can_overwrite_field(
66-
"devName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
66+
"devName", "OldName", "ARPSCAN", "UNIFIAPI", {"set_always": [], "set_empty": []}, "NewName"
6767
)
6868

6969
def test_whitespace_value_treated_as_empty(self):
7070
"""Whitespace-only values should be treated as empty."""
7171
assert not can_overwrite_field(
72-
"devName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, " "
72+
"devName", "OldName", "", "UNIFIAPI", {"set_always": ["devName"], "set_empty": []}, " "
7373
)
7474

7575

0 commit comments

Comments
 (0)