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:
Joseph Doherty
2026-06-18 10:58:42 -04:00
parent 6c853b43af
commit 2671639250
10 changed files with 718 additions and 30 deletions
@@ -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