feat(audit): log asset renames done by a sync

A manual rename under Assets is audited, a sync-driven one left no trace:
Netdisco, vCenter, IGEL and Intune adopt the name the source reports once
they matched an asset on a pin, and the old name, still in a ticket or a
mail, then pointed at nothing.

All five rename spots now go through asset_lifecycle.rename, the rename
counterpart of apply_status. It logs "<source> sync renamed asset <id>:
old → new" at INFO and writes an ASSET_UPDATED audit entry "Asset renamed
by <source> sync: old → new", old_value the old name, new_value JSON with
hostname, old_hostname and source so the Asset column shows the new name.
Wazuh, Nessus, Defender and the container imports never rename.
This commit is contained in:
2026-09-18 09:34:33 +02:00
parent 4f1f750658
commit 88a6bd47cc
7 changed files with 53 additions and 9 deletions
+6
View File
@@ -1559,6 +1559,12 @@ other: a thin client is not a network device, so a name or address shared
across the two is two devices (`asset_matching._not_pinned_by`, field report
2026-09-15).
Renames: Netdisco, vCenter, IGEL and Intune adopt the name the source reports
when the asset was matched on a pin. That goes through
`asset_lifecycle.rename`, which logs it (INFO) and writes an `ASSET_UPDATED`
audit entry "Asset renamed by <source> sync: old → new" (`old_value` = old
name). Wazuh, Nessus, Defender and the container imports never rename.
**Defender is the deliberate exception.** A re-imaged device keeps its old
machine entry, so Defender reports two machine ids for one host with the same
`computerDnsName` — and the CVE reconcile is built for exactly that
+31
View File
@@ -194,6 +194,37 @@ def apply_status(
return True
def rename(db: Session, asset: Asset, new_name: str, source: str) -> bool:
"""Set `asset.hostname` from a sync and log it, backend and audit log.
No-op if unchanged.
Every sync rename goes through here, for the same reason status changes go
through apply_status: a manual rename is audited by the asset router, and
without this a sync-driven one left no trace — the old name, still in a
ticket or a mail, then pointed at nothing (field request 2026-09-18).
"""
old = asset.hostname
if not new_name or old == new_name:
return False
asset.hostname = new_name
logger.info("%s sync renamed asset %s: %s%s", source, asset.id, old, new_name)
try:
from app.models.audit_log import AuditLog, AuditEventType
db.add(AuditLog(
user_id=None,
event_type=AuditEventType.ASSET_UPDATED,
event_description=f"Asset renamed by {source} sync: {old}{new_name}"[:500],
resource_type="asset",
resource_id=str(asset.id),
old_value=old,
new_value=json.dumps({"hostname": new_name, "old_hostname": old, "source": source}),
timestamp=datetime.now(),
))
except Exception as e:
logger.warning("asset rename audit failed for %s: %s", asset.id, e)
return True
def _stamp_source(asset: Asset) -> str:
"""Human-readable name of the sync that wrote this asset's stamps.
+2 -2
View File
@@ -332,7 +332,7 @@ def run_igel_sync(db: Session) -> dict:
def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
from app.services.asset_lifecycle import reconcile_igel_by_seen_ids
from app.services.asset_lifecycle import reconcile_igel_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
sync_devices = bool(cfg.get("sync_devices", True))
@@ -412,7 +412,7 @@ def _run_igel_sync_locked(db: Session, cfg: dict) -> dict:
# Renames are the norm on a thin-client estate; matched by unit
# ID or serial means the name UMS reports now is the current one.
if how in ("unit", "serial") and hostname and asset.hostname != hostname:
asset.hostname = hostname.split(".")[0] or hostname
rename(db, asset, hostname.split(".")[0] or hostname, "IGEL")
if d.get("ip_address"):
asset.ip_address = d["ip_address"][:45]
if resolved:
+2 -2
View File
@@ -335,7 +335,7 @@ def run_intune_sync(db: Session, asset_id: Optional[int] = None) -> dict:
def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
from app.services import eol_service
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids
from app.services.asset_lifecycle import reconcile_intune_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
detected_apps_enabled = bool(cfg.get("detected_apps", True))
@@ -373,7 +373,7 @@ def _run_intune_sync_locked(db: Session, cfg: dict) -> dict:
# HARDWARE — so the name Intune reports now is the current one.
if how in ("intune_id", "aad_id", "intune-serial") \
and new_name and asset.hostname != new_name:
asset.hostname = new_name.split(".")[0] or new_name
rename(db, asset, new_name.split(".")[0] or new_name, "Intune")
# refresh inventory fields
os_name = _os_string(device)
if os_name:
+4 -3
View File
@@ -598,7 +598,8 @@ def _find_or_create_asset(db: Session, d: dict, auto_create: bool):
# nothing else legitimately carries it. A device that now answers a
# name is renamed by the caller instead.
if not name and a.hostname == ip.split(".")[0]:
a.hostname = ip[:255]
from app.services.asset_lifecycle import rename
rename(db, a, ip[:255], "Netdisco")
return a, "netdisco-ip"
for candidate in [c for c in (hostname, short) if c]:
@@ -641,7 +642,7 @@ def run_netdisco_sync(db: Session) -> dict:
def _run_netdisco_sync_locked(db: Session, cfg: dict) -> dict:
from app.services.asset_lifecycle import reconcile_netdisco_by_seen_ids
from app.services.asset_lifecycle import reconcile_netdisco_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
stats = {"devices": 0, "assets_matched": 0, "assets_created": 0,
@@ -680,7 +681,7 @@ def _run_netdisco_sync_locked(db: Session, cfg: dict) -> dict:
name = (d.get("name") or "").strip()
if how in ("netdisco-ip", "netdisco-serial") and name \
and asset.hostname != name:
asset.hostname = (name.split(".")[0] or name)[:255]
rename(db, asset, (name.split(".")[0] or name)[:255], "Netdisco")
if d.get("ip"):
asset.ip_address = d["ip"][:45]
os_name, os_version = os_and_version(d)
+2 -2
View File
@@ -169,7 +169,7 @@ def run_vcenter_sync(db: Session, *, refresh_catalog: bool = False) -> dict:
def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -> dict:
from app.services.asset_lifecycle import reconcile_vcenter_by_seen_ids
from app.services.asset_lifecycle import reconcile_vcenter_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
sync_hosts = bool(cfg.get("sync_hosts", True))
@@ -239,7 +239,7 @@ def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -
# the name vCenter reports now is the current one.
name = (h.get("name") or "").strip()
if how == "uuid" and name and asset.hostname != name:
asset.hostname = name.split(".")[0] or name
rename(db, asset, name.split(".")[0] or name, "vCenter")
if h.get("ip_address"):
asset.ip_address = h["ip_address"][:45]
asset.operating_system = ESXI_OS
+6
View File
@@ -34,6 +34,7 @@ import app.models # noqa: F401,E402 (registers every mapper)
from app.integrations.igel_client import IgelClient # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.asset import Asset, AssetStatus # noqa: E402
from app.models.audit_log import AuditLog # noqa: E402
from app.services import igel_service as ig # noqa: E402
@@ -101,6 +102,11 @@ def demo():
_sync(db, _tc("00E0C514325B", "14D3E3C02B16160323M", "EMPFANG-01", id="4711"))
[a2] = _devices(db)
assert a2.id == a.id and a2.hostname == "EMPFANG-01"
# A sync rename leaves the same trail a manual one does (field request
# 2026-09-18): which source, old name, new name.
[log] = db.query(AuditLog).filter(AuditLog.old_value == "ITC00E0C514325B").all()
assert log.event_description == "Asset renamed by IGEL sync: ITC00E0C514325B → EMPFANG-01"
assert log.resource_id == str(a.id)
# --- 3: unitID changes, serial is the same hardware --------------------
_sync(db, _tc("00E0C5AAAAAA", "14D3E3C02B16160323M", "EMPFANG-01-NEU"))