Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use DataModel::Provider provided command information to handle command validation #35897

Merged
merged 30 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
17dcef6
A first pass to implement better invoke logic decoupling
andreilitvin Oct 3, 2024
3ed30ea
Fix some includes
andreilitvin Oct 3, 2024
6bd969d
Fix some includes
andreilitvin Oct 3, 2024
b63c918
More include fixes
andreilitvin Oct 3, 2024
0912e9a
Fix dependency to make things build
andreilitvin Oct 3, 2024
6386bf7
Fixed tests
andreilitvin Oct 3, 2024
4188ae5
Fix includes
andreilitvin Oct 3, 2024
af7552f
Restyle
andreilitvin Oct 3, 2024
129ad24
Fix naming typo
andreilitvin Oct 3, 2024
b08d16e
Fix tizen build
andreilitvin Oct 3, 2024
03dcc81
Fix condition typo
andy31415 Oct 4, 2024
d197a94
Keep group command logic to use continue instead of status return ...…
andy31415 Oct 4, 2024
f83397f
Merge branch 'master' into use_dm_for_command_validation
andy31415 Oct 4, 2024
4ec72e5
Minor update to kick restyler
andy31415 Oct 4, 2024
3d3e7f6
one more decoupling update: sligtly more inefficient, however likely …
andy31415 Oct 4, 2024
5ca78df
Renamed based on code review feedback
andy31415 Oct 4, 2024
e03197d
Restyled by clang-format
restyled-commits Oct 4, 2024
3cf6ff3
Fix some code review comments
andreilitvin Oct 8, 2024
6e77713
Review comment: default privilege to operate
andreilitvin Oct 8, 2024
99d0629
Fix up renames
andreilitvin Oct 8, 2024
5981a57
Fix hex prefix
andreilitvin Oct 8, 2024
f0968ae
Restyled by clang-format
restyled-commits Oct 8, 2024
649c50c
Merge branch 'master' into use_dm_for_command_validation
andy31415 Oct 8, 2024
c8efbbf
Fix cirque after log formatting change. Load bearing log ....
andreilitvin Oct 8, 2024
ae63895
Merge branch 'use_dm_for_command_validation' of github.com:andy31415/…
andreilitvin Oct 8, 2024
4f1f196
Revert file change that I did not mean to change
andreilitvin Oct 8, 2024
bab965a
Update src/app/CommandHandlerImpl.cpp
andy31415 Oct 9, 2024
fcdb555
Merge branch 'master' into use_dm_for_command_validation
andy31415 Oct 9, 2024
38e3bdb
Remove separate member for accessing fabric index
andy31415 Oct 9, 2024
e497eec
Restyled by clang-format
restyled-commits Oct 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions examples/lighting-app/tizen/src/DBusInterface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ class CommandHandlerImplCallback : public CommandHandlerImpl::Callback
{
public:
using Status = Protocols::InteractionModel::Status;
void OnDone(CommandHandlerImpl & apCommandObj) {}
void DispatchCommand(CommandHandlerImpl & apCommandObj, const ConcreteCommandPath & aCommandPath, TLV::TLVReader & apPayload) {}
Status CommandExists(const ConcreteCommandPath & aCommandPath) { return Status::Success; }

void OnDone(CommandHandlerImpl & apCommandObj) override {}
void DispatchCommand(CommandHandlerImpl & apCommandObj, const ConcreteCommandPath & aCommandPath,
TLV::TLVReader & apPayload) override
{}
Status ValidateCommandCanBeDispatched(const DataModel::InvokeRequest & request) override { return Status::Success; }
};

DBusInterface::DBusInterface(chip::EndpointId endpointId) : mEndpointId(endpointId)
Expand Down
1 change: 1 addition & 0 deletions src/app/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ source_set("command-handler-impl") {
"${chip_root}/src/access:types",
"${chip_root}/src/app/MessageDef",
"${chip_root}/src/app/data-model",
"${chip_root}/src/app/data-model-provider",
"${chip_root}/src/app/util:callbacks",
"${chip_root}/src/lib/support",
"${chip_root}/src/messaging",
Expand Down
85 changes: 25 additions & 60 deletions src/app/CommandHandlerImpl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include <app/MessageDef/StatusIB.h>
#include <app/RequiredPrivilege.h>
#include <app/StatusResponse.h>
#include <app/data-model-provider/OperationTypes.h>
#include <app/util/MatterCallbacks.h>
#include <credentials/GroupDataProvider.h>
#include <lib/core/CHIPConfig.h>
Expand Down Expand Up @@ -390,55 +391,17 @@ Status CommandHandlerImpl::ProcessCommandDataIB(CommandDataIB::Parser & aCommand
VerifyOrReturnError(err == CHIP_NO_ERROR, Status::InvalidAction);

{
Status commandExists = mpCallback->CommandExists(concretePath);
if (commandExists != Status::Success)
{
ChipLogDetail(DataManagement, "No command " ChipLogFormatMEI " in Cluster " ChipLogFormatMEI " on Endpoint 0x%x",
ChipLogValueMEI(concretePath.mCommandId), ChipLogValueMEI(concretePath.mClusterId),
concretePath.mEndpointId);
return FallibleAddStatus(concretePath, commandExists) != CHIP_NO_ERROR ? Status::Failure : Status::Success;
}
}

{
Access::SubjectDescriptor subjectDescriptor = GetSubjectDescriptor();
Access::RequestPath requestPath{ .cluster = concretePath.mClusterId,
.endpoint = concretePath.mEndpointId,
.requestType = Access::RequestType::kCommandInvokeRequest,
.entityId = concretePath.mCommandId };
Access::Privilege requestPrivilege = RequiredPrivilege::ForInvokeCommand(concretePath);
err = Access::GetAccessControl().Check(subjectDescriptor, requestPath, requestPrivilege);
if (err != CHIP_NO_ERROR)
{
if ((err != CHIP_ERROR_ACCESS_DENIED) && (err != CHIP_ERROR_ACCESS_RESTRICTED_BY_ARL))
{
return FallibleAddStatus(concretePath, Status::Failure) != CHIP_NO_ERROR ? Status::Failure : Status::Success;
}
// TODO: when wildcard invokes are supported, handle them to discard rather than fail with status
Status status = err == CHIP_ERROR_ACCESS_DENIED ? Status::UnsupportedAccess : Status::AccessRestricted;
return FallibleAddStatus(concretePath, status) != CHIP_NO_ERROR ? Status::Failure : Status::Success;
}
}
DataModel::InvokeRequest request;

if (CommandNeedsTimedInvoke(concretePath.mClusterId, concretePath.mCommandId) && !IsTimedInvoke())
{
// TODO: when wildcard invokes are supported, discard a
// wildcard-expanded path instead of returning a status.
return FallibleAddStatus(concretePath, Status::NeedsTimedInteraction) != CHIP_NO_ERROR ? Status::Failure : Status::Success;
}

if (CommandIsFabricScoped(concretePath.mClusterId, concretePath.mCommandId))
{
// SPEC: Else if the command in the path is fabric-scoped and there is no accessing fabric,
// a CommandStatusIB SHALL be generated with the UNSUPPORTED_ACCESS Status Code.
request.path = concretePath;
request.subjectDescriptor = GetSubjectDescriptor();
request.accessingFabricIndex = GetAccessingFabricIndex();
request.invokeFlags.Set(DataModel::InvokeFlags::kTimed, IsTimedInvoke());

// Fabric-scoped commands are not allowed before a specific accessing fabric is available.
// This is mostly just during a PASE session before AddNOC.
if (GetAccessingFabricIndex() == kUndefinedFabricIndex)
Status preCheckStatus = mpCallback->ValidateCommandCanBeDispatched(request);
if (preCheckStatus != Status::Success)
{
// TODO: when wildcard invokes are supported, discard a
// wildcard-expanded path instead of returning a status.
return FallibleAddStatus(concretePath, Status::UnsupportedAccess) != CHIP_NO_ERROR ? Status::Failure : Status::Success;
return FallibleAddStatus(concretePath, preCheckStatus) != CHIP_NO_ERROR ? Status::Failure : Status::Success;
}
}

Expand Down Expand Up @@ -516,11 +479,19 @@ Status CommandHandlerImpl::ProcessGroupCommandDataIB(CommandDataIB::Parser & aCo
// once up front and discard all the paths at once. Ordering with respect
// to ACL and command presence checks does not matter, because the behavior
// is the same for all of them: ignore the path.
#if !CHIP_CONFIG_USE_DATA_MODEL_INTERFACE

// Without data model interface, we can query individual commands.
// Data model interface queries commands by a full path so we need endpointID as well.
//
// Since this is a performance update and group commands are never timed,
// missing this should not be that noticeable.
if (CommandNeedsTimedInvoke(clusterId, commandId))
{
// Group commands are never timed.
return Status::Success;
}
#endif

// No check for `CommandIsFabricScoped` unlike in `ProcessCommandDataIB()` since group commands
// always have an accessing fabric, by definition.
Expand All @@ -542,30 +513,24 @@ Status CommandHandlerImpl::ProcessGroupCommandDataIB(CommandDataIB::Parser & aCo

const ConcreteCommandPath concretePath(mapping.endpoint_id, clusterId, commandId);

if (mpCallback->CommandExists(concretePath) != Status::Success)
{
ChipLogDetail(DataManagement, "No command " ChipLogFormatMEI " in Cluster " ChipLogFormatMEI " on Endpoint 0x%x",
ChipLogValueMEI(commandId), ChipLogValueMEI(clusterId), mapping.endpoint_id);
DataModel::InvokeRequest request;

continue;
}
request.path = concretePath;
request.subjectDescriptor = GetSubjectDescriptor();
request.accessingFabricIndex = GetAccessingFabricIndex();
request.invokeFlags.Set(DataModel::InvokeFlags::kTimed, IsTimedInvoke());

{
Access::SubjectDescriptor subjectDescriptor = GetSubjectDescriptor();
Access::RequestPath requestPath{ .cluster = concretePath.mClusterId,
.endpoint = concretePath.mEndpointId,
.requestType = Access::RequestType::kCommandInvokeRequest,
.entityId = concretePath.mCommandId };
Access::Privilege requestPrivilege = RequiredPrivilege::ForInvokeCommand(concretePath);
err = Access::GetAccessControl().Check(subjectDescriptor, requestPath, requestPrivilege);
if (err != CHIP_NO_ERROR)
Status preCheckStatus = mpCallback->ValidateCommandCanBeDispatched(request);
andy31415 marked this conversation as resolved.
Show resolved Hide resolved
if (preCheckStatus != Status::Success)
{
// NOTE: an expected error is CHIP_ERROR_ACCESS_DENIED, but there could be other unexpected errors;
andy31415 marked this conversation as resolved.
Show resolved Hide resolved
// therefore, keep processing subsequent commands, and if any errors continue, those subsequent
// commands will likewise fail.
continue;
}
}

if ((err = DataModelCallbacks::GetInstance()->PreCommandReceived(concretePath, GetSubjectDescriptor())) == CHIP_NO_ERROR)
{
TLV::TLVReader dataReader(commandDataReader);
Expand Down
27 changes: 18 additions & 9 deletions src/app/CommandHandlerImpl.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <app/CommandPathRegistry.h>
#include <app/MessageDef/InvokeRequestMessage.h>
#include <app/MessageDef/InvokeResponseMessage.h>
#include <app/data-model-provider/OperationTypes.h>
#include <lib/core/TLV.h>
#include <lib/core/TLVDebug.h>
#include <lib/support/BitFlags.h>
Expand Down Expand Up @@ -50,21 +51,29 @@ class CommandHandlerImpl : public CommandHandler
*/
virtual void OnDone(CommandHandlerImpl & apCommandObj) = 0;

/**
* Perform pre-validation that the command dispatch can be performed. In particular:
* - check command existence/validity
* - validate ACL
* - validate timed-invoke and fabric-scoped requirements
*
* Returns Status::Success if the command can be dispatched, otherwise it will
* return the status to be forwarded to the client on failure.
*
* Possible error return codes:
* - UnsupportedEndpoint/UnsupportedCluster/UnsupportedCommand if the command path is invalid
* - NeedsTimedInteraction
* - UnsupportedAccess (ACL failure or fabric scoped without a valid fabric index)
* - AccessRestricted
*/
virtual Protocols::InteractionModel::Status ValidateCommandCanBeDispatched(const DataModel::InvokeRequest & request) = 0;

/*
* Upon processing of a CommandDataIB, this method is invoked to dispatch the command
* to the right server-side handler provided by the application.
*/
virtual void DispatchCommand(CommandHandlerImpl & apCommandObj, const ConcreteCommandPath & aCommandPath,
TLV::TLVReader & apPayload) = 0;

/*
* Check to see if a command implementation exists for a specific
* concrete command path. If it does, Success will be returned. If
* not, one of UnsupportedEndpoint, UnsupportedCluster, or
* UnsupportedCommand will be returned, depending on how the command
* fails to exist.
*/
virtual Protocols::InteractionModel::Status CommandExists(const ConcreteCommandPath & aCommandPath) = 0;
};

struct InvokeResponseParameters
Expand Down
6 changes: 3 additions & 3 deletions src/app/CommandResponseSender.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ void CommandResponseSender::DispatchCommand(CommandHandlerImpl & apCommandObj, c
mpCommandHandlerCallback->DispatchCommand(apCommandObj, aCommandPath, apPayload);
}

Status CommandResponseSender::CommandExists(const ConcreteCommandPath & aCommandPath)
Protocols::InteractionModel::Status CommandResponseSender::ValidateCommandCanBeDispatched(const DataModel::InvokeRequest & request)
{
VerifyOrReturnValue(mpCommandHandlerCallback, Protocols::InteractionModel::Status::UnsupportedCommand);
return mpCommandHandlerCallback->CommandExists(aCommandPath);
VerifyOrReturnValue(mpCommandHandlerCallback, Protocols::InteractionModel::Status::Failure);
return mpCommandHandlerCallback->ValidateCommandCanBeDispatched(request);
}

CHIP_ERROR CommandResponseSender::SendCommandResponse()
Expand Down
2 changes: 1 addition & 1 deletion src/app/CommandResponseSender.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class CommandResponseSender : public Messaging::ExchangeDelegate,
void DispatchCommand(CommandHandlerImpl & apCommandObj, const ConcreteCommandPath & aCommandPath,
TLV::TLVReader & apPayload) override;

Protocols::InteractionModel::Status CommandExists(const ConcreteCommandPath & aCommandPath) override;
Protocols::InteractionModel::Status ValidateCommandCanBeDispatched(const DataModel::InvokeRequest & request) override;

/**
* Gets the inner exchange context object, without ownership.
Expand Down
96 changes: 93 additions & 3 deletions src/app/InteractionModelEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
#include <app/AppConfig.h>
#include <app/CommandHandlerInterfaceRegistry.h>
#include <app/RequiredPrivilege.h>
#include <app/data-model-provider/ActionReturnStatus.h>
#include <app/data-model-provider/MetadataTypes.h>
#include <app/data-model-provider/OperationTypes.h>
#include <app/util/IMClusterCommandHandler.h>
#include <app/util/af-types.h>
#include <app/util/ember-compatibility-functions.h>
Expand All @@ -42,10 +45,9 @@
#include <lib/support/CHIPFaultInjection.h>
#include <lib/support/CodeUtils.h>
#include <lib/support/FibonacciUtils.h>
#include <protocols/interaction_model/StatusCode.h>

#if CHIP_CONFIG_USE_DATA_MODEL_INTERFACE
#include <app/data-model-provider/ActionReturnStatus.h>

// TODO: defaulting to codegen should eventually be an application choice and not
// hard-coded in the interaction model
#include <app/codegen-data-model-provider/Instance.h>
Expand Down Expand Up @@ -1646,6 +1648,8 @@ void InteractionModelEngine::DispatchCommand(CommandHandlerImpl & apCommandObj,

DataModel::InvokeRequest request;
request.path = aCommandPath;
request.invokeFlags.Set(DataModel::InvokeFlags::kTimed, apCommandObj.IsTimedInvoke());
request.subjectDescriptor = apCommandObj.GetSubjectDescriptor();

std::optional<DataModel::ActionReturnStatus> status = GetDataModelProvider()->Invoke(request, apPayload, &apCommandObj);

Expand Down Expand Up @@ -1678,7 +1682,93 @@ void InteractionModelEngine::DispatchCommand(CommandHandlerImpl & apCommandObj,
#endif // CHIP_CONFIG_USE_DATA_MODEL_INTERFACE
}

Protocols::InteractionModel::Status InteractionModelEngine::CommandExists(const ConcreteCommandPath & aCommandPath)
Protocols::InteractionModel::Status InteractionModelEngine::ValidateCommandCanBeDispatched(const DataModel::InvokeRequest & request)
{

Status status = CommandCheckExists(request.path);
andy31415 marked this conversation as resolved.
Show resolved Hide resolved

if (status != Status::Success)
{
ChipLogDetail(DataManagement, "No command " ChipLogFormatMEI " in Cluster " ChipLogFormatMEI " on Endpoint 0x%x",
andy31415 marked this conversation as resolved.
Show resolved Hide resolved
ChipLogValueMEI(request.path.mCommandId), ChipLogValueMEI(request.path.mClusterId), request.path.mEndpointId);
return status;
}

status = CommandCheckACL(request);
andy31415 marked this conversation as resolved.
Show resolved Hide resolved
VerifyOrReturnValue(status == Status::Success, status);

return CommandCheckFlags(request);
andy31415 marked this conversation as resolved.
Show resolved Hide resolved
}

Protocols::InteractionModel::Status InteractionModelEngine::CommandCheckACL(const DataModel::InvokeRequest & aRequest)
{
if (!aRequest.subjectDescriptor.has_value())
bzbarsky-apple marked this conversation as resolved.
Show resolved Hide resolved
{
return Status::UnsupportedAccess; // we require a subject for invoke
}

Access::RequestPath requestPath{ .cluster = aRequest.path.mClusterId,
.endpoint = aRequest.path.mEndpointId,
.requestType = Access::RequestType::kCommandInvokeRequest,
.entityId = aRequest.path.mCommandId };
#if CHIP_CONFIG_USE_DATA_MODEL_INTERFACE
std::optional<DataModel::CommandInfo> commandInfo = mDataModelProvider->GetAcceptedCommandInfo(aRequest.path);
// This is checked by previous validations, so it should not happen
VerifyOrReturnValue(commandInfo.has_value(), Status::UnsupportedCommand);
andy31415 marked this conversation as resolved.
Show resolved Hide resolved

Access::Privilege minimumRequiredPrivilege = commandInfo->invokePrivilege;
#else
Access::Privilege minimumRequiredPrivilege = RequiredPrivilege::ForInvokeCommand(aRequest.path);
#endif
CHIP_ERROR err = Access::GetAccessControl().Check(*aRequest.subjectDescriptor, requestPath, minimumRequiredPrivilege);
if (err != CHIP_NO_ERROR)
{
if ((err != CHIP_ERROR_ACCESS_DENIED) && (err != CHIP_ERROR_ACCESS_RESTRICTED_BY_ARL))
{
return Status::Failure;
}
return err == CHIP_ERROR_ACCESS_DENIED ? Status::UnsupportedAccess : Status::AccessRestricted;
}

return Status::Success;
}

Protocols::InteractionModel::Status InteractionModelEngine::CommandCheckFlags(const DataModel::InvokeRequest & aRequest)
{
#if CHIP_CONFIG_USE_DATA_MODEL_INTERFACE
std::optional<DataModel::CommandInfo> commandInfo = mDataModelProvider->GetAcceptedCommandInfo(aRequest.path);
// This is checked by previous validations, so it should not happen
andy31415 marked this conversation as resolved.
Show resolved Hide resolved
VerifyOrReturnValue(commandInfo.has_value(), Status::UnsupportedCommand);

const bool commandNeedsTimedInvoke = commandInfo->flags.Has(DataModel::CommandQualityFlags::kTimed);
const bool commandIsFabricScoped = commandInfo->flags.Has(DataModel::CommandQualityFlags::kFabricScoped);
#else
const bool commandNeedsTimedInvoke = CommandNeedsTimedInvoke(aRequest.path.mClusterId, aRequest.path.mCommandId);
const bool commandIsFabricScoped = CommandIsFabricScoped(aRequest.path.mClusterId, aRequest.path.mCommandId);
#endif

if (commandNeedsTimedInvoke && !aRequest.invokeFlags.Has(DataModel::InvokeFlags::kTimed))
{
return Status::NeedsTimedInteraction;
}

if (commandIsFabricScoped)
{
// SPEC: Else if the command in the path is fabric-scoped and there is no accessing fabric,
// a CommandStatusIB SHALL be generated with the UNSUPPORTED_ACCESS Status Code.

// Fabric-scoped commands are not allowed before a specific accessing fabric is available.
// This is mostly just during a PASE session before AddNOC.
if (aRequest.accessingFabricIndex == kUndefinedFabricIndex)
{
return Status::UnsupportedAccess;
}
}

return Status::Success;
}

Protocols::InteractionModel::Status InteractionModelEngine::CommandCheckExists(const ConcreteCommandPath & aCommandPath)
{
#if CHIP_CONFIG_USE_DATA_MODEL_INTERFACE
auto provider = GetDataModelProvider();
Expand Down
7 changes: 6 additions & 1 deletion src/app/InteractionModelEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
#include <app/TimedHandler.h>
#include <app/WriteClient.h>
#include <app/WriteHandler.h>
#include <app/data-model-provider/OperationTypes.h>
#include <app/data-model-provider/Provider.h>
#include <app/icd/server/ICDServerConfig.h>
#include <app/reporting/Engine.h>
Expand Down Expand Up @@ -508,7 +509,7 @@ class InteractionModelEngine : public Messaging::UnsolicitedMessageHandler,
void DispatchCommand(CommandHandlerImpl & apCommandObj, const ConcreteCommandPath & aCommandPath,
TLV::TLVReader & apPayload) override;

Protocols::InteractionModel::Status CommandExists(const ConcreteCommandPath & aCommandPath) override;
Protocols::InteractionModel::Status ValidateCommandCanBeDispatched(const DataModel::InvokeRequest & request) override;

bool HasActiveRead();

Expand Down Expand Up @@ -612,6 +613,10 @@ class InteractionModelEngine : public Messaging::UnsolicitedMessageHandler,
void ShutdownMatchingSubscriptions(const Optional<FabricIndex> & aFabricIndex = NullOptional,
const Optional<NodeId> & aPeerNodeId = NullOptional);

Status CommandCheckExists(const ConcreteCommandPath & aCommandPath);
Status CommandCheckACL(const DataModel::InvokeRequest & aRequest);
Status CommandCheckFlags(const DataModel::InvokeRequest & aRequest);

/**
* Check if the given attribute path is a valid path in the data model provider.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/app/data-model-provider/OperationTypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include <access/SubjectDescriptor.h>
#include <app/ConcreteAttributePath.h>
#include <app/ConcreteCommandPath.h>
#include <lib/core/DataModelTypes.h>
#include <lib/support/BitFlags.h>

#include <cstdint>
Expand Down Expand Up @@ -99,6 +100,7 @@ struct InvokeRequest : OperationRequest
{
ConcreteCommandPath path;
BitFlags<InvokeFlags> invokeFlags;
FabricIndex accessingFabricIndex = kUndefinedFabricIndex;
andy31415 marked this conversation as resolved.
Show resolved Hide resolved
};

} // namespace DataModel
Expand Down
Loading
Loading