fix(backend): admin configuration is written only in its changed columns; PATCH global/config replaces the full-model PUT (D-285)

On 2026-09-25 a Global Settings save from a page loaded before an agent release wrote the whole configuration back and reverted the release's LatestAgentV2* hashes. After that, every 2.0.1465 agent reported IntegrityCheckFailed. The flaw was structural: every writer (portal PUT, plan tiers, three vulnerability-sync stamp paths) replaced the whole row from the copy it held, usually the 5-minute cache.

- IConfigRepository.UpdateAdminConfigurationAsync(mutate, updatedBy) is the one write path. It reads the row fresh with its ETag and applies the mutation to a copy materialized from that read. It computes the changed columns from the converter projections and writes only those onto the freshly read raw entity; a null value drops the column. The replace is conditional, and a conflict triggers a re-read and retry. A no-op writes nothing. Pipeline-owned and unknown columns survive. The full-model SaveAdminConfigurationAsync / SaveConfigurationAsync are removed, and the first row is created with AddEntity.
- PATCH global/config with { fields } (PatchAdminConfigurationRequest) replaces PUT/POST. Its field gate is derived from the persisted columns and rejects server stamps, LatestAgentV2* / LatestBootstrapV2ScriptVersion, PlanTierDefinitionsJson, the sync stamps and redacted placeholders. The route catalog lists PATCH.
- Plan tiers, TriggerMsrcSync, TriggerVulnerabilityDataSync, VulnerabilityDataSyncFunction and VulnerabilityCacheRefreshService.StampAsync each hand in a mutation.
- Tests: AdminConfigurationChangedFieldsTests (stale-page toggle keeps the release hashes, concurrent pipeline write re-read and kept, no-op, rejection, null drop, first row, gate); existing tests adapted; the request guard allows the admin field map.
- Regenerated shared manifest and wire types (web utils + MCP). The web still sends PUT until the web commit that follows the backend deploy.
- The internal pointer is bumped to D-285.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-25 13:51:53 +02:00
parent 59bea9d888
commit df640e3684
22 changed files with 780 additions and 224 deletions
@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// D-285: the admin configuration row is changed only through the changed-columns write — fresh
/// read, mutation on a copy, only the columns whose value changed are laid onto the freshly read
/// row, conditional on its ETag. Pins the 2026-09-25 incident: a Global Settings page loaded before
/// an agent release saved one toggle and reverted the release's hashes with the full-model PUT.
/// </summary>
public class AdminConfigurationChangedFieldsTests
{
private const string ReleaseVersion = "2.0.1465";
private const string ReleaseZipSha = "9f0b5403da7e49202274fdf8f38675bc5cfea85f7d881e85d5fe4bd71308250e";
private const string ReleaseExeSha = "503dd02b3c3690acfe20c44dfce82c367ca1e56dcedaba3ecc91ff6ce893d726";
/// <summary>
/// A one-row table that behaves like storage: reads hand out copies with the current ETag,
/// a replace with a stale ETag fails 412, a create on an existing row fails 409.
/// </summary>
private sealed class FakeAdminTable
{
private int _version = 1;
public TableEntity? Current;
public int Writes;
public TableUpdateMode? LastMode;
/// <summary>Runs once, right before the first replace is checked — a concurrent writer.</summary>
public Action<FakeAdminTable>? BeforeFirstReplace;
public TableConfigRepository Sut { get; }
public FakeAdminTable(TableEntity? current)
{
Current = current;
var table = new Mock<TableClient>();
table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(() => Current == null
? throw new RequestFailedException(404, "Not Found", "ResourceNotFound", null)
: Task.FromResult(Response.FromValue(Copy(Current, _version), Mock.Of<Response>())));
table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, ifMatch, mode, _) =>
{
if (BeforeFirstReplace != null)
{
var concurrent = BeforeFirstReplace;
BeforeFirstReplace = null;
concurrent(this);
}
if (ifMatch != Etag(_version))
throw new RequestFailedException(412, "Precondition Failed", "UpdateConditionNotSatisfied", null);
Current = Copy(e, _version);
_version++;
Writes++;
LastMode = mode;
return Task.FromResult(Mock.Of<Response>());
});
table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((e, _) =>
{
if (Current != null)
throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
Current = Copy(e, _version);
Writes++;
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(table.Object);
Sut = new TableConfigRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
new Mock<IConfigBackupRepository>().Object,
NullLogger<TableConfigRepository>.Instance);
}
/// <summary>A write by someone else (the release pipeline's Merge), bumping the ETag.</summary>
public void ExternalMerge(string column, object value)
{
Current![column] = value;
_version++;
}
private static ETag Etag(int version) => new($"W/\"v{version}\"");
private static TableEntity Copy(TableEntity source, int version)
=> new(source.Where(kv => kv.Key != "odata.etag").ToDictionary(kv => kv.Key, kv => kv.Value)) { ETag = Etag(version) };
}
/// <summary>The stored row right after the agent release: the pipeline's hashes plus a column this build does not know.</summary>
private static TableEntity ReleasedRow()
{
var row = TableConfigRepository.ConvertToAdminTableEntity(new AdminConfiguration
{
UpdatedBy = "ga@operator.example",
GlobalRateLimitRequestsPerMinute = 100,
NvdApiKey = "stored-key",
LatestAgentV2Version = ReleaseVersion,
LatestAgentV2Sha256 = ReleaseZipSha,
LatestAgentV2ExeSha256 = ReleaseExeSha,
});
row["ColumnOfANewerBuild"] = "keep-me";
return row;
}
[Fact]
public async Task Stale_page_toggle_writes_only_that_field_and_keeps_the_release_hashes()
{
var table = new FakeAdminTable(ReleasedRow());
// The page was loaded before the release; it sends only what the operator changed.
var fields = JObject.Parse("{ \"enforceClientAppBinding\": true }");
Assert.Null(PatchAdminConfigurationFunction.CheckFields(fields));
var result = await table.Sut.UpdateAdminConfigurationAsync(
draft => PatchAdminConfigurationFunction.ApplyFields(draft, fields), "ga@operator.example");
Assert.Null(result.Error);
Assert.Equal(new[] { "EnforceClientAppBinding" }, result.ChangedColumns);
var row = table.Current!;
Assert.True(row.GetBoolean("EnforceClientAppBinding"));
Assert.Equal(ReleaseVersion, row.GetString("LatestAgentV2Version"));
Assert.Equal(ReleaseZipSha, row.GetString("LatestAgentV2Sha256"));
Assert.Equal(ReleaseExeSha, row.GetString("LatestAgentV2ExeSha256"));
Assert.Equal("keep-me", row.GetString("ColumnOfANewerBuild"));
Assert.Equal("stored-key", row.GetString("NvdApiKey"));
Assert.Equal(TableUpdateMode.Replace, table.LastMode);
Assert.Equal(1, table.Writes);
}
[Fact]
public async Task A_concurrent_pipeline_write_is_reread_and_kept()
{
var table = new FakeAdminTable(ReleasedRow())
{
// The release pipeline lands between the backend's read and its write.
BeforeFirstReplace = t => t.ExternalMerge("LatestAgentV2Version", "2.0.1466"),
};
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 250; return null; }, "System (test)");
Assert.Null(result.Error);
Assert.Equal("2.0.1466", table.Current!.GetString("LatestAgentV2Version"));
Assert.Equal(250, table.Current.GetInt32("GlobalRateLimitRequestsPerMinute"));
Assert.Equal(1, table.Writes);
}
[Fact]
public async Task A_mutation_that_changes_nothing_writes_nothing()
{
var table = new FakeAdminTable(ReleasedRow());
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 100; return null; }, "ga@operator.example");
Assert.Null(result.Error);
Assert.Empty(result.ChangedColumns);
Assert.Equal(0, table.Writes);
}
[Fact]
public async Task A_rejected_mutation_writes_nothing()
{
var table = new FakeAdminTable(ReleasedRow());
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 999; return "nope"; }, "ga@operator.example");
Assert.Equal("nope", result.Error);
Assert.Equal(0, table.Writes);
Assert.Equal(100, table.Current!.GetInt32("GlobalRateLimitRequestsPerMinute"));
}
[Fact]
public async Task A_null_value_drops_its_column()
{
var table = new FakeAdminTable(ReleasedRow());
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.NvdApiKey = null!; return null; }, "ga@operator.example"); // what a JSON null in a patch does
Assert.Equal(new[] { "NvdApiKey" }, result.ChangedColumns);
Assert.False(table.Current!.ContainsKey("NvdApiKey"));
Assert.Equal(ReleaseExeSha, table.Current.GetString("LatestAgentV2ExeSha256"));
}
[Fact]
public async Task Without_a_row_the_first_change_creates_it()
{
var table = new FakeAdminTable(current: null);
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.EnforceClientAppBinding = true; return null; }, "ga@operator.example");
Assert.Null(result.Error);
Assert.True(table.Current!.GetBoolean("EnforceClientAppBinding"));
Assert.Equal(1, table.Writes);
}
[Fact]
public async Task Default_creation_never_replaces_an_existing_row()
{
var table = new FakeAdminTable(ReleasedRow());
var created = await table.Sut.CreateAdminConfigurationIfMissingAsync(AdminConfiguration.CreateDefault());
Assert.False(created);
Assert.Equal(ReleaseVersion, table.Current!.GetString("LatestAgentV2Version"));
}
[Theory]
[InlineData("latestAgentV2Version")]
[InlineData("LatestAgentV2Sha256")]
[InlineData("latestAgentV2ExeSha256")]
[InlineData("latestBootstrapV2ScriptVersion")]
[InlineData("updatedBy")]
[InlineData("lastUpdated")]
[InlineData("planTierDefinitionsJson")]
[InlineData("msrcLastSyncUtc")]
public void The_gate_rejects_pipeline_stamp_and_flow_owned_fields(string field)
{
var error = PatchAdminConfigurationFunction.CheckFields(new JObject { [field] = "x" });
Assert.NotNull(error);
Assert.Contains("not writable", error);
}
[Fact]
public void The_gate_rejects_unknown_fields_and_redacted_placeholders()
{
Assert.Contains("Unknown field", PatchAdminConfigurationFunction.CheckFields(new JObject { ["noSuchField"] = 1 }));
Assert.Contains("placeholder", PatchAdminConfigurationFunction.CheckFields(
new JObject { ["nvdApiKey"] = Constants.RedactedSecretPlaceholder }));
}
[Fact]
public void Patchable_fields_are_the_persisted_settings()
{
var patchable = PatchAdminConfigurationFunction.PatchableFields;
Assert.Contains("EnforceClientAppBinding", patchable);
Assert.Contains("McpClientRegistrationEnabled", patchable);
Assert.Contains("McpAccessPolicy", patchable);
Assert.Contains("NvdApiKey", patchable);
Assert.Contains("OpsNotificationChannelsJson", patchable);
Assert.DoesNotContain("LatestAgentV2ExeSha256", patchable);
Assert.DoesNotContain("PartitionKey", patchable);
Assert.DoesNotContain("UpdatedBy", patchable);
}
[Fact]
public void Applying_validates_the_result_without_echoing_values()
{
var draft = AdminConfiguration.CreateDefault();
Assert.Contains("at least 1", PatchAdminConfigurationFunction.ApplyFields(
draft, JObject.Parse("{ \"globalRateLimitRequestsPerMinute\": 0 }")));
var typeError = PatchAdminConfigurationFunction.ApplyFields(
AdminConfiguration.CreateDefault(), JObject.Parse("{ \"collectorIdleTimeoutMinutes\": \"https://secret.example/sas?sig=abc\" }"));
Assert.NotNull(typeError);
Assert.DoesNotContain("secret.example", typeError);
}
}
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// for the device path and UserRateLimitMiddleware for the user path). These tests pin the
/// removal of the former background sync job:
/// <list type="bullet">
/// <item>Saving the admin config must NOT enumerate or write ANY tenant configuration.</item>
/// <item>Changing the admin config must NOT enumerate or write ANY tenant configuration.</item>
/// </list>
/// <para>
/// Historical context: the sync used to copy <c>GlobalRateLimitRequestsPerMinute</c> into every
@@ -28,26 +28,33 @@ namespace AutopilotMonitor.Functions.Tests;
public class AdminConfigurationRateLimitSyncTests
{
[Fact]
public async Task SaveConfigurationAsync_DoesNotEnumerateOrWriteTenantConfigs()
public async Task UpdateAsync_DoesNotEnumerateOrWriteTenantConfigs()
{
var repo = new Mock<IConfigRepository>(MockBehavior.Strict);
repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()))
.ReturnsAsync(true);
repo.Setup(r => r.UpdateAdminConfigurationAsync(
It.IsAny<Func<AdminConfiguration, string?>>(), It.IsAny<string>(), It.IsAny<string?>()))
.ReturnsAsync(new AdminConfigurationUpdateResult
{
Before = new AdminConfiguration(),
After = new AdminConfiguration(),
ChangedColumns = new[] { "GlobalRateLimitRequestsPerMinute", "UserRateLimitRequestsPerMinute" },
});
var sut = new AdminConfigurationService(
repo.Object,
NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
await sut.SaveConfigurationAsync(new AdminConfiguration
await sut.UpdateAsync(c =>
{
UpdatedBy = "global-admin@contoso.com",
GlobalRateLimitRequestsPerMinute = 200,
UserRateLimitRequestsPerMinute = 240,
});
c.GlobalRateLimitRequestsPerMinute = 200;
c.UserRateLimitRequestsPerMinute = 240;
return null;
}, "global-admin@contoso.com");
// The admin config row is written...
repo.Verify(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()), Times.Once);
repo.Verify(r => r.UpdateAdminConfigurationAsync(
It.IsAny<Func<AdminConfiguration, string?>>(), "global-admin@contoso.com", It.IsAny<string?>()), Times.Once);
// ...but NO tenant configuration is enumerated or mutated (sync removed).
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
@@ -56,6 +56,13 @@ public class ConfigBackupHookTests
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
Table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
@@ -225,15 +232,11 @@ public class ConfigBackupHookTests
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var incoming = new AdminConfiguration
{
UpdatedBy = "ga@operator.example",
GlobalRateLimitRequestsPerMinute = 200,
};
var result = await harness.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 200; return null; }, "ga@operator.example");
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
Assert.True(saved);
Assert.Null(result.Error);
Assert.Equal(new[] { "GlobalRateLimitRequestsPerMinute" }, result.ChangedColumns);
Assert.NotNull(snapshot);
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
Assert.Equal("admin-config", snapshot.Source);
@@ -61,7 +61,7 @@ public class TypedRequestGuardTests
private static readonly Dictionary<string, int> NewtonsoftBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = 1,
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = 1,
["Functions/Config/PatchAdminConfigurationFunction.cs"] = 1,
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = 1,
["Functions/Rules/AnalyzeRulesFunction.cs"] = 2,
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = 1,
@@ -157,13 +157,21 @@ public class TypedRequestGuardTests
var isObjectMap = slot.IsGenericType && slot.GetGenericTypeDefinition() == typeof(Dictionary<,>) && slot.GetGenericArguments()[1] == typeof(object);
if (slot == typeof(object))
offenders.Add($"{t.Name}.{p.Name}: object slot (TypeScript gets unknown)");
else if (isObjectMap && !(t == typeof(PatchTenantConfigurationFieldsRequest) && p.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields)))
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the tenant-config field patch carries one by design)");
else if (isObjectMap && !IsFieldPatchMap(t, p))
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the config field patches carry one by design)");
}
}
Assert.True(offenders.Count == 0, "Request DTO shape violations:\n " + string.Join("\n ", offenders));
}
/// <summary>
/// The two field patches (tenant and admin configuration) carry a { field: value } map on purpose:
/// each value takes its field's own JSON type and the server gates the keys against the model.
/// </summary>
private static bool IsFieldPatchMap(Type type, PropertyInfo property)
=> (type == typeof(PatchTenantConfigurationFieldsRequest) && property.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields))
|| (type == typeof(PatchAdminConfigurationRequest) && property.Name == nameof(PatchAdminConfigurationRequest.Fields));
private static IEnumerable<(string Relative, string Text)> HandlerSources()
{
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
@@ -327,23 +327,126 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config)
{
await TrySnapshotBeforeSaveAsync(
_adminConfigTableClient, "GlobalConfig", "config",
ConvertFromAdminTableEntity, config, AdminBackupNoiseProperties,
config.UpdatedBy, "admin-config", reason: null);
internal const int AdminUpdateMaxAttempts = 3;
/// <summary>Server stamps: written on every accepted change, never counted as one.</summary>
private static readonly HashSet<string> AdminStampColumns = new(StringComparer.Ordinal)
{
"PartitionKey", "RowKey", "LastUpdated", "UpdatedBy",
};
/// <summary>
/// The one write path of the admin row (D-285). Each attempt reads the raw row fresh with
/// its ETag and materializes it twice — <c>before</c> and the <c>draft</c> the mutation edits —
/// so the copy is exact for every persisted column. The changed columns are computed from
/// the two converter projections and laid onto the freshly read raw entity (a null value
/// drops the column); the conditional replace then writes exactly those plus the stamps.
/// Every other column keeps its stored value: the release pipeline's <c>LatestAgentV2*</c>,
/// columns of a newer build, retired columns. The former full-model replace wrote every
/// column from whatever copy the caller held, so a portal page loaded before an agent
/// release reverted the release's hashes on save (2026-09-25).
/// </summary>
public async Task<AdminConfigurationUpdateResult> UpdateAdminConfigurationAsync(
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
{
if (mutate == null) throw new ArgumentNullException(nameof(mutate));
for (var attempt = 1; attempt <= AdminUpdateMaxAttempts; attempt++)
{
TableEntity? stored = null;
try
{
stored = (await _adminConfigTableClient.GetEntityAsync<TableEntity>("GlobalConfig", "config")).Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// No row yet: the first accepted change creates it from the defaults.
}
var before = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
var draft = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
var error = mutate(draft);
if (error != null)
return new AdminConfigurationUpdateResult { Error = error, Before = before, After = before };
var beforeColumns = ConvertToAdminTableEntity(before);
var draftColumns = ConvertToAdminTableEntity(draft);
var changed = ChangedAdminColumns(beforeColumns, draftColumns);
if (changed.Count == 0)
return new AdminConfigurationUpdateResult { Before = before, After = before };
draft.UpdatedBy = updatedBy;
draft.LastUpdated = DateTime.UtcNow;
await TrySnapshotBeforeSaveAsync(
_adminConfigTableClient, "GlobalConfig", "config",
ConvertFromAdminTableEntity, draft, AdminBackupNoiseProperties,
updatedBy, source ?? "admin-config", reason: null);
try
{
if (stored == null)
{
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(draft));
}
else
{
foreach (var column in changed)
{
var value = draftColumns[column];
if (value == null) stored.Remove(column);
else stored[column] = value;
}
stored["LastUpdated"] = draft.LastUpdated;
stored["UpdatedBy"] = draft.UpdatedBy;
await _adminConfigTableClient.UpdateEntityAsync(stored, stored.ETag, TableUpdateMode.Replace);
}
return new AdminConfigurationUpdateResult { Before = before, After = draft, ChangedColumns = changed };
}
catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 409)
{
// Someone wrote (or created) the row since this attempt's read: re-read and
// re-apply the mutation onto their state instead of overwriting it.
_logger.LogInformation(
"Admin configuration changed concurrently (attempt {Attempt}/{Max}) — re-reading",
attempt, AdminUpdateMaxAttempts);
await Task.Delay(TimeSpan.FromMilliseconds(150));
}
}
throw new InvalidOperationException(
$"Admin configuration update lost {AdminUpdateMaxAttempts} conditional-write races — retry shortly.");
}
/// <summary>
/// Columns whose projected value differs between the two converter outputs. Both sides come
/// from <see cref="ConvertToAdminTableEntity"/>, so value types match and plain equality holds.
/// </summary>
internal static List<string> ChangedAdminColumns(TableEntity before, TableEntity after)
{
var changed = new List<string>();
foreach (var column in after.Keys.Union(before.Keys, StringComparer.Ordinal))
{
if (AdminStampColumns.Contains(column)) continue;
before.TryGetValue(column, out var b);
after.TryGetValue(column, out var a);
if (!Equals(b, a)) changed.Add(column);
}
return changed;
}
public async Task<bool> CreateAdminConfigurationIfMissingAsync(AdminConfiguration config)
{
try
{
var entity = ConvertToAdminTableEntity(config);
await _adminConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(config));
return true;
}
catch (Exception ex)
catch (RequestFailedException ex) when (ex.Status == 409)
{
_logger.LogError(ex, "Error saving admin configuration");
return false;
return false; // A row exists — never replace it with defaults.
}
}
@@ -931,10 +1034,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
// Per-line agent binary integrity (written by build scripts via Merge).
// Per-line agent binary integrity: written by the release pipeline via Merge.
// The backend never changes them (the PATCH gate rejects them), so the
// changed-columns write (UpdateAdminConfigurationAsync) never touches them.
// V2 is the only wired line; future V3 = add field set here. Retired columns
// (V1-suffix and the even older unsuffixed "LatestAgent*") are never written,
// so the next Save evicts them implicitly on overwrite.
// (V1-suffix and the even older unsuffixed "LatestAgent*") are not projected.
{ "AllowAgentDowngrade", config.AllowAgentDowngrade },
{ "LatestAgentV2Version", config.LatestAgentV2Version ?? string.Empty },
{ "LatestAgentV2Sha256", config.LatestAgentV2Sha256 ?? string.Empty },
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions.Config
{
/// <summary>
/// PATCH global/config — the portal's only write to the platform configuration (D-285). The body
/// names just the fields the operator changed; the repository re-reads the row, applies them and
/// writes only the columns whose value actually changed. It replaced a full-model PUT that wrote
/// every column from the page's copy, so a page loaded before an agent release reverted the
/// release's hashes on save (2026-09-25).
/// </summary>
public class PatchAdminConfigurationFunction
{
private const int MaxBodyBytes = 1_048_576;
private readonly ILogger<PatchAdminConfigurationFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
private readonly IMaintenanceRepository _maintenanceRepo;
public PatchAdminConfigurationFunction(
ILogger<PatchAdminConfigurationFunction> logger,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_adminConfigService = adminConfigService;
_maintenanceRepo = maintenanceRepo;
}
/// <summary>
/// Fields no patch may write: the server stamps, the columns only the agent release pipeline
/// writes (build-agent.yml / publish-scripts.yml), and the ones owned by their own flow
/// (plan-tier endpoint, vulnerability-sync bookkeeping).
/// </summary>
internal static readonly HashSet<string> DeniedFields = new(StringComparer.OrdinalIgnoreCase)
{
"LastUpdated", "UpdatedBy",
"LatestAgentV2Version", "LatestAgentV2Sha256", "LatestAgentV2ExeSha256", "LatestBootstrapV2ScriptVersion",
"PlanTierDefinitionsJson",
"VulnerabilityDataLastSyncUtc", "MsrcLastSyncUtc", "NvdCacheLastRefreshUtc", "EpssLastSyncUtc",
};
/// <summary>
/// Every persisted, writable AdminConfiguration property except <see cref="DeniedFields"/>.
/// Derived from the entity converter, so a new column is patchable the moment it is persisted
/// and a model property that is never stored cannot be "saved" into nothing.
/// </summary>
internal static readonly IReadOnlyCollection<string> PatchableFields = BuildPatchableFields();
private static HashSet<string> BuildPatchableFields()
{
var writable = new HashSet<string>(
typeof(AdminConfiguration)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
.Select(p => p.Name),
StringComparer.OrdinalIgnoreCase);
return new HashSet<string>(
TableConfigRepository.ConvertToAdminTableEntity(AdminConfiguration.CreateDefault()).Keys
.Where(c => c != "PartitionKey" && c != "RowKey" && writable.Contains(c) && !DeniedFields.Contains(c)),
StringComparer.OrdinalIgnoreCase);
}
[Function("PatchAdminConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/config")] HttpRequestData req)
{
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Newtonsoft on purpose: the field map is applied with JsonConvert.PopulateObject, like the
// tenant field patch (TypedRequestGuardTests baseline).
var read = await req.ReadNewtonsoftAsync<PatchAdminConfigurationRequest>(MaxBodyBytes);
if (read.Error != null) return read.Error;
var request = read.Value!;
if (request.Fields == null || request.Fields.Count == 0)
{
return await req.BadRequestAsync(
"Body must be { \"fields\": { <fieldName>: <value>, ... } } with at least one field.");
}
var fields = JObject.FromObject(request.Fields);
var gateError = CheckFields(fields);
if (gateError != null) return await req.BadRequestAsync(gateError);
var result = await _adminConfigService.UpdateAsync(
draft => ApplyFields(draft, fields), userIdentifier, "portal");
if (result.Error != null) return await req.BadRequestAsync(result.Error);
_logger.LogInformation(
"PatchAdminConfiguration by {User}: {Count} field(s) sent, changed: {Columns}",
userIdentifier, fields.Count, string.Join(", ", result.ChangedColumns));
if (result.ChangedColumns.Count > 0)
{
await _maintenanceRepo.LogAuditEntryAsync(
Constants.AuditGlobalTenantId,
"UPDATE",
"AdminConfiguration",
"GlobalConfig",
userIdentifier,
ConfigDiffHelper.GetChanges(result.Before, result.After));
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
{
Success = true,
Message = result.ChangedColumns.Count > 0
? "Admin configuration updated successfully"
: "No changes — nothing was written",
Config = result.After,
});
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "PatchAdminConfiguration");
}
}
/// <summary>
/// The field gate, before any storage work: every key must be a patchable field, named in the
/// error when it is not so the caller can correct itself.
/// </summary>
internal static string? CheckFields(JObject fields)
{
foreach (var prop in fields.Properties())
{
if (DeniedFields.Contains(prop.Name))
{
return $"Field \"{prop.Name}\" is not writable here (server stamp, owned by the agent release " +
"pipeline, or written by its own flow).";
}
if (!PatchableFields.Contains(prop.Name))
return $"Unknown field \"{prop.Name}\".";
// The ops channel list carries per-channel redacted destinations that are restored below;
// anywhere else the placeholder would overwrite a real secret with the sentinel.
if (!prop.Name.Equals(nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)
&& prop.Value is JValue { Type: JTokenType.String, Value: string text }
&& text.Contains(Constants.RedactedSecretPlaceholder, StringComparison.Ordinal))
{
return $"Field \"{prop.Name}\" carries the \"{Constants.RedactedSecretPlaceholder}\" placeholder — " +
"a redacted read must never be written back.";
}
}
return null;
}
/// <summary>
/// Applies the gated fields onto the repository's fresh draft and validates the result. JSON
/// null clears a nullable field; camelCase keys bind case-insensitively to the model.
/// </summary>
internal static string? ApplyFields(AdminConfiguration draft, JObject fields)
{
var storedChannels = draft.OpsNotificationChannelsJson;
try
{
JsonConvert.PopulateObject(fields.ToString(), draft, new JsonSerializerSettings
{
MissingMemberHandling = MissingMemberHandling.Error,
});
}
catch (JsonException ex)
{
// Never ex.Message: Newtonsoft quotes the offending VALUE, which may be a secret.
var path = (ex as JsonSerializationException)?.Path ?? (ex as JsonReaderException)?.Path;
return string.IsNullOrEmpty(path)
? "A value has the wrong JSON type for its field."
: $"The value for \"{path}\" has the wrong JSON type.";
}
// Rate limits must be positive: a zero/negative value would throttle every request
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
var rateLimitError =
draft.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
draft.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
draft.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
draft.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
null;
if (rateLimitError != null)
return $"{rateLimitError} must be at least 1 request per minute.";
if (fields.Properties().Any(p => p.Name.Equals(
nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)))
{
// A GlobalReader is served destinations as the ***REDACTED*** sentinel; persisting it as a
// chat ID or webhook URL would silently break every alert, so restore per channel id.
draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
draft.OpsNotificationChannelsJson, storedChannels);
// Same structural gate the tenant channel list passes. No GA gate on Telegram here:
// this endpoint is GlobalAdminOnly by policy.
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson);
if (channelsError != null)
return $"Invalid ops notification channels: {channelsError}";
}
return null;
}
}
}
@@ -602,9 +602,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
foreach (var tier in body.Tiers)
tier.Name = tier.Name.ToLowerInvariant();
var config = await _adminConfigService.GetConfigurationAsync();
config.PlanTierDefinitionsJson = JsonSerializer.Serialize(body.Tiers);
await _adminConfigService.SaveConfigurationAsync(config);
var tiersJson = JsonSerializer.Serialize(body.Tiers);
await _adminConfigService.UpdateAsync(
c => { c.PlanTierDefinitionsJson = tiersJson; return null; },
TenantHelper.GetUserIdentifier(req), "plan-tiers");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = body.Tiers, Catalog = BuildUsagePlanCatalog() });
@@ -1,118 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateAdminConfigurationFunction
{
private readonly ILogger<UpdateAdminConfigurationFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
private readonly IMaintenanceRepository _maintenanceRepo;
public UpdateAdminConfigurationFunction(
ILogger<UpdateAdminConfigurationFunction> logger,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_adminConfigService = adminConfigService;
_maintenanceRepo = maintenanceRepo;
}
[Function("UpdateAdminConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "global/config")] HttpRequestData req)
{
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"UpdateAdminConfiguration by Global Admin user {userIdentifier}");
// Parse request body
// Newtonsoft on purpose: same configuration stack as the tenant PUT — TypedRequestGuardTests baseline.
var read = await req.ReadNewtonsoftAsync<AdminConfiguration>(1_048_576);
if (read.Error != null) return read.Error;
var config = read.Value!;
// Rate limits must be positive: a zero/negative value would throttle every request
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
var rateLimitError =
config.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
config.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
config.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
config.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
null;
if (rateLimitError != null)
{
return await req.BadRequestAsync($"{rateLimitError} must be at least 1 request per minute.");
}
// Set the actual user identifier for audit logging
config.UpdatedBy = userIdentifier;
// Ensure PartitionKey and RowKey are correct
config.PartitionKey = "GlobalConfig";
config.RowKey = "config";
// Load existing config for diff before saving
var existingConfig = await _adminConfigService.GetConfigurationAsync();
// Ops channels: a GlobalReader is served a redacted copy (destinations replaced
// with the ***REDACTED*** sentinel). Only a GA can reach this endpoint, so this is
// defense-in-depth — but persisting the literal sentinel as a chat ID or webhook
// URL would silently break every alert, so restore per channel id first.
config.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
config.OpsNotificationChannelsJson, existingConfig.OpsNotificationChannelsJson);
// Same structural gate the tenant channel list passes (ids, provider types,
// SSRF-checked URLs / Telegram chat IDs, header and signing-secret shape). No GA
// gate on Telegram here: this endpoint is GlobalAdminOnly by policy.
var channelsError = TenantConfigValidation.ValidateNotificationChannels(config.OpsNotificationChannelsJson);
if (channelsError != null)
{
return await req.BadRequestAsync($"Invalid ops notification channels: {channelsError}");
}
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
// Save configuration
await _adminConfigService.SaveConfigurationAsync(config);
await _maintenanceRepo.LogAuditEntryAsync(
AutopilotMonitor.Shared.Constants.AuditGlobalTenantId,
"UPDATE",
"AdminConfiguration",
"GlobalConfig",
userIdentifier,
changes.Count > 0 ? changes : null
);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
{
Success = true,
Message = "Admin configuration updated successfully",
Config = config
});
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "UpdateAdminConfiguration");
}
}
}
}
@@ -49,12 +49,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
if (string.IsNullOrEmpty(msrc.Error))
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.MsrcLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
}
var syncedAt = DateTime.UtcNow.ToString("o");
await _adminConfigService.UpdateAsync(
c => { c.MsrcLastSyncUtc = syncedAt; return null; },
"System (MSRC sync)", "vuln-sync");
}
var durationMs = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
@@ -97,12 +97,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
}
// Update sync timestamp
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.VulnerabilityDataLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
}
var syncedAt = DateTime.UtcNow.ToString("o");
await _adminConfigService.UpdateAsync(
c => { c.VulnerabilityDataLastSyncUtc = syncedAt; return null; },
"System (vulnerability sync)", "vuln-sync");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new TriggerVulnerabilityDataSyncResponse
@@ -117,22 +117,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// "Last KEV/MSRC Sync" accurately. (NVD/EPSS stamps are written by the service.)
try
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
if (kevSyncedAt != null || msrcSyncedAt != null)
{
bool dirty = false;
if (kevSyncedAt != null)
await _adminConfigService.UpdateAsync(c =>
{
config.VulnerabilityDataLastSyncUtc = kevSyncedAt;
dirty = true;
}
if (msrcSyncedAt != null)
{
config.MsrcLastSyncUtc = msrcSyncedAt;
dirty = true;
}
if (dirty)
await _adminConfigService.SaveConfigurationAsync(config);
if (kevSyncedAt != null) c.VulnerabilityDataLastSyncUtc = kevSyncedAt;
if (msrcSyncedAt != null) c.MsrcLastSyncUtc = msrcSyncedAt;
return null;
}, "System (vulnerability sync)", "vuln-sync");
}
}
catch (Exception ex)
@@ -479,8 +479,8 @@ public static class EndpointAccessPolicyCatalog
// ?tenantId= path — never the no-tenantId aggregate (which the delegated-rescue cannot satisfy).
new("GET", "global/metrics/summary", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
// Changed fields only (D-285); the former full-model PUT/POST is gone.
new("PATCH", "global/config", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/config/test-ops-channel", EndpointPolicy.GlobalAdminOnly),
new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset),
// Transactional field-level config writes + snapshot list/revert (MCP + web Settings).
@@ -78,8 +78,8 @@ namespace AutopilotMonitor.Functions.Services
try
{
// Save the default configuration via repository
await _configRepo.SaveAdminConfigurationAsync(defaultConfig);
// Create-only: a concurrent first write (or a row this read missed) is never replaced.
await _configRepo.CreateAdminConfigurationIfMissingAsync(defaultConfig);
_cache.Set(CacheKey, defaultConfig, CacheDuration);
@@ -191,38 +191,24 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Saves global admin configuration and syncs rate limit to all tenant configurations
/// Changes the admin configuration through the repository's changed-columns write (D-285):
/// fresh read, <paramref name="mutate"/> on a copy, only the columns it changed are written.
/// Never pass a configuration object around to save it — a cached or page-loaded copy is
/// exactly what used to revert other writers' changes. Invalidates this instance's cache
/// after a write; other instances catch up within the 5-minute TTL.
/// </summary>
public async Task SaveConfigurationAsync(AdminConfiguration config)
public virtual async Task<AdminConfigurationUpdateResult> UpdateAsync(
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
{
if (config == null)
var result = await _configRepo.UpdateAdminConfigurationAsync(mutate, updatedBy, source);
if (result.Error == null && result.ChangedColumns.Count > 0)
{
throw new ArgumentException("Configuration is required");
}
try
{
config.LastUpdated = DateTime.UtcNow;
await _configRepo.SaveAdminConfigurationAsync(config);
// Invalidate cache
_cache.Remove(CacheKey);
_logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}");
// NOTE: Rate limits are no longer mirrored into every tenant row. The effective
// per-tenant limit is computed at read time as `tenantOverride ?? global`
// (see SecurityValidator for the device path and UserRateLimitMiddleware for the
// user path). This removes the former background sync job — and the clobbering
// foot-gun where a per-tenant edit to the base field was overwritten on the next
// global save.
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving admin configuration");
throw;
_logger.LogInformation(
"Admin configuration changed by {UpdatedBy}: {Columns}",
updatedBy, string.Join(", ", result.ChangedColumns));
}
return result;
}
/// <summary>
@@ -281,7 +281,7 @@ namespace AutopilotMonitor.Functions.Services
// Tenants come from TenantConfiguration (1 row per tenant — cheap).
// PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId)
// where platform-action audits are written from TenantOffboardFunction,
// VersionBlockFunction, UpdateAdminConfigurationFunction, … TenantConfiguration
// VersionBlockFunction, PatchAdminConfigurationFunction, … TenantConfiguration
// has no row for it because it's a virtual partition, so it'd be
// silently skipped without this explicit add.
var tenantIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
@@ -314,10 +314,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{
try
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config == null) return;
set(config);
await _adminConfigService.SaveConfigurationAsync(config);
await _adminConfigService.UpdateAsync(
c => { set(c); return null; }, "System (vulnerability sync)", "vuln-sync");
}
catch (Exception ex)
{
@@ -3029,6 +3029,11 @@ export interface OpsEventListResponse {
nextLink?: string;
}
/** Body of PATCH global/config: { "fields": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */
export interface PatchAdminConfigurationRequest {
fields?: Record<string, unknown>;
}
/** Body of PATCH config/{tenantId}/fields: { "fields": { <fieldName>: <value>, ... }, "reason"?: "..." } with at least one field. Values take the field's own JSON type (TenantConfiguration). */
export interface PatchTenantConfigurationFieldsRequest {
fields?: Record<string, unknown>;
@@ -5291,7 +5296,7 @@ export interface UnmatchedSoftwareItem {
normalizedVersion?: string;
}
/** Response of PUT global/config: acknowledgement plus the stored admin configuration. */
/** Response of PATCH global/config: acknowledgement plus the stored admin configuration. */
export interface UpdateAdminConfigurationResponse {
success: boolean;
message: string;
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Outcome of <see cref="IConfigRepository.UpdateAdminConfigurationAsync"/>: either the mutation
/// rejected the change (<see cref="Error"/> set, nothing written) or it was accepted, in which case
/// <see cref="ChangedColumns"/> names the columns actually written (empty = no-op, no write).
/// </summary>
public sealed class AdminConfigurationUpdateResult
{
/// <summary>The mutation's rejection message; null when the change was accepted.</summary>
public string? Error { get; set; }
/// <summary>The stored configuration the mutation started from (fresh read).</summary>
public AdminConfiguration Before { get; set; } = default!;
/// <summary>The configuration after the mutation — what the row holds now when accepted.</summary>
public AdminConfiguration After { get; set; } = default!;
/// <summary>Columns written, server stamps excluded. Empty when the mutation changed nothing.</summary>
public IReadOnlyCollection<string> ChangedColumns { get; set; } = Array.Empty<string>();
}
}
@@ -66,7 +66,24 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- Admin Configuration ---
Task<AdminConfiguration?> GetAdminConfigurationAsync();
Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config);
/// <summary>
/// The one write path of the admin configuration row. It reads the row fresh, runs
/// <paramref name="mutate"/> on a copy and writes ONLY the columns whose value the mutation
/// changed, conditionally on the row's ETag (re-read and retry on a concurrent write).
/// Every other column keeps its stored value, including the ones only the agent release
/// pipeline writes (<c>LatestAgentV2*</c>) and columns this build does not know. A mutation
/// that changes nothing writes nothing. <paramref name="mutate"/> returns an error message
/// to reject the change (nothing is written) or null to accept it. Storage failures throw.
/// </summary>
Task<AdminConfigurationUpdateResult> UpdateAdminConfigurationAsync(
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null);
/// <summary>
/// Creates the row from <paramref name="config"/> when none exists yet; never replaces an
/// existing row. True when this call created it.
/// </summary>
Task<bool> CreateAdminConfigurationIfMissingAsync(AdminConfiguration config);
// --- Preview Whitelist ---
Task<bool> IsInPreviewWhitelistAsync(string tenantId);
@@ -320,7 +320,7 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// Response of PUT global/config: acknowledgement plus the stored admin configuration.
/// Response of PATCH global/config: acknowledgement plus the stored admin configuration.
/// </summary>
// Declaration order == wire order.
public class UpdateAdminConfigurationResponse : IApiResponse
@@ -360,6 +360,16 @@ namespace AutopilotMonitor.Shared.Models
public string? Reason { get; set; }
}
/// <summary>
/// Body of PATCH global/config: <c>{ "fields": { &lt;fieldName&gt;: &lt;value&gt;, ... } }</c> with at least one
/// field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take
/// the field's own JSON type.
/// </summary>
public class PatchAdminConfigurationRequest : IApiRequest
{
public Dictionary<string, object>? Fields { get; set; }
}
/// <summary>Body of POST config/{tenantId}/revert.</summary>
public class RevertTenantConfigurationRequest : IApiRequest
{
@@ -14239,6 +14239,23 @@
}
]
},
"PatchAdminConfigurationRequest": {
"kind": "object",
"doc": "Body of PATCH global/config: { \"fields\": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type.",
"fields": [
{
"name": "fields",
"type": {
"kind": "map",
"value": {
"kind": "primitive",
"name": "unknown"
}
},
"optional": true
}
]
},
"PatchTenantConfigurationFieldsRequest": {
"kind": "object",
"doc": "Body of PATCH config/{tenantId}/fields: { \"fields\": { <fieldName>: <value>, ... }, \"reason\"?: \"...\" } with at least one field. Values take the field's own JSON type (TenantConfiguration).",
@@ -24121,7 +24138,7 @@
},
"UpdateAdminConfigurationResponse": {
"kind": "object",
"doc": "Response of PUT global/config: acknowledgement plus the stored admin configuration.",
"doc": "Response of PATCH global/config: acknowledgement plus the stored admin configuration.",
"fields": [
{
"name": "success",
@@ -3029,6 +3029,11 @@ export interface OpsEventListResponse {
nextLink?: string;
}
/** Body of PATCH global/config: { "fields": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */
export interface PatchAdminConfigurationRequest {
fields?: Record<string, unknown>;
}
/** Body of PATCH config/{tenantId}/fields: { "fields": { <fieldName>: <value>, ... }, "reason"?: "..." } with at least one field. Values take the field's own JSON type (TenantConfiguration). */
export interface PatchTenantConfigurationFieldsRequest {
fields?: Record<string, unknown>;
@@ -5291,7 +5296,7 @@ export interface UnmatchedSoftwareItem {
normalizedVersion?: string;
}
/** Response of PUT global/config: acknowledgement plus the stored admin configuration. */
/** Response of PATCH global/config: acknowledgement plus the stored admin configuration. */
export interface UpdateAdminConfigurationResponse {
success: boolean;
message: string;