fix(management): elide ExternalSystem AuthConfiguration from responses and audit (arch-review C3)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -156,6 +156,8 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
|
|||||||
- **ListExternalSystems** / **GetExternalSystem**: Query external system definitions.
|
- **ListExternalSystems** / **GetExternalSystem**: Query external system definitions.
|
||||||
- **CreateExternalSystem** / **UpdateExternalSystem** / **DeleteExternalSystem**: Manage external system definitions.
|
- **CreateExternalSystem** / **UpdateExternalSystem** / **DeleteExternalSystem**: Manage external system definitions.
|
||||||
|
|
||||||
|
> **Secret elision (arch-review C3).** The `AuthConfiguration` blob holds API keys / Basic credentials. All command responses **and** audit `afterState` are projected through `ExternalSystemPublicShape`, which drops `AuthConfiguration` entirely and surfaces only a `hasAuthConfiguration` presence flag. **Update** is preserve-if-null: an omitted (`null`) `AuthConfiguration` leaves the stored secret intact (mirrors the SMTP/SMS credential rule).
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
- **ListNotificationLists** / **GetNotificationList**: Query notification lists.
|
- **ListNotificationLists** / **GetNotificationList**: Query notification lists.
|
||||||
|
|||||||
@@ -1661,16 +1661,36 @@ public class ManagementActor : ReceiveActor
|
|||||||
// External System handlers
|
// External System handlers
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Secret-elided ExternalSystemDefinition projection (arch-review C3): the
|
||||||
|
/// opaque <c>AuthConfiguration</c> blob holds API keys / Basic credentials and
|
||||||
|
/// must never leave this boundary via responses or audit afterState. Presence
|
||||||
|
/// is surfaced as <c>HasAuthConfiguration</c>; every non-secret field is
|
||||||
|
/// projected verbatim. Mirrors <see cref="SmtpConfigPublicShape"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static object ExternalSystemPublicShape(ExternalSystemDefinition d) => new
|
||||||
|
{
|
||||||
|
d.Id,
|
||||||
|
d.Name,
|
||||||
|
d.EndpointUrl,
|
||||||
|
d.AuthType,
|
||||||
|
d.MaxRetries,
|
||||||
|
d.RetryDelay,
|
||||||
|
d.TimeoutSeconds,
|
||||||
|
HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration),
|
||||||
|
};
|
||||||
|
|
||||||
private static async Task<object?> HandleListExternalSystems(IServiceProvider sp)
|
private static async Task<object?> HandleListExternalSystems(IServiceProvider sp)
|
||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||||
return await repo.GetAllExternalSystemsAsync();
|
return (await repo.GetAllExternalSystemsAsync()).Select(ExternalSystemPublicShape).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd)
|
private static async Task<object?> HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd)
|
||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||||
return await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
|
var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
|
||||||
|
return def is null ? null : ExternalSystemPublicShape(def);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user)
|
private static async Task<object?> HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user)
|
||||||
@@ -1683,8 +1703,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
};
|
};
|
||||||
await repo.AddExternalSystemAsync(def);
|
await repo.AddExternalSystemAsync(def);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, def);
|
var publicShape = ExternalSystemPublicShape(def);
|
||||||
return def;
|
await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
|
||||||
|
return publicShape;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user)
|
private static async Task<object?> HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user)
|
||||||
@@ -1695,12 +1716,15 @@ public class ManagementActor : ReceiveActor
|
|||||||
def.Name = cmd.Name;
|
def.Name = cmd.Name;
|
||||||
def.EndpointUrl = cmd.EndpointUrl;
|
def.EndpointUrl = cmd.EndpointUrl;
|
||||||
def.AuthType = cmd.AuthType;
|
def.AuthType = cmd.AuthType;
|
||||||
def.AuthConfiguration = cmd.AuthConfiguration;
|
// Preserve-if-null: an update that omits AuthConfiguration leaves the stored
|
||||||
|
// secret intact (mirrors the SMTP/SMS credential preserve-if-null rule).
|
||||||
|
if (cmd.AuthConfiguration is not null) def.AuthConfiguration = cmd.AuthConfiguration;
|
||||||
if (cmd.TimeoutSeconds is { } ts) def.TimeoutSeconds = ts;
|
if (cmd.TimeoutSeconds is { } ts) def.TimeoutSeconds = ts;
|
||||||
await repo.UpdateExternalSystemAsync(def);
|
await repo.UpdateExternalSystemAsync(def);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, def);
|
var publicShape = ExternalSystemPublicShape(def);
|
||||||
return def;
|
await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
|
||||||
|
return publicShape;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user)
|
private static async Task<object?> HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user)
|
||||||
|
|||||||
@@ -131,4 +131,91 @@ public class SecretProjectionTests : TestKit, IDisposable
|
|||||||
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newpw")),
|
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newpw")),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Task 8 — ExternalSystem AuthConfiguration elision
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private IExternalSystemRepository AddExternalSystemRepo(ExternalSystemDefinition def)
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IExternalSystemRepository>();
|
||||||
|
repo.GetExternalSystemByIdAsync(def.Id, Arg.Any<CancellationToken>()).Returns(def);
|
||||||
|
repo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<ExternalSystemDefinition> { def });
|
||||||
|
_services.AddScoped(_ => repo);
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetExternalSystem_ResponseOmitsAuthConfiguration()
|
||||||
|
{
|
||||||
|
AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = """{"apiKey":"es-secret"}""",
|
||||||
|
});
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new GetExternalSystemCommand(1), "Viewer"));
|
||||||
|
|
||||||
|
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.DoesNotContain("es-secret", resp.JsonData);
|
||||||
|
Assert.Contains("hasAuthConfiguration", resp.JsonData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListExternalSystems_ResponseOmitsAuthConfiguration()
|
||||||
|
{
|
||||||
|
AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = """{"apiKey":"es-secret"}""",
|
||||||
|
});
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new ListExternalSystemsCommand(), "Viewer"));
|
||||||
|
|
||||||
|
var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.DoesNotContain("es-secret", resp.JsonData);
|
||||||
|
Assert.Contains("hasAuthConfiguration", resp.JsonData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateExternalSystem_NullAuthConfiguration_PreservesStoredSecret()
|
||||||
|
{
|
||||||
|
var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = """{"apiKey":"es-secret"}""",
|
||||||
|
};
|
||||||
|
var repo = AddExternalSystemRepo(stored);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", null), "Designer"));
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
await repo.Received(1).UpdateExternalSystemAsync(
|
||||||
|
Arg.Is<ExternalSystemDefinition>(d => d.AuthConfiguration == """{"apiKey":"es-secret"}"""),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateExternalSystem_AuditAfterState_OmitsAuthConfiguration()
|
||||||
|
{
|
||||||
|
var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = """{"apiKey":"es-secret"}""",
|
||||||
|
};
|
||||||
|
AddExternalSystemRepo(stored);
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", """{"apiKey":"newsecret"}"""), "Designer"));
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
_auditService.Received().LogAsync(Arg.Any<string>(), "Update", "ExternalSystem",
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newsecret")),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user