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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user