fix(gateway): resolve 2026-06-18 array-write review findings
- Server-057: extend []-suffix normalization to AddItemBulk/AddBufferedItem so bulk-added array tags bind write-capable handles (authz check, worker bind, and registration kept consistent); update gateway.md + client READMEs. Tests: AddItemBulk/AddBufferedItem wiring. - Server-058: assert []-fallback-resolved bare array names are still denied when out of read/write scope and that MaxWriteClassification is enforced on suffixed array registrations. - Contracts-023/024/025: round-trip + field-19 descriptor pin for MxSparseArray; document MxSparseArray in docs/Contracts.md; enumerate it in the protocol-version-3 test summary. - Tests-040: add wiring tests for the six uncovered sparse-write arms (WriteSecured, Write2, WriteSecured2, Write2Bulk, WriteSecuredBulk, WriteSecured2Bulk). dotnet build + targeted tests green (184 passed).
This commit is contained in:
@@ -263,6 +263,116 @@ public sealed class ConstraintEnforcerTests
|
||||
Assert.Equal("tag_metadata", unknown.ConstraintName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>[]</c>-suffix fallback widened *resolution* of a bare array name, not *authorization*:
|
||||
/// a bare array attribute that resolves through the fallback but is outside the key's read scope
|
||||
/// must still be denied with a <c>read_scope</c> failure (Server-058).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithBareArrayName_OutOfScope_StillDeniedReadScope()
|
||||
{
|
||||
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||
{
|
||||
// Scope covers a different object, so the resolved array attribute is out of scope.
|
||||
ReadTagGlobs = ["Other_001.*"],
|
||||
});
|
||||
|
||||
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
|
||||
identity,
|
||||
"Pump_001.Levels",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(failure);
|
||||
Assert.Equal("read_scope", failure.ConstraintName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write against an array handle whose registration carries the suffixed form ("Pump_001.Levels[]")
|
||||
/// resolves through <c>ResolveTarget</c> and is denied with a <c>write_scope</c> failure when the
|
||||
/// array attribute is outside the key's write scope (Server-058).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CheckWriteHandleAsync_WithSuffixedArrayRegistration_OutOfScope_StillDeniedWriteScope()
|
||||
{
|
||||
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||
{
|
||||
WriteSubtrees = ["Other/*"],
|
||||
});
|
||||
GatewaySession session = CreateSession();
|
||||
session.TrackCommandReply(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = 12,
|
||||
// The worker-bound, suffixed array address is what lands in the registration.
|
||||
ItemDefinition = "Pump_001.Levels[]",
|
||||
},
|
||||
},
|
||||
new MxCommandReply
|
||||
{
|
||||
ProtocolStatus = ZB.MOM.WW.MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(),
|
||||
AddItem = new AddItemReply { ItemHandle = 70 },
|
||||
});
|
||||
|
||||
ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
serverHandle: 12,
|
||||
itemHandle: 70,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(failure);
|
||||
Assert.Equal("write_scope", failure.ConstraintName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write against an in-scope array handle whose registration carries the suffixed form is still
|
||||
/// denied when the array attribute's <c>SecurityClassification</c> exceeds the key's
|
||||
/// <c>MaxWriteClassification</c>, resolved through <c>ResolveTarget</c> via the suffixed address
|
||||
/// (Server-058).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CheckWriteHandleAsync_WithSuffixedArrayRegistration_ClassificationTooHigh_StillDenied()
|
||||
{
|
||||
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||
{
|
||||
// In scope for the array attribute, but the array's classification (2) exceeds the cap (1).
|
||||
WriteTagGlobs = ["Pump_001.*"],
|
||||
MaxWriteClassification = 1,
|
||||
});
|
||||
GatewaySession session = CreateSession();
|
||||
session.TrackCommandReply(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = 12,
|
||||
ItemDefinition = "Pump_001.Setpoints[]",
|
||||
},
|
||||
},
|
||||
new MxCommandReply
|
||||
{
|
||||
ProtocolStatus = ZB.MOM.WW.MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(),
|
||||
AddItem = new AddItemReply { ItemHandle = 71 },
|
||||
});
|
||||
|
||||
ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
serverHandle: 12,
|
||||
itemHandle: 71,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(failure);
|
||||
Assert.Equal("max_write_classification", failure.ConstraintName);
|
||||
}
|
||||
|
||||
private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
|
||||
{
|
||||
auditWriter = new FakeAuditWriter();
|
||||
@@ -340,6 +450,16 @@ public sealed class ConstraintEnforcerTests
|
||||
FullTagReference = "Pump_001.Levels[]",
|
||||
IsArray = true,
|
||||
},
|
||||
new GalaxyAttribute
|
||||
{
|
||||
// A second array attribute carrying a non-zero security classification so
|
||||
// the MaxWriteClassification check can be exercised via a suffixed
|
||||
// registration address resolving through ResolveTarget.
|
||||
AttributeName = "Setpoints",
|
||||
FullTagReference = "Pump_001.Setpoints[]",
|
||||
IsArray = true,
|
||||
SecurityClassification = 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
new GalaxyObject
|
||||
|
||||
Reference in New Issue
Block a user