diff --git a/LogicMonitor.Api.Test/Resources/EntityPropertyWriteTests.cs b/LogicMonitor.Api.Test/Resources/EntityPropertyWriteTests.cs
new file mode 100644
index 00000000..7a6fc44e
--- /dev/null
+++ b/LogicMonitor.Api.Test/Resources/EntityPropertyWriteTests.cs
@@ -0,0 +1,80 @@
+using Newtonsoft.Json;
+
+namespace LogicMonitor.Api.Test.Resources;
+
+///
+/// Portal-free tests for - the config-as-code shape used to write
+/// hidden (masked) property fields such as snmp.community / *.pass / *.key onto resources and groups.
+/// No network or credentials required.
+///
+public class EntityPropertyWriteTests
+{
+ [Fact]
+ public void HiddenFieldWriteConfig_Deserializes()
+ {
+ // The exact config shape a caller supplies to write hidden fields.
+ const string json = """
+ [
+ { "type": "resourceGroup", "id": 1234, "name": "snmp.community", "value": "public" }
+ ]
+ """;
+
+ var writes = JsonConvert.DeserializeObject>(json);
+
+ writes.Should().NotBeNull();
+ writes!.Should().HaveCount(1);
+
+ var write = writes[0];
+ write.Type.Should().Be(EntityPropertyWriteTargetType.ResourceGroup);
+ write.Id.Should().Be(1234);
+ write.Name.Should().Be("snmp.community");
+ write.Value.Should().Be("public");
+
+ // The write targets the group's own properties collection (one field), so the caller always
+ // supplies the real value rather than round-tripping a masked ********.
+ write.PropertiesSubUrl().Should().Be("device/groups/1234/properties");
+ }
+
+ [Fact]
+ public void ResourceTarget_MapsToDevicePropertiesEndpoint()
+ {
+ var write = new EntityPropertyWrite
+ {
+ Type = EntityPropertyWriteTargetType.Resource,
+ Id = 42,
+ Name = "esx.pass",
+ Value = "s3cret"
+ };
+
+ write.PropertiesSubUrl().Should().Be("device/devices/42/properties");
+ }
+
+ [Fact]
+ public void RoundTrips_WithCamelCaseTargetType()
+ {
+ var write = new EntityPropertyWrite
+ {
+ Type = EntityPropertyWriteTargetType.ResourceGroup,
+ Id = 1234,
+ Name = "snmp.community",
+ Value = "public"
+ };
+
+ var json = JObject.Parse(JsonConvert.SerializeObject(write));
+
+ json["type"]!.Value().Should().Be("resourceGroup");
+ json["id"]!.Value().Should().Be(1234);
+ json["name"]!.Value().Should().Be("snmp.community");
+ json["value"]!.Value().Should().Be("public");
+ }
+
+ [Fact]
+ public void UnknownTarget_Throws()
+ {
+ var write = new EntityPropertyWrite { Type = EntityPropertyWriteTargetType.Unknown, Id = 1, Name = "x" };
+
+ var act = write.PropertiesSubUrl;
+
+ act.Should().Throw();
+ }
+}
diff --git a/LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs b/LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs
new file mode 100644
index 00000000..c4a42b8a
--- /dev/null
+++ b/LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs
@@ -0,0 +1,206 @@
+namespace LogicMonitor.Api.Test.Resources;
+
+///
+/// Integration tests (live portal) proving:
+/// 1. A can be renamed via a full-object .
+/// 2. An Uptime ping-check can be renamed via PutAsync - and that BOTH Name and
+/// DisplayName must be set (a DisplayName-only change silently no-ops on uptimepingcheck devices), while
+/// the ping target (Host) is preserved.
+/// 3. A hidden/secret custom property (snmp.community) can be set/rotated via
+///
+/// (one field at a time, supplying the real value) - the admin-gated, config-as-code way to write
+/// hidden fields.
+///
+/// Note on the original masked-secret concern: a full-object PUT round-trip does NOT clobber a
+/// Resource/ResourceGroup secret - LogicMonitor treats the ******** mask as "unchanged" on write. This
+/// was verified empirically on a live portal (set test.pass, GET->PUT the whole object, then read the
+/// real stored value back via a reveal PropertySource that echoes nominated hidden props, with a
+/// fresh-run marker to defeat staleness; the value survived). So PutAsync-based rename is secret-safe.
+///
+public class ResourceRenameTests(ITestOutputHelper iTestOutputHelper, Fixture fixture)
+ : TestWithOutput(iTestOutputHelper, fixture), IClassFixture
+{
+ private const string TargetHost = "8.8.8.8";
+
+ [Fact]
+ public async Task PutAsync_RenamesResourceGroup()
+ {
+ const string originalName = "IntegrationTest-Rename-Group-Before";
+ const string renamedName = "IntegrationTest-Rename-Group-After";
+
+ await DeleteResourceGroupIfExistsAsync(originalName);
+ await DeleteResourceGroupIfExistsAsync(renamedName);
+
+ var group = await LogicMonitorClient.CreateAsync(
+ new ResourceGroupCreationDto { ParentId = "1", Name = originalName },
+ CancellationToken);
+
+ try
+ {
+ // Fetch the full object, change the Name, and PUT it back.
+ var fetched = await LogicMonitorClient.GetAsync(group.Id, CancellationToken);
+ fetched.Name = renamedName;
+ await LogicMonitorClient.PutAsync(fetched, CancellationToken);
+
+ var reloaded = await LogicMonitorClient.GetAsync(group.Id, CancellationToken);
+ reloaded.Name.Should().Be(renamedName);
+ }
+ finally
+ {
+ await LogicMonitorClient.DeleteAsync(group, true, CancellationToken);
+ }
+ }
+
+ [Fact]
+ public async Task PutAsync_RenamesUptimePingCheckResource_SettingNameAndDisplayName()
+ {
+ const string originalName = "IntegrationTest-Rename-Ping-Before";
+ const string renamedName = "IntegrationTest-Rename-Ping-After";
+
+ await DeleteResourceIfExistsAsync(originalName);
+ await DeleteResourceIfExistsAsync(renamedName);
+
+ var collectorId = await GetLiveCollectorIdAsync();
+
+ PingCheckResource resource;
+ try
+ {
+ resource = await LogicMonitorClient.CreateAsync(
+ new PingCheckResourceCreationDto
+ {
+ Name = originalName,
+ DisplayName = originalName,
+ Description = "Integration test rename ping check",
+ ResourceGroupIds = "1",
+ PreferredCollectorId = collectorId,
+ DisableAlerting = true,
+ IsInternal = true,
+ HostName = TargetHost,
+ PollingIntervalMinutes = 5,
+ PacketCount = 5,
+ TimeoutMs = 500,
+ PercentPacketsNotReceivedInTime = 80,
+ SyntheticsCollectorIds = [collectorId],
+ TestLocation = new UptimeTestLocation { All = true, CollectorIds = [collectorId], SmgIds = [] },
+ Alerting = new UptimeAlertSettings
+ {
+ OverallAlertLevel = Level.Critical,
+ IndividualAlertLevel = Level.Warning,
+ IndividualCheckpointAlertsEnabled = true,
+ FailedCheckCountBeforeAlerting = 1,
+ AlertCondition = SiteMonitorAlertCondition.AllLocations
+ }
+ },
+ CancellationToken);
+ }
+ catch (LogicMonitorApiException ex) when (IsUptimeUnavailable(ex))
+ {
+ Assert.Skip($"Portal does not have LM Uptime enabled: {ex.Message}");
+ return;
+ }
+
+ try
+ {
+ var fetched = await LogicMonitorClient.GetAsync(resource.Id, CancellationToken);
+ var originalHost = fetched.HostName;
+
+ // A rename on a uptimepingcheck device must set BOTH Name and DisplayName; a DisplayName-only
+ // change silently no-ops on these devices. For these devices Name is a label, not the ping
+ // target (that is HostName), so changing Name is safe.
+ fetched.Name = renamedName;
+ fetched.DisplayName = renamedName;
+ await LogicMonitorClient.PutAsync(fetched, CancellationToken);
+
+ var reloaded = await LogicMonitorClient.GetAsync(resource.Id, CancellationToken);
+ reloaded.Name.Should().Be(renamedName);
+ reloaded.DisplayName.Should().Be(renamedName);
+ reloaded.HostName.Should().Be(originalHost, "the ping target must be preserved across a rename");
+ reloaded.ResourceType.Should().Be(ResourceType.Ping);
+ }
+ finally
+ {
+ if (resource.Id > 0)
+ {
+ await LogicMonitorClient.DeleteAsync(resource, cancellationToken: CancellationToken);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task SetCustomPropertyAsync_WritesHiddenField_ViaEntityPropertyWrite()
+ {
+ const string groupName = "IntegrationTest-HiddenField-Group";
+ const string secretPropertyName = "snmp.community";
+
+ await DeleteResourceGroupIfExistsAsync(groupName);
+
+ var group = await LogicMonitorClient.CreateAsync(
+ new ResourceGroupCreationDto { ParentId = "1", Name = groupName },
+ CancellationToken);
+
+ try
+ {
+ // Write the hidden field one property at a time, supplying the real value (never round-tripping
+ // a masked ********). This is the admin-gated config-as-code shape used to set/rotate hidden
+ // fields: [ { "type": "resourceGroup", "id": , "name": "snmp.community", "value": "public" } ]
+ var write = new EntityPropertyWrite
+ {
+ Type = EntityPropertyWriteTargetType.ResourceGroup,
+ Id = group.Id,
+ Name = secretPropertyName,
+ Value = "public"
+ };
+ await LogicMonitorClient.SetCustomPropertyAsync(write, SetPropertyMode.Create, CancellationToken);
+
+ var properties = await LogicMonitorClient.GetResourceGroupPropertiesAsync(group.Id, CancellationToken);
+ properties.Should().ContainSingle(p => p.Name == secretPropertyName,
+ "the hidden field must be written (its value is masked as ******** by LogicMonitor on read)");
+
+ // The batch entry point applies the same write as a config list.
+ await LogicMonitorClient.SetCustomPropertiesAsync([write], SetPropertyMode.Update, CancellationToken);
+
+ properties = await LogicMonitorClient.GetResourceGroupPropertiesAsync(group.Id, CancellationToken);
+ properties.Should().ContainSingle(p => p.Name == secretPropertyName);
+ }
+ finally
+ {
+ await LogicMonitorClient.DeleteAsync(group, true, CancellationToken);
+ }
+ }
+
+ ///
+ /// True when a creation error indicates the portal cannot host LM Uptime checks (feature disabled or the
+ /// Ping_Check LogicModules are not imported) - in which case the test should be skipped, not failed.
+ ///
+ private static bool IsUptimeUnavailable(LogicMonitorApiException ex)
+ => ex.Message.Contains("Uptime feature is not enabled", StringComparison.OrdinalIgnoreCase)
+ || ex.Message.Contains("datasources not found", StringComparison.OrdinalIgnoreCase);
+
+ private async Task GetLiveCollectorIdAsync()
+ {
+ var collectors = await LogicMonitorClient.GetAllAsync(CancellationToken);
+ var collector = collectors.FirstOrDefault(c => c.Id == CollectorId)
+ ?? collectors.FirstOrDefault(c => !c.IsDown)
+ ?? collectors.FirstOrDefault();
+ collector.Should().NotBeNull("the portal must have at least one Collector for an internal Uptime check");
+ return collector!.Id;
+ }
+
+ private async Task DeleteResourceGroupIfExistsAsync(string fullPath)
+ {
+ var existing = await LogicMonitorClient.GetResourceGroupByFullPathAsync(fullPath, CancellationToken);
+ if (existing is not null)
+ {
+ await LogicMonitorClient.DeleteAsync(existing, true, CancellationToken);
+ }
+ }
+
+ private async Task DeleteResourceIfExistsAsync(string displayName)
+ {
+ var existing = await LogicMonitorClient.GetResourceByDisplayNameAsync(displayName, CancellationToken);
+ if (existing is not null)
+ {
+ await LogicMonitorClient.DeleteAsync(existing, cancellationToken: CancellationToken);
+ }
+ }
+}
diff --git a/LogicMonitor.Api/LogicMonitorClient_Resources.cs b/LogicMonitor.Api/LogicMonitorClient_Resources.cs
index 21d2aa87..f9c9c715 100644
--- a/LogicMonitor.Api/LogicMonitorClient_Resources.cs
+++ b/LogicMonitor.Api/LogicMonitorClient_Resources.cs
@@ -205,6 +205,56 @@ public Task SetResourceGroupCustomPropertyAsync(
"device/groups",
cancellationToken);
+ ///
+ /// Applies a single , writing one custom property onto the
+ /// target or .
+ ///
+ ///
+ /// This writes only the named property (never a full object), so you always supply the real value
+ /// rather than round-tripping a masked ********. It is the admin-gated way to set or rotate
+ /// hidden fields such as snmp.community, *.pass and *.key. (For reference: a
+ /// full-object PUT does not clobber a Resource/ResourceGroup secret either - the server treats the
+ /// ******** mask as "unchanged" - but this method is how you change the stored value.)
+ ///
+ /// The property write directive.
+ /// How to set the property (Create, Update, Delete or Automatic).
+ /// The cancellation token.
+ /// Thrown when the write targets an unsupported entity type.
+ public Task SetCustomPropertyAsync(
+ EntityPropertyWrite write,
+ SetPropertyMode mode,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(write);
+ return write.Type switch
+ {
+ EntityPropertyWriteTargetType.Resource
+ => SetResourceCustomPropertyAsync(write.Id, write.Name, write.Value, mode, cancellationToken),
+ EntityPropertyWriteTargetType.ResourceGroup
+ => SetResourceGroupCustomPropertyAsync(write.Id, write.Name, write.Value, mode, cancellationToken),
+ _ => throw new NotSupportedException($"Unsupported {nameof(EntityPropertyWriteTargetType)}: {write.Type}")
+ };
+ }
+
+ ///
+ /// Applies a batch of directives in order. This is the
+ /// config-as-code entry point for setting hidden property fields across resources and groups.
+ ///
+ /// The property write directives to apply, in order.
+ /// How to set each property (Create, Update, Delete or Automatic).
+ /// The cancellation token.
+ public async Task SetCustomPropertiesAsync(
+ IEnumerable writes,
+ SetPropertyMode mode,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(writes);
+ foreach (var write in writes)
+ {
+ await SetCustomPropertyAsync(write, mode, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
///
/// Gets Resources
///
diff --git a/LogicMonitor.Api/Resources/EntityPropertyWrite.cs b/LogicMonitor.Api/Resources/EntityPropertyWrite.cs
new file mode 100644
index 00000000..1a4754ad
--- /dev/null
+++ b/LogicMonitor.Api/Resources/EntityPropertyWrite.cs
@@ -0,0 +1,71 @@
+namespace LogicMonitor.Api.Resources;
+
+///
+/// A single directive to write one custom property onto a or
+/// , identified by type and id.
+///
+///
+///
+/// This is the config-as-code shape for setting or rotating hidden property fields (e.g.
+/// snmp.community, *.pass, *.key) to a new value. It writes one named property
+/// at a time, so a masked ******** value is never sent - you always supply the real value.
+///
+///
+/// Note on the masked-secret concern: LogicMonitor masks these values as ******** on GET, but
+/// for a / the server treats that mask as
+/// "leave unchanged" on write, so a full-object round-trip (GET then
+/// , e.g. for a
+/// rename) does not clobber the real secret. This was verified empirically. Use
+/// when you actually want to change the stored value, not merely
+/// preserve it across an unrelated update.
+///
+///
+/// Applying these writes requires LogicMonitor administrator rights. A list of these objects
+/// deserialises directly from JSON such as:
+///
+/// [ { "type": "resourceGroup", "id": 1234, "name": "snmp.community", "value": "public" } ]
+///
+///
+///
+[DataContract]
+public class EntityPropertyWrite
+{
+ ///
+ /// The type of entity to write the property onto.
+ ///
+ [DataMember(Name = "type")]
+ public EntityPropertyWriteTargetType Type { get; set; }
+
+ ///
+ /// The id of the target or .
+ ///
+ [DataMember(Name = "id")]
+ public int Id { get; set; }
+
+ ///
+ /// The custom property name to write (e.g. snmp.community).
+ ///
+ [DataMember(Name = "name")]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// The custom property value to write. If null and the property exists, it is removed.
+ ///
+ [DataMember(Name = "value")]
+ public string? Value { get; set; }
+
+ ///
+ /// The properties sub-URL that this write targets, e.g.
+ /// device/groups/1234/properties or device/devices/1234/properties.
+ ///
+ /// Thrown when is not a supported target.
+ public string PropertiesSubUrl() => Type switch
+ {
+ EntityPropertyWriteTargetType.Resource => $"device/devices/{Id}/properties",
+ EntityPropertyWriteTargetType.ResourceGroup => $"device/groups/{Id}/properties",
+ _ => throw new NotSupportedException($"Unsupported {nameof(EntityPropertyWriteTargetType)}: {Type}")
+ };
+
+ ///
+ public override string ToString() => $"{Type} {Id}: {Name}={Value}";
+}
diff --git a/LogicMonitor.Api/Resources/EntityPropertyWriteTargetType.cs b/LogicMonitor.Api/Resources/EntityPropertyWriteTargetType.cs
new file mode 100644
index 00000000..dfbd0cf7
--- /dev/null
+++ b/LogicMonitor.Api/Resources/EntityPropertyWriteTargetType.cs
@@ -0,0 +1,27 @@
+namespace LogicMonitor.Api.Resources;
+
+///
+/// The type of entity that an targets.
+///
+[DataContract]
+[JsonConverter(typeof(StringEnumConverter))]
+public enum EntityPropertyWriteTargetType
+{
+ ///
+ /// Unknown / unset.
+ ///
+ [EnumMember(Value = "unknown")]
+ Unknown = 0,
+
+ ///
+ /// A (device).
+ ///
+ [EnumMember(Value = "resource")]
+ Resource,
+
+ ///
+ /// A (device group).
+ ///
+ [EnumMember(Value = "resourceGroup")]
+ ResourceGroup
+}