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
@@ -987,6 +987,19 @@ public sealed class GatewaySession
break;
case MxCommand.PayloadOneofCase.AddItem2:
command.AddItem2.ItemDefinition = NormalizeAddress(command.AddItem2.ItemDefinition);
break;
case MxCommand.PayloadOneofCase.AddBufferedItem:
command.AddBufferedItem.ItemDefinition = NormalizeAddress(command.AddBufferedItem.ItemDefinition);
break;
case MxCommand.PayloadOneofCase.AddItemBulk:
// Normalize each bare array address in place so the worker binds a write-capable handle
// for every array tag in the batch (the same IsArray-gated rewrite the single-add path
// applies). Scalar addresses pass through unchanged.
for (int i = 0; i < command.AddItemBulk.TagAddresses.Count; i++)
{
command.AddItemBulk.TagAddresses[i] = NormalizeAddress(command.AddItemBulk.TagAddresses[i]);
}
break;
case MxCommand.PayloadOneofCase.Write:
ExpandValue(command.Write.Value);
@@ -1089,9 +1102,16 @@ public sealed class GatewaySession
TrackItem(command.AddItem2.ServerHandle, reply.AddItem2.ItemHandle, NormalizeAddress(command.AddItem2.ItemDefinition));
break;
case MxCommandKind.AddBufferedItem when reply.AddBufferedItem is not null:
TrackItem(command.AddBufferedItem.ServerHandle, reply.AddBufferedItem.ItemHandle, command.AddBufferedItem.ItemDefinition);
// The reply carries no address, so tracking keys off the command's ItemDefinition;
// re-apply the array-suffix normalization (the tracking copy is a separate, un-mutated
// instance from the one forwarded at the InvokeAsync choke point) so the registration
// matches the write-capable handle the worker bound.
TrackItem(command.AddBufferedItem.ServerHandle, reply.AddBufferedItem.ItemHandle, NormalizeAddress(command.AddBufferedItem.ItemDefinition));
break;
case MxCommandKind.AddItemBulk when reply.AddItemBulk is not null:
// The worker echoes back the (already-normalized) address it bound in each
// SubscribeResult.TagAddress, so TrackBulkItems stores the suffixed array address
// without re-normalizing here.
TrackBulkItems(reply.AddItemBulk);
break;
case MxCommandKind.SubscribeBulk when reply.SubscribeBulk is not null:
@@ -13,15 +13,18 @@ public sealed class GatewayContractInfoTests
/// <summary>
/// Pins the current <see cref="GatewayContractInfo.GatewayProtocolVersion"/>
/// constant at 3. Both the alarm proto extension (`AcknowledgeAlarm` /
/// `QueryActiveAlarms` RPCs, the `OnAlarmTransitionEvent` body, and the
/// alarm command/reply payload cases) and the bulk write/read command
/// family extension (`WriteBulk` / `Write2Bulk` / `WriteSecuredBulk` /
/// `WriteSecured2Bulk` / `ReadBulk` plus their `BulkWriteReply` and
/// `BulkReadReply` payloads) shipped under version 3 — both were strictly
/// additive contract changes, so neither required a further bump. A
/// future breaking contract change should bump this constant and update
/// this test in lock-step.
/// constant at 3. All three of the following extensions shipped under
/// version 3 as strictly additive contract changes that did not require a
/// further bump: (1) the alarm proto extension (<c>AcknowledgeAlarm</c> /
/// <c>QueryActiveAlarms</c> RPCs, the <c>OnAlarmTransitionEvent</c> body,
/// and the alarm command/reply payload cases); (2) the bulk write/read
/// command family (<c>WriteBulk</c> / <c>Write2Bulk</c> /
/// <c>WriteSecuredBulk</c> / <c>WriteSecured2Bulk</c> / <c>ReadBulk</c>
/// plus their <c>BulkWriteReply</c> and <c>BulkReadReply</c> payloads);
/// (3) the sparse-array write ergonomics extension (<c>MxSparseArray</c> /
/// <c>MxSparseElement</c> messages plus the <c>sparse_array_value = 19</c>
/// arm on the <c>MxValue</c> oneof). A future breaking contract change
/// should bump this constant and update this test in lock-step.
/// </summary>
[Fact]
public void GatewayProtocolVersion_IsVersionThree()
@@ -1588,4 +1588,87 @@ public sealed class ProtobufContractRoundTripTests
Assert.Equal(150UL, parsed.ReplayGap.RequestedAfterSequence);
Assert.Equal(200UL, parsed.ReplayGap.OldestAvailableSequence);
}
/// <summary>
/// Pins the wire field number for <c>MxValue.sparse_array_value</c> (19)
/// via the generated constant, pins the <see cref="MxSparseArray"/> and
/// <see cref="MxSparseElement"/> field numbers by name + number against the
/// descriptor, and round-trips an <see cref="MxValue"/> carrying a
/// <see cref="MxSparseArray"/> with at least one <see cref="MxSparseElement"/>
/// so a future renumber or type-narrowing is caught at the contract level.
/// Also verifies that an all-defaults <see cref="MxSparseArray"/> (no elements)
/// is not a proto-level error. See Contracts-023.
/// </summary>
[Fact]
public void MxValue_RoundTripsSparseArrayValueAndPinsFieldNumbers()
{
// Pin MxValue.sparse_array_value wire field number == 19.
Assert.Equal(19, MxValue.SparseArrayValueFieldNumber);
// Pin MxSparseArray field numbers by name + number.
var sparseArrayFields = MxSparseArray.Descriptor.Fields;
Assert.Equal(1, sparseArrayFields[MxSparseArray.ElementDataTypeFieldNumber].FieldNumber);
Assert.Equal("element_data_type", sparseArrayFields[MxSparseArray.ElementDataTypeFieldNumber].Name);
Assert.Equal(2, sparseArrayFields[MxSparseArray.TotalLengthFieldNumber].FieldNumber);
Assert.Equal("total_length", sparseArrayFields[MxSparseArray.TotalLengthFieldNumber].Name);
Assert.Equal(3, sparseArrayFields[MxSparseArray.ElementsFieldNumber].FieldNumber);
Assert.Equal("elements", sparseArrayFields[MxSparseArray.ElementsFieldNumber].Name);
// Pin MxSparseElement field numbers by name + number.
var sparseElementFields = MxSparseElement.Descriptor.Fields;
Assert.Equal(1, sparseElementFields[MxSparseElement.IndexFieldNumber].FieldNumber);
Assert.Equal("index", sparseElementFields[MxSparseElement.IndexFieldNumber].Name);
Assert.Equal(2, sparseElementFields[MxSparseElement.ValueFieldNumber].FieldNumber);
Assert.Equal("value", sparseElementFields[MxSparseElement.ValueFieldNumber].Name);
// Round-trip an MxValue with a populated MxSparseArray (one scalar element).
var original = new MxValue
{
DataType = MxDataType.Float,
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Float,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 2,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 3.14f,
VariantType = "VT_R4",
},
},
},
},
};
var parsed = MxValue.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, parsed.KindCase);
Assert.NotNull(parsed.SparseArrayValue);
Assert.Equal(MxDataType.Float, parsed.SparseArrayValue.ElementDataType);
Assert.Equal(4u, parsed.SparseArrayValue.TotalLength);
var element = Assert.Single(parsed.SparseArrayValue.Elements);
Assert.Equal(2u, element.Index);
Assert.Equal(3.14f, element.Value.FloatValue);
// An all-defaults MxSparseArray (no elements) is not a proto-level error.
var empty = new MxValue
{
DataType = MxDataType.Float,
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Float,
TotalLength = 10,
},
};
var parsedEmpty = MxValue.Parser.ParseFrom(empty.ToByteArray());
Assert.Equal(empty, parsedEmpty);
Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, parsedEmpty.KindCase);
Assert.Empty(parsedEmpty.SparseArrayValue.Elements);
}
}
@@ -247,6 +247,440 @@ public sealed class GatewayArrayWriteWiringTests
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A bare array address added via <c>AddItemBulk</c> is normalized to its writable array form
/// on the wire (so the worker registers a write-capable handle), while a scalar address in the
/// same batch is forwarded unchanged. Tracking the worker's echoed reply lands the normalized
/// address in the <see cref="SessionItemRegistration"/>.
/// </summary>
[Fact]
public async Task AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.AddItemBulk,
AddItemBulk = new AddItemBulkCommand
{
ServerHandle = 1,
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
// The array address gains the writable "[]" suffix; the scalar passes through unchanged.
Assert.NotNull(worker.LastCommand);
Assert.Equal(
new[] { "Obj.Arr[]", "Obj.Scalar" },
worker.LastCommand!.Command.AddItemBulk.TagAddresses);
// The real worker echoes back the (suffixed) address it bound; tracking the reply must land the
// normalized address in the registration so a later write resolves the write-capable handle.
MxCommand trackingCopy = new()
{
Kind = MxCommandKind.AddItemBulk,
AddItemBulk = new AddItemBulkCommand
{
ServerHandle = 1,
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
},
};
MxCommandReply reply = new()
{
Kind = MxCommandKind.AddItemBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult
{
ServerHandle = 1,
ItemHandle = 50,
TagAddress = "Obj.Arr[]",
WasSuccessful = true,
},
new SubscribeResult
{
ServerHandle = 1,
ItemHandle = 51,
TagAddress = "Obj.Scalar",
WasSuccessful = true,
},
},
},
};
session.TrackCommandReply(trackingCopy, reply);
Assert.True(session.TryGetItemRegistration(1, 50, out SessionItemRegistration arrayRegistration));
Assert.Equal("Obj.Arr[]", arrayRegistration.TagAddress);
Assert.True(session.TryGetItemRegistration(1, 51, out SessionItemRegistration scalarRegistration));
Assert.Equal("Obj.Scalar", scalarRegistration.TagAddress);
}
/// <summary>
/// A sparse-array <see cref="WriteSecuredCommand"/> value is expanded to a full,
/// default-filled <see cref="MxArray"/> before reaching the worker; the secured variant's
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task WriteSecured_SparseArrayValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = 1,
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 2,
Value = new MxValue { Int32Value = 9 },
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecured.Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 9, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array <see cref="Write2Command"/> (timestamped) value is expanded to a full,
/// default-filled <see cref="MxArray"/> before reaching the worker; the Write2 variant's
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task Write2_SparseArrayValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.Write2,
Write2 = new Write2Command
{
ServerHandle = 1,
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 3,
Value = new MxValue { Int32Value = 5 },
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.Write2.Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 0, 5 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array <see cref="WriteSecured2Command"/> (timestamped secured) value is expanded
/// to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
/// WriteSecured2 variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
/// the expander.
/// </summary>
[Fact]
public async Task WriteSecured2_SparseArrayValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured2,
WriteSecured2 = new WriteSecured2Command
{
ServerHandle = 1,
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 0,
Value = new MxValue { Int32Value = 3 },
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2.Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 3, 0, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array entry in a <see cref="Write2BulkCommand"/> (timestamped) is expanded to a
/// full, default-filled <see cref="MxArray"/> before reaching the worker; the Write2Bulk
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task Write2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.Write2Bulk,
Write2Bulk = new Write2BulkCommand
{
ServerHandle = 1,
Entries =
{
new Write2BulkEntry
{
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 1,
Value = new MxValue { Int32Value = 11 },
},
},
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.Write2Bulk.Entries[0].Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 11, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array entry in a <see cref="WriteSecuredBulkCommand"/> is expanded to a full,
/// default-filled <see cref="MxArray"/> before reaching the worker; the WriteSecuredBulk
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
/// </summary>
[Fact]
public async Task WriteSecuredBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecuredBulk,
WriteSecuredBulk = new WriteSecuredBulkCommand
{
ServerHandle = 1,
Entries =
{
new WriteSecuredBulkEntry
{
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 2,
Value = new MxValue { Int32Value = 13 },
},
},
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecuredBulk.Entries[0].Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 13, 0 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A sparse-array entry in a <see cref="WriteSecured2BulkCommand"/> (timestamped secured) is
/// expanded to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
/// WriteSecured2Bulk variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
/// the expander.
/// </summary>
[Fact]
public async Task WriteSecured2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured2Bulk,
WriteSecured2Bulk = new WriteSecured2BulkCommand
{
ServerHandle = 1,
Entries =
{
new WriteSecured2BulkEntry
{
ItemHandle = 42,
Value = new MxValue
{
SparseArrayValue = new MxSparseArray
{
ElementDataType = MxDataType.Integer,
TotalLength = 4,
Elements =
{
new MxSparseElement
{
Index = 3,
Value = new MxValue { Int32Value = 17 },
},
},
},
},
},
},
},
},
};
await session.InvokeAsync(command, CancellationToken.None);
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2Bulk.Entries[0].Value;
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
Assert.Equal(new[] { 0, 0, 0, 17 }, forwarded.ArrayValue.Int32Values.Values);
}
/// <summary>
/// A bare array address added via <c>AddBufferedItem</c> is normalized to its writable array
/// form on the wire and in the tracked <see cref="SessionItemRegistration"/>.
/// </summary>
[Fact]
public async Task AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
{
CapturingWorkerClient worker = new();
GatewaySession session = CreateReadySession(worker);
WorkerCommand command = new()
{
Command = new MxCommand
{
Kind = MxCommandKind.AddBufferedItem,
AddBufferedItem = new AddBufferedItemCommand
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
},
};
worker.NextReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
Kind = MxCommandKind.AddBufferedItem,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddBufferedItem = new AddBufferedItemReply { ItemHandle = 60 },
},
};
await session.InvokeAsync(command, CancellationToken.None);
Assert.NotNull(worker.LastCommand);
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddBufferedItem.ItemDefinition);
// AddBufferedItem tracking keys off the command's ItemDefinition (the reply carries no address),
// so the tracking-path normalization must run for the registration to match the bound handle.
MxCommand trackingCopy = new()
{
Kind = MxCommandKind.AddBufferedItem,
AddBufferedItem = new AddBufferedItemCommand
{
ServerHandle = 1,
ItemDefinition = "Obj.Arr",
},
};
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
Assert.True(session.TryGetItemRegistration(1, 60, out SessionItemRegistration registration));
Assert.Equal("Obj.Arr[]", registration.TagAddress);
}
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
{
GatewaySession session = new(
@@ -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