Random wanderings through Microsoft Azure esp. PaaS plumbing, the IoT bits, AI on Micro controllers, AI on Edge Devices, .NET nanoFramework, .NET Core on *nix and ML.NET+ONNX
const string codeSwarmSpaceUplinkFormatterCode = @"
using Newtonsoft.Json.Linq;
public class UplinkFormatter : PayloadFormatter.ISwarmSpaceFormatterUplink
{
public JObject Evaluate(JObject telemetryEvent, string payloadBase64, byte[] payloadBytes, string payloadText, JObject payloadJson)
{
if ((payloadText != """" ) && ( payloadJson != null))
{
JObject location = new JObject() ;
location.Add(""Lat"", payloadJson.GetValue(""lt""));
location.Add(""Lon"", payloadJson.GetValue(""ln""));
location.Add(""Alt"", payloadJson.GetValue(""a""));
telemetryEvent.Add( ""location"", location);
};
return telemetryEvent;
}
}";
}
The PayloadFormatter namespace was added to reduce the length of the payload formatter C# interface declarations.
namespace PayloadFormatter
{
using Newtonsoft.Json.Linq;
public interface ISwarmSpaceFormatterUplink
{
public JObject Evaluate(JObject telemetry, string payloadBase64, byte[] payloadBytes, string payloadText, JObject payloadJson);
}
public interface ISwarmSpaceFormatterDownlink
{
public string Evaluate(JObject payloadJson, string payloadText, byte[] payloadBytes, string payloadBase64);
}
}
namespace devMobile.IoT.SwarmSpace.AzureIoT.Connector
{
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using CSScriptLib;
using PayloadFormatter;
public interface ISwarmSpaceFormatterCache
{
public Task<ISwarmSpaceFormatterUplink> PayloadFormatterGetOrAddAsync(int userApplicationId);
}
public class SwarmSpaceFormatterCache : ISwarmSpaceFormatterCache
{
private readonly ILogger<SwarmSpaceFormatterCache> _logger;
public SwarmSpaceFormatterCache(ILogger<SwarmSpaceFormatterCache>logger)
{
_logger = logger;
}
public async Task<ISwarmSpaceFormatterUplink> PayloadFormatterGetOrAddAsync(int deviceId)
{
return CSScript.Evaluator.LoadCode<PayloadFormatter.ISwarmSpaceFormatterUplink>(codeSwarmSpaceUplinkFormatterCode);
}
...
}
The parameters of the formatter are Base64 encoded, textual and a Newtonsoft JObject representations of the uplink payload and a telemetry event populated with some uplink message metadata.
Azure IoT Central uplink telemetry message payload
The initial “compile” of an uplink formatter was taking approximately 2.1 seconds so they will be “compiled” on demand and cached in a Dictionary with the UserApplicationId as the key. A default uplink formatter will be used when a UserApplicationId specific uplink formatter is not configured.
https://json2csharp.com/
// Root myDeserializedClass = JsonConvert.DeserializeObject<Root>(myJsonResponse);
public class Root
{
public int packetId { get; set; }
public int deviceType { get; set; }
public int deviceId { get; set; }
public int userApplicationId { get; set; }
public int organizationId { get; set; }
public string data { get; set; }
public int len { get; set; }
public int status { get; set; }
public DateTime hiveRxTime { get; set; }
}
*/
public class UplinkPayload
{
[JsonProperty("packetId")]
public int PacketId { get; set; }
[JsonProperty("deviceType")]
public int DeviceType { get; set; }
[JsonProperty("deviceId")]
public int DeviceId { get; set; }
[JsonProperty("userApplicationId")]
public int UserApplicationId { get; set; }
[JsonProperty("organizationId")]
public int OrganizationId { get; set; }
[JsonProperty("data")]
[JsonRequired]
public string Data { get; set; }
[JsonProperty("len")]
public int Len { get; set; }
[JsonProperty("status")]
public int Status { get; set; }
[JsonProperty("hiveRxTime")]
public DateTime HiveRxTime { get; set; }
}
This class is used to “automagically” deserialise Delivery Webhook payloads. There is also some additional payload validation which discards test messages (not certain this is a good idea) etc.
//---------------------------------------------------------------------------------
// Copyright (c) December 2022, devMobile Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
//---------------------------------------------------------------------------------
namespace devMobile.IoT.SwarmSpace.AzureIoT.Connector.Controllers
{
using System.Globalization;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Devices.Client;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
[ApiController]
[Route("api/[controller]")]
public class UplinkController : ControllerBase
{
private readonly ILogger<UplinkController> _logger;
private readonly IAzureIoTDeviceClientCache _azureIoTDeviceClientCache;
public UplinkController(ILogger<UplinkController> logger, IAzureIoTDeviceClientCache azureIoTDeviceClientCache)
{
_logger = logger;
_azureIoTDeviceClientCache = azureIoTDeviceClientCache;
}
[HttpPost]
public async Task<IActionResult> Uplink([FromBody] Models.UplinkPayload payload)
{
DeviceClient deviceClient;
_logger.LogDebug("Payload {0}", JsonConvert.SerializeObject(payload, Formatting.Indented));
if (payload.PacketId == 0)
{
_logger.LogWarning("Uplink-payload simulated DeviceId:{DeviceId}", payload.DeviceId);
return this.Ok();
}
if ((payload.UserApplicationId < Constants.UserApplicationIdMinimum) || (payload.UserApplicationId > Constants.UserApplicationIdMaximum))
{
_logger.LogWarning("Uplink-payload invalid User Application Id:{UserApplicationId}", payload.UserApplicationId);
return this.BadRequest($"Invalid User Application Id {payload.UserApplicationId}");
}
if ((payload.Len < Constants.PayloadLengthMinimum) || string.IsNullOrEmpty(payload.Data))
{
_logger.LogWarning("Uplink-payload.Data is empty PacketId:{PacketId}", payload.PacketId);
return this.Ok("payload.Data is empty");
}
Models.AzureIoTDeviceClientContext context = new Models.AzureIoTDeviceClientContext()
{
OrganisationId = payload.OrganizationId,
UserApplicationId = payload.UserApplicationId,
DeviceType = payload.DeviceType,
DeviceId = payload.DeviceId,
};
deviceClient = await _azureIoTDeviceClientCache.GetOrAddAsync(payload.DeviceId.ToString(), context);
JObject telemetryEvent = new JObject
{
{ "packetId", payload.PacketId},
{ "deviceType" , payload.DeviceType},
{ "DeviceID", payload.DeviceId },
{ "organizationId", payload.OrganizationId },
{ "ApplicationId", payload.UserApplicationId},
{ "ReceivedAtUtc", payload.HiveRxTime.ToString("s", CultureInfo.InvariantCulture) },
{ "DataLength", payload.Len },
{ "Data", payload.Data },
{ "Status", payload.Status },
};
// Send the message to Azure IoT Hub
using (Message ioTHubmessage = new Message(Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(telemetryEvent))))
{
// Ensure the displayed time is the acquired time rather than the uploaded time.
ioTHubmessage.Properties.Add("iothub-creation-time-utc", payload.HiveRxTime.ToString("s", CultureInfo.InvariantCulture));
ioTHubmessage.Properties.Add("OrganizationId", payload.OrganizationId.ToString());
ioTHubmessage.Properties.Add("ApplicationId", payload.UserApplicationId.ToString());
ioTHubmessage.Properties.Add("DeviceId", payload.DeviceId.ToString());
ioTHubmessage.Properties.Add("deviceType", payload.DeviceType.ToString());
await deviceClient.SendEventAsync(ioTHubmessage);
_logger.LogInformation("Uplink-DeviceID:{deviceId} SendEventAsync success", payload.DeviceId);
}
return this.Ok();
}
}
}
The webhook was configured to “acknowledge messages on successful delivery”. I then checked my Delivery Method configuration with a couple of “Test” messages.
My Swarm Space Eval Kit arrived md-week and after some issues with jumper settings it started reporting position and status information.
I started with a modified version of the first sample on Github.
public class Samples
{
const string codeMethod = @"
int Multiply(int a, int b)
{
return a * b;
}";
public void Execute1()
{
dynamic script = CSScript.Evaluator.LoadMethod(codeMethod);
int result = script.Multiply(3, 2);
Console.WriteLine($"Product 1:{result}");
}
...
internal class Program
{
static void Main(string[] args)
{
new Samples().Execute1();
...
Console.WriteLine($"Press Enter to exit");
Console.ReadLine();
}
}
I then modified it to use a C# interface and the application failed with an exception
CSScriptLib.CompilerException
HResult=0x80131600
Message=(2,39): error CS0246: The type or namespace name 'IMultiplier' could not be found (are you missing a using directive or an assembly reference?)
Source=CSScriptLib
StackTrace:
at CSScriptLib.RoslynEvaluator.Compile(String scriptText, String scriptFile, CompileInfo info)
at CSScriptLib.EvaluatorBase`1.LoadCode[T](String scriptText, Object[] args)
at devMobile.IoT.SwarmSpace.AzureIoT.PayloadFormatterCSScript.Samples.Execute2A() in C:\Users\BrynLewis\source\repos\SwarmSpaceAzureIoT\PayloadFormatterCSScipt\Program.cs:line 90
at devMobile.IoT.SwarmSpace.AzureIoT.PayloadFormatterCSScript.Program.Main(String[] args) in C:\Users\BrynLewis\source\repos\SwarmSpaceAzureIoT\PayloadFormatterCSScipt\Program.cs:line 375
After some trial and error, I figured out I had the namespace wrong
const string codeClassA = @"
public class Calculator : devMobile.IoT.SwarmSpace.AzureIoT.PayloadFormatterCSScript.IMultiplier
{
public int Multiply(int a, int b)
{
return a * b;
}
}";
public void Execute2A()
{
IMultiplier multiplierA = CSScript.Evaluator.LoadCode<IMultiplier>(codeClassA);
Console.WriteLine($"Product 2A:{multiplierA.Multiply(3, 2)} - Press Enter to exit");
}
The long namespace would have been a pain in the arse (PITA) for users creating payload formatters and after some experimentation I added another interface with a short namespace. (Not certain this is a good idea).
namespace PayloadFormatter // Additional namespace for shortening interface for formatters
{
public interface IMultiplier
{
int Multiply(int a, int b);
}
}
...
public void Execute2B()
{
PayloadFormatter.IMultiplier multiplierB = CSScript.Evaluator.LoadCode<PayloadFormatter.IMultiplier>(codeClassB);
Console.WriteLine($"Product 2B:{multiplierB.Multiply(3, 2)} - Press Enter to exit");
}
I then wanted to figure out how to limit the namepaces the script has access to
const string codeClassDebug = @"
using System.Diagnostics;
public class Calculator : devMobile.IoT.SwarmSpace.AzureIoT.PayloadFormatterCSScript.IMultiplier
{
public int Multiply(int a, int b)
{
Debug.WriteLine(""Oops""); // Comment out the using System.Diagnostics;
return a * b;
}
}";
public void Execute3()
{
CSScript.Evaluator.Reset(true);
IMultiplier multiplier = CSScript.Evaluator
.LoadCode<IMultiplier>(codeClassDebug);
int result = multiplier.Multiply(6, 2);
Console.WriteLine($"Product 3:{result}");
}
The CSScript.Evaluator.Reset(true); removes all of the “default” references but a using directive could make namespaces available, so this needs some more investigation
The next step was to build the simplest possible payload formatter a “pipe” which displayed the text encoded in Base64 string.
const string codeSwarmSpaceFormatterPipe = @"
public class SwarmSpaceFormatter:devMobile.IoT.SwarmSpace.AzureIoT.PayloadFormatterCSScript.ISwarmSpaceFormatterPipe
{
public string Pipe(string payloadBase64)
{
var payloadBase64Bytes = System.Convert.FromBase64String(payloadBase64);
return System.Text.Encoding.UTF8.GetString(payloadBase64Bytes);
}
}";
...
public void Execute4()
{
ISwarmSpaceFormatterPipe SwarmSpaceFormatter = CSScript.Evaluator
...
.LoadCode<ISwarmSpaceFormatterPipe>(codeSwarmSpaceFormatterPipe);
string payload = SwarmSpaceFormatter.Pipe(PayloadBase64);
Console.WriteLine($"Pipe:{payload}");
}
The Base64 encoded uplink payloads will have to be converted to JSON and the downlink JSON payloads will have to be converted to Base64 encoded binary, so I created an uplink and downlink formatters.
I found that having both the byte array and Base64 encoded representation of the uplink payloads was useful. The first formatter converts the temperature field of the downlink payload into a four byte array then reverses the array to illustrate how packed byte payloads could be constructed.
const string codeSwarmSpaceFormatter1 = @"
public class SwarmSpaceFormatter : devMobile.IoT.SwarmSpace.AzureIoT.PayloadFormatterCSScript.ISwarmSpaceFormatter
{
public string Pipe(string payloadBase64)
{
var payloadBase64Bytes = System.Convert.FromBase64String(payloadBase64);
return System.Text.Encoding.UTF8.GetString(payloadBase64Bytes);
}
public JObject Uplink(JObject telemetryEvent, string payloadBase64, byte[] payloadBytes)
{
var payloadBase64Bytes = System.Convert.FromBase64String(payloadBase64);
telemetryEvent.Add(""PayloadBase64"", payloadBase64Bytes);
telemetryEvent.Add(""PayloadBytes"",System.Text.Encoding.UTF8.GetString(payloadBytes));
return telemetryEvent;
}
public string Downlink(JObject command)
{
int temperature = command.Value<int>(""Temperature"");
return System.Convert.ToBase64String(BitConverter.GetBytes(temperature));
}
}";
const string codeSwarmSpaceFormatter2 = @"
public class SwarmSpaceFormatter:devMobile.IoT.SwarmSpace.AzureIoT.PayloadFormatterCSScript.ISwarmSpaceFormatter
{
public string Pipe(string payloadBase64)
{
var payloadBase64Bytes = System.Convert.FromBase64String(payloadBase64);
return System.Text.Encoding.UTF8.GetString(payloadBase64Bytes);
}
public JObject Uplink(JObject telemetryEvent, string payloadBase64, byte[] payloadBytes)
{
var payloadBase64Bytes = System.Convert.FromBase64String(payloadBase64);
telemetryEvent.Add(""PayloadBase64"", payloadBase64Bytes);
telemetryEvent.Add(""PayloadBytes"",System.Text.Encoding.UTF8.GetString(payloadBytes));
return telemetryEvent;
}
public string Downlink(JObject command)
{
int temperature = command.Value<int>(""Temperature"");
byte[] temperatureBytes = BitConverter.GetBytes(temperature);
Array.Reverse(temperatureBytes);
return System.Convert.ToBase64String(temperatureBytes);
}
}";
...
public void Execute6()
{
string namespaces = $"using Newtonsoft.Json.Linq;using System;\n";
string code1 = namespaces + codeSwarmSpaceFormatter1;
string code2 = namespaces + codeSwarmSpaceFormatter2;
JObject telemetry = new JObject
{
{ "ApplicationID", 12345 },
{ "DeviceID", 54321 },
{ "DeviceType", 2 },
{ "ReceivedAtUtc", DateTime.UtcNow.ToString("s", CultureInfo.InvariantCulture) },
};
var formatters = new Dictionary<string, ISwarmSpaceFormatter>();
Console.WriteLine($"Evaluator start");
DateTime evaluatorStartAtUtc = DateTime.UtcNow;
ISwarmSpaceFormatter SwarmSpaceFormatter1 = CSScript.Evaluator
.LoadCode<ISwarmSpaceFormatter>(code1);
ISwarmSpaceFormatter SwarmSpaceFormatter2 = CSScript.Evaluator
.LoadCode<ISwarmSpaceFormatter>(code2);
Console.WriteLine($"Evaluator:{DateTime.UtcNow - evaluatorStartAtUtc}");
Console.WriteLine("");
Console.WriteLine($"Evaluation start");
DateTime evaluationStartUtc = DateTime.UtcNow;
formatters.Add("F1", SwarmSpaceFormatter1);
formatters.Add("F2", SwarmSpaceFormatter2);
JObject command = new JObject
{
{"Temperature", 1},
};
ISwarmSpaceFormatter downlinkPayload;
downlinkPayload = formatters["F1"];
Console.WriteLine($"Downlink F1:{downlinkPayload.Downlink(command)}");
downlinkPayload = formatters["F2"];
Console.WriteLine($"Downlink F2:{downlinkPayload.Downlink(command)}");
Console.WriteLine($"Evaluation:{DateTime.UtcNow - evaluationStartUtc}");
Console.WriteLine("");
const int iterations = 100;
Console.WriteLine($"Evaluations start {iterations}");
DateTime evaluationsStartUtc = DateTime.UtcNow;
for (int i = 1; i <= iterations; i++)
{
JObject command1 = new JObject
{
{"Temperature", 1},
};
downlinkPayload = formatters["F1"];
Console.WriteLine($" Downlink F1:{downlinkPayload.Downlink(command1)}");
downlinkPayload = formatters["F2"];
Console.WriteLine($" Downlink F2:{downlinkPayload.Downlink(command1)}");
}
Console.WriteLine($"Evaluations:{iterations} Took:{DateTime.UtcNow - evaluationsStartUtc}");
}
On my development box the initial “compile” of each function was taking approximately 2.1 seconds so I cached the “compiled” formatters in a dictionary so they could be reused. Cached in the dictionary executing the two formatters 100 times took approximately 15 milliseconds (which is close to native .NET performance).
Compatibility
To check that the CS-Script tooling could run on a machine without the .NET 6 Software Development Kit (SDK) I tested the application on a laptop which had a “fresh” install of Windows 10.
CS-Script application failing due to missing .NET 6 runtime
Installing the .NET 6 Runtime
CS-Script application running after .NET runtime installation
The CS-Script library is pretty amazing and has made the development of uplink and downlink payload formatters significantly less complex than I was expecting.
To figure out how to poll the Swarm Hive API I have built yet another “nasty” Proof of Concept (PoC) which gets ToDevice and FromDevice messages. Initially I have focused on polling as the volume of messages from my single device is pretty low (WebHooks will be covered in a future post).
NOTE: Swarm Space technical support clarified the parameter values required to get FromDevice and ToDevice messages using the Bumbleebee Hive API.
Swarm API Docs messages functionality
The Messages Get method has a lot of parameters for filtering and paging the response message lists. Many of the parameters have default values so can be null or left blank.
Swarm API Get User Message filters
I started off by seeing if I could duplicate the functionality of the user interface and get a list of all ToDevice and FromDevice messages.
Swarm Dashboard messages list
I first called the Messages Get method with the direction set to “fromdevice” (Odd this is a string rather than an enumeration) and the messages I had sent from my Sparkfun Satellite Transceiver Breakout – Swarm M138 were displayed.
Swarm API Docs displaying “fromdevice” messages
I then called the Messages Get method with the direction set to “all” and only the FromDevice messages were displayed which I wasn’t expecting.
Swarm API Docs displaying ToDevice and FromDevices messages
I then called the Messages Get method with the direction set to “FromDevice and no messages were displayed which I wasn’t expecting
Swarm API Docs displaying “todevice” messages
I then called the Message Get method with the messageId of a ToDevice message and the detailed message information was displayed.
Swarm API Docs displaying the details of a specific inbound message
For testing I configured 5 devices (a real device and the others simulated) in my Azure IoT Hub with the Swarm Device ID ued as the Azure IoT Hub device ID.
While testing I disabled the message RxAck functionality so I could repeatedly call the MessagesGet method so I didn’t have to send new messages and burn through my 50 free messages.
Azure IoT Explorer telemetry displaying the three messages processed by my console application.
.
Updated parameters based on feedback from Swarm technical support
So, I can simulate lots of devices and test more complex configurations I have started build a Swarm Bumble Bee Hive emulator based on the API and Delivery-APIOpenAPI files.
NSwagStudio configuration for generating ASP.NET Core web API
As well as generating clients NSwagStudio can also generate ASP.NET Core web APIs. To test my approach, I built the simplest possible client I could which calls the generated PostLoginAsync and GetDeviceCountAsync.
Swagger UI for NSwagStudio generated ASP.NET Core web API
BumblebeeHiveBasicClientConsole application 415 Unsupported Media Type error
After some trial and error, I modified the HiveController.cs and HiveControllerImplementation.cs Login method signatures so the payload was “application/x-www-form-urlencoded” rather than “application/json” by changing FromBody to FromForm
Modifying code generated by a tool like NSwagStudio should be avoided but I couldn’t work out a simpler solution
/// <summary>
/// POST login
/// </summary>
/// <remarks>
/// <p>Use username and password to log in.</p><p>On success: returns status code 200. The response body is the JSON <code>{"token": "&lt;token&gt;"}</code>, along with the header <code>Set-Cookie: JSESSIONID=&lt;token&gt;; Path=/; Secure; HttpOnly;</code>. The tokens in the return value and the <code>Set-Cookie</code> header are the same. The token is a long string of letters, numbers, and punctuation.</p><p>On failure: returns status code 401.</p><p>To make authenticated requests, there are two ways: <ul><li>(Preferred) Use the token as a Bearer Authentication token by including the HTTP header <code>Authorization: Bearer &lt;token&gt;</code> in further requests.</li><li>(Deprecated) Use the token as the JSESSIONID cookie in further requests.</li></ul></p>
/// </remarks>
/// <returns>Login success</returns>
[Microsoft.AspNetCore.Mvc.HttpPost, Microsoft.AspNetCore.Mvc.Route("login")]
public System.Threading.Tasks.Task<Response> PostLogin([Microsoft.AspNetCore.Mvc.FromForm] LoginForm body)
{
return _implementation.PostLoginAsync(body);
}
BumblebeeHiveBasicCLientConsole application calling the simulator
BumblebeeHiveBasicClientConsole application calling the production system
After some initial problems with content-types the Swarm Hive API (not tried the Delivery-API yet) appears to be documented and easy to use. Though, some of the variable type choices do seem a bit odd.
public virtual async System.Threading.Tasks.Task<string> GetDeviceCountAsync(int? devicetype, System.Threading.CancellationToken cancellationToken)
'---------------------------------------------------------------------------------
' Copyright (c) November 2022, devMobile Software
'
' Licensed under the Apache License, Version 2.0 (the "License");
' you may Not use this file except in compliance with the License.
' You may obtain a copy of the License at
'
' http://www.apache.org/licenses/LICENSE-2.0
'
' Unless required by applicable law Or agreed to in writing, software
' distributed under the License Is distributed on an "AS IS" BASIS,
' WITHOUT WARRANTIES Or CONDITIONS OF ANY KIND, either express Or implied.
' See the License for the specific language governing permissions And
' limitations under the License.
'
'---------------------------------------------------------------------------------
Imports System.Threading
Imports Microsoft.Azure.WebJobs
Imports Microsoft.Extensions.Logging
Public Class TimerTrigger
Shared executionCount As Int32
<FunctionName("Timer")>
Public Shared Sub Run(<TimerTrigger("0 */1 * * * *")> myTimer As TimerInfo, log As ILogger)
Interlocked.Increment(executionCount)
log.LogInformation("VB.Net .NET V6 TimerTrigger next trigger:{0} Execution count:{1}", myTimer.ScheduleStatus.Next, executionCount)
End Sub
End Class
Visual Studio 2022 highlighting missing libraries
Visual Studio 2022 with additional function SDK references
The next step is to add the hosts.json(empty for timer tigger) and localsettings.json to configure the function
Visual 2022 Hosts.json file
Visual Studio 2022 showing hosts.json & local.settings.json
“Can’t determine project language from files. Please add one of [–csharp, –javascript, –typescript, –java, –powershell, –customer]
Check “FUNCTIONS_WORKER_RUNTIME” in the local.settings.json file.
The baked in error logging doesn’t handle broken message formats very well. Look at the call stack or single step through the application to find the message format that is broken
Visual Studio 2022 editor with malformed message highlighted
The theme of this post is controlling users’ ability to read and write rows in a table. The best scenario I could come up with using the World Wide Importers database was around controlling access to Customer information.
This would be a representative set of “project requirements”…
Salespeople tend to look after categories of Customers
Kayla – Novelty Shops
Hudson – Supermarkets
Issabella – Computer Stores
Sophia – Gift Stores, Novelty Shops
Amy – Corporates
Anthony – Novelty Stores
Alica – Coporates
Stella – Supermarkets
But some Salespeople have direct relationships with Customers
No changes to the database which could break the existing solution
In a previous engagement we added CustomerCategoryPerson and CustomerPerson like tables to the database to control read/write access to Customers’ information.
The CustomerCategoryPerson table links the CustomerCategory and Person tables with a flag (IsWritable) which indicates whether the Person can read/write Customer information for all the Customers in a CustomerCategory.
CREATE TABLE [Sales].[CustomerCategoryPerson](
[CustomerCategoryPersonID] [int] IDENTITY(1,1) NOT NULL,
[CustomerCategoryId] [int] NOT NULL,
[PersonId] [int] NOT NULL,
[IsWritable] [bit] NOT NULL,
[LastUpdatedBy] [int] NOT NULL,
CONSTRAINT [PK_CustomerCategoryPerson] PRIMARY KEY CLUSTERED
(
[CustomerCategoryPersonID] ASC
)...
The CustomerPerson table links the Customer and Person tables with a flag (IsWritable) which indicates whether a Person can read/write a Customer’s information.
CREATE TABLE [Sales].[CustomerPerson](
[CustomerPersonId] [int] IDENTITY(1,1) NOT NULL,
[CustomerID] [int] NOT NULL,
[PersonId] [int] NOT NULL,
[IsWritable] [bit] NOT NULL,
[LastEditedBy] [int] NOT NULL,
CONSTRAINT [PK_CustomerPerson] PRIMARY KEY CLUSTERED
(
[CustomerPersonId] ASC
)...
Users can do “wildcard” searches for Customers and the results set has to be limited to “their” customers and customers in the customer categories they are assigned too.
ALTER PROCEDURE [Sales].[CustomersNameSearchUnionV1]
@UserId as int,
@SearchText nvarchar(20),
@MaximumRowsToReturn int
AS
BEGIN
-- Individual assignment
SELECT TOP(@MaximumRowsToReturn) [Customers].[CustomerID] as "ID", [Customers].[CustomerName] as "Name", [Customers].[IsOnCreditHold] as "IsOnCreditHold"
FROM Sales.Customers
INNER JOIN [Sales].[CustomerPerson] ON ([Sales].[Customers].[CustomerId] = [Sales].[CustomerPerson].[CustomerId])
WHERE ((CustomerName LIKE N'%' + @SearchText + N'%')
AND ([Sales].[CustomerPerson].PersonId = @UserId))
--ORDER BY [CustomerName]
UNION
-- group assignment
SELECT TOP(@MaximumRowsToReturn) [Customers].[CustomerID] as "ID", [Customers].[CustomerName] as "Name", [Customers].[IsOnCreditHold] as "IsOnCreditHold"
FROM [Sales].[Customers]
INNER JOIN [Sales].[CustomerCategories] ON ([Sales].[Customers].[CustomerCategoryID] = [Sales].[CustomerCategories].[CustomerCategoryID])
INNER JOIN [Sales].[CustomerCategoryPerson] ON ([Sales].[Customers].[CustomerCategoryID] = [CustomerCategoryPerson].[CustomerCategoryID])
WHERE ((CustomerName LIKE N'%' + @SearchText + N'%')
AND ([Sales].[CustomerCategoryPerson].PersonId = @UserId))
END;
This approach increases the complexity and reduces the maintainability of stored procedures which have to control the reading/writing of Customer information. Several times I have extracted customer information read\write controls to a couple of database views, one for controlling read access.
CREATE VIEW [Sales].[CustomerPersonReadV1]
AS
-- Individual assignment
SELECT [Sales].[Customers].[CustomerID], [Sales].[CustomerPerson].[PersonID], [Sales].[Customers].[CustomerCategoryID]
FROM [Sales].[Customers]
INNER JOIN [Sales].[CustomerPerson] ON ( [Sales].[Customers].[CustomerID] = CustomerPerson.CustomerID)
UNION -- Takes care of duplicates
-- Group assignment
SELECT [Sales].[Customers].[CustomerID], [Sales].[CustomerCategoryPerson].[PersonID], [Sales].[Customers].[CustomerCategoryID]
FROM [Sales].[Customers]
--INNER JOIN [Sales].[CustomerCategories] ON ([Sales].[Customers].[CustomerCategoryID] = [Sales].[CustomerCategories].[CustomerCategoryID])
INNER JOIN [Sales].[CustomerCategoryPerson] ON ([Sales].[Customers].[CustomerCategoryID] = [CustomerCategoryPerson].[CustomerCategoryID])
The other database for controlling write access
CREATE VIEW [Sales].[CustomerPersonWriteV1]
AS
-- Individual assignment
SELECT [Sales].[Customers].[CustomerID], [Sales].[CustomerPerson].[PersonID], [Sales].[Customers].[CustomerCategoryID]
FROM [Sales].[Customers]
INNER JOIN [Sales].[CustomerPerson] ON (([Sales].[Customers].[CustomerID] = [CustomerPerson].[CustomerID]) AND ([Sales].[CustomerPerson].[IsWritable] = 1))
UNION -- Takes care of duplicates
-- Group assignment
SELECT [Sales].[Customers].[CustomerID], [Sales].[CustomerCategoryPerson].[PersonID], [Sales].[Customers].[CustomerCategoryID]
FROM [Sales].[Customers]
INNER JOIN [Sales].[CustomerCategories] ON ([Sales].[Customers].[CustomerCategoryID] = [Sales].[CustomerCategories].[CustomerCategoryID])
INNER JOIN [Sales].[CustomerCategoryPerson] ON ([Sales].[Customers].[CustomerCategoryID] = [CustomerCategoryPerson].[CustomerCategoryID] AND ([Sales].[CustomerCategoryPerson].[IsWritable] = 1))
The versioning of database views uses the same approach as stored procedures. When a view is updated (the columns returned changes , updated constraints etc.) the version number is incremented. Then we work through the dependencies list checking and updating the view version used and re-testing.
SQL Server Management Studio displaying objects which depend on the view
These two views are the UNION of the users individual and group access permissions. (If a user has Write they also have Read access). This reduces the complexity of stored procedures used for reading from and writing to the Customer table.
CREATE PROCEDURE [Sales].[CustomersListV1]
@UserId as int
AS
BEGIN
SELECT [Customers].[CustomerID] as "ID", [Customers].[CustomerName] as "Name", [Customers].[IsOnCreditHold] as "IsOnCreditHold"
FROM [Sales].[Customers]
INNER JOIN [Sales].[CustomerPersonReadV1] ON ([Sales].[Customers].[CustomerID] = [Sales].[CustomerPersonReadV1].CustomerID)
WHERE ([Sales].[CustomerPersonReadV1].PersonId = @UserId)
ORDER BY Name
END
The GET method of the Customer controller returns a list of all the Customers the current user has read only access to using their individual and group assignment.
[HttpGet(), Authorize(Roles = "SalesPerson,SalesAdministrator")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<Models.CustomerListDtoV1>))]
public async Task<ActionResult<IEnumerable<Models.CustomerListDtoV1>>> Get()
{
IEnumerable<Models.CustomerListDtoV1> response;
using (SqlConnection db = new SqlConnection(this.connectionString))
{
response = await db.QueryWithRetryAsync<Models.CustomerListDtoV1>(sql: "[Sales].[CustomersListV1]", param: new { userId = HttpContext.PersonId() }, commandType: CommandType.StoredProcedure);
}
return this.Ok(response);
}
The CustomerPersonWriteV1 view is used to stop users without IsWritable set (individual or group) updating a Customers IsOnCreditHold flag.
CREATE PROCEDURE [Sales].[CustomerCreditHoldStatusUpdateV1]
@UserID int,
@CustomerId int,
@IsOnCreditHold Bit
AS
BEGIN
UPDATE [Sales].[Customers]
SET IsOnCreditHold = @IsOnCreditHold, LastEditedBy = @UserID
FROM [Sales].[Customers]
INNER JOIN [Sales].[CustomerPersonWriteV1] ON ([Sales].[Customers].[CustomerID] = [Sales].[CustomerPersonWriteV1].CustomerID)
WHERE (([Sales].[CustomerPersonWriteV1].PersonId = @UserId)
AND ([Sales].[Customers].[CustomerID] = @CustomerId )
AND (IsOnCreditHold <> @IsOnCreditHold))
END
The PUT CreditHold method uses a combination of roles (Aministrator,SalesAdministrator,SalesPerson) and database views (CustomerPersonWriteV1) to control the updating of customer data.
[HttpPut("{customerId}/CreditStatus", Name ="CreditHold")]
[Authorize(Roles = "Aministrator,SalesAdministrator,SalesPerson")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CustomerCeditHold(int customerId, [FromBody] Models.CustomerCreditHoldUpdateV1 request )
{
request.UserId = HttpContext.PersonId();
request.CustomerId = customerId;
using (SqlConnection db = new SqlConnection(connectionString))
{
if (await db.ExecuteWithRetryAsync("[Sales].[CustomerCreditHoldStatusUpdateV1]", param: request, commandType: CommandType.StoredProcedure) != 1)
{
logger.LogWarning("Person {UserId} Customer {CustomerId} IsOnCreditHold {IsOnCreditHold} update failed", request.UserId, request.CustomerId, request.IsOnCreditHold);
return this.Conflict();
}
}
return this.Ok();
}
My customers usually don’t have a lot of automated testing so minimising the impact of changes across the database and codebase is critical. Sometimes we duplicate code (definitely not DRY) so that the amount of functionality that has to be retested is reduced. We ensure this is time allocated for revisiting these decisions and remediating as required.
The permissions required for an on-premises system running in a trusted environment are often minimalist. The World Wide Importers database People table has IsSystemUser, IsEmployee, IsSalesperson which is representative of the granularity of permissions I have encountered in Windows Forms .NET, ASP.NET Web Forms and other “legacy” applications.
CREATE TABLE [Application].[People](
[PersonID] [int] NOT NULL,
[FullName] nvarchar NOT NULL,
[PreferredName] nvarchar NOT NULL,
[SearchName] AS (concat([PreferredName],N' ',[FullName])) PERSISTED NOT NULL,
[IsPermittedToLogon] [bit] NOT NULL,
[LogonName] nvarchar NULL,
[IsExternalLogonProvider] [bit] NOT NULL,
[HashedPassword] varbinary NULL,
[IsSystemUser] [bit] NOT NULL,
[IsEmployee] [bit] NOT NULL,
[IsSalesperson] [bit] NOT NULL,
[UserPreferences] nvarchar NULL,
[PhoneNumber] nvarchar NULL,
[FaxNumber] nvarchar NULL,
[EmailAddress] nvarchar NULL,
[Photo] varbinary NULL,
[CustomFields] nvarchar NULL,
[OtherLanguages] AS (json_query([CustomFields],N'$.OtherLanguages')),
[LastEditedBy] [int] NOT NULL,
[ValidFrom] datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
[ValidTo] datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
CONSTRAINT [PK_Application_People] PRIMARY KEY CLUSTERED
(
[PersonID] ASC
)
The existing application appears to have a method for a Person to change their password which calls the [Website].[ChangePassword] stored procedure (I was surprised that the stored procedure didn’t set the LastEditedBy value).
CREATE PROCEDURE [Website].[ChangePassword]
@PersonID int,
@OldPassword nvarchar(40),
@NewPassword nvarchar(40)
WITH EXECUTE AS OWNER
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
UPDATE [Application].People
SET IsPermittedToLogon = 1,
HashedPassword = HASHBYTES(N'SHA2_256', @NewPassword + FullName)
WHERE PersonID = @PersonID
AND PersonID <> 1
AND HashedPassword = HASHBYTES(N'SHA2_256', @OldPassword + FullName);
IF @@ROWCOUNT = 0
BEGIN
PRINT N'The PersonID must be valid, and the old password must be valid.';
PRINT N'If the user has also changed name, please contact the IT staff to assist.';
THROW 51000, N'Invalid Password Change', 1;
RETURN -1;
END;
END;
The new version removes the PersonId special case (Assumed that PersonId 1 can’t logon and the use of Throw). I think the use of Throw can add significant complexity to the exception handling of the WebAPI controller that calls the stored procedure
ALTER PROCEDURE [Website].[PersonPasswordChangeV1]
@UserID int,
@PasswordOld nvarchar(40),
@PasswordNew nvarchar(40)
WITH EXECUTE AS OWNER
AS
BEGIN
UPDATE [Application].People
SET IsPermittedToLogon = 1
,HashedPassword = HASHBYTES(N'SHA2_256', @PasswordNew + FullName)
,LastEditedBy = @UserID
WHERE ((PersonID = @UserID )
AND (HashedPassword = HASHBYTES(N'SHA2_256', @PasswordOld + FullName)))
END;
The PasswordChange method of the Person Controller only requires the caller to be authenticated.
/// <summary>
/// Changes current user's password.
/// </summary>
/// <param name="request">Current password and new password</param>
/// <response code="200">Password changed.</response>
/// <response code="401">Unauthorised, bearer token missing or expired.</response>
/// <response code="409">Previous password invalid or User name has changed.</response>
[Authorize()]
[HttpPut(Name = "PasswordChange")]
public async Task<ActionResult> PasswordChange([FromBody] Models.PersonPasswordChangeRequest request)
{
request.UserID = HttpContext.PersonId();
using (SqlConnection db = new SqlConnection(connectionString))
{
if (await db.ExecuteWithRetryAsync("[WebSite].[PersonPasswordChangeV1]", param: request, commandType: CommandType.StoredProcedure) != 1)
{
logger.LogWarning("Person {0} password change failed", request.UserID);
return this.Conflict();
}
}
return this.Ok();
}
The new application will have functionality for resetting a Person’s password. Access to this functionality will be restricted to people with the “Administrator” and “PasswordReset” roles.
CREATE PROCEDURE [Website].[PersonPasswordResetV1]
@UserID int,
@PersonID int,
@Password nvarchar(40)
WITH EXECUTE AS OWNER
AS
BEGIN
UPDATE [Application].People
SET IsPermittedToLogon = 1
,HashedPassword = HASHBYTES(N'SHA2_256', @Password + FullName)
,LastEditedBy = @UserID
WHERE PersonID = @PersonID
END;
One of the conventions we often use, is that the first parameter of any stored procedure that is called once a User has logged on is their unique identifier which is used for data access permissions and change tracking.
[Authorize(Roles = "Administrator")]
[HttpPut("{personId:int}", Name = "PasswordReset")]
public async Task<ActionResult> PasswordReset([Range(1, int.MaxValue, ErrorMessage = "Person id must greater than 1")] int personId, [FromBody] Models.PersonPasswordResetRequest request)
{
request.UserId = HttpContext.PersonId();
request.PersonID = personId;
using (SqlConnection db = new SqlConnection(connectionString))
{
if (await db.ExecuteWithRetryAsync("[WebSite].[PersonPasswordResetV1]", param: request, commandType: CommandType.StoredProcedure) != 1)
{
logger.LogWarning("Person {0} password change failed", request.PersonID);
return this.Conflict();
}
}
return this.Ok();
}
For a couple of applications, we have added “Permissions” and “PersonPermissions” tables alongside the existing authorisation functionality to reduce the likely hood of any unintended side effects.
CREATE TABLE [Application].[Permissions](
[PermissionID] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](20) NOT NULL,
[Description] [nvarchar](50) NOT NULL,
[LastEditedBy] [int] NOT NULL,
[ValidFrom] [datetime2](7) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL,
[ValidUntil] [datetime2](7) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL,
CONSTRAINT [PK_Permissions] PRIMARY KEY CLUSTERED
(
[PermissionID] ASC
)...
CREATE TABLE [Application].[PersonPermissions](
[PersonPermissionId] [int] IDENTITY(1,1) NOT NULL,
[PersonId] [int] NOT NULL,
[PermIssionId] [int] NOT NULL,
[Active] [bit] NOT NULL,
[ValidFrom] [datetime2](7) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL,
[ValidUntil] [datetime2](7) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL,
CONSTRAINT [PK_PersonPermissions] PRIMARY KEY CLUSTERED
(
[PersonPermissionId] ASC
)...
Permissions, PersonPermissions and People with Foreign Keys
The additional permissions (from the Person record) and the PersonPermissions table required some modifications to the PersonAuthenticateLookupByLogonNameV1 stored procedure and the addition of the PersonPermissionsByPersonIdV1 stored procedure.
ALTER PROCEDURE [Website].[PersonAuthenticateLookupByLogonNameV2]
@LogonName nvarchar(50),
@Password nvarchar(40)
AS
BEGIN
SELECT PersonID
,FullName
,EmailAddress
,IsSystemUser
,IsEmployee
,IsSalesPerson
FROM [Application].[People]
WHERE (( LogonName = @LogonName)
AND (IsPermittedToLogon = 1)
AND (HASHBYTES(N'SHA2_256', @Password + FullName) = HashedPassword))
The IsSystemUser, IsEmployee and IsSalesPerson bit flags were added to the stored procedure and Data Transfer Object(DTO)
private class PersonAuthenticateLogonDetailsDto
{
public int PersonID { get; set; }
public string FullName { get; set; }
public string EmailAddress { get; set; }
public bool IsSystemUser { get; set; }
public bool IsEmployee { get; set; }
public bool IsSalesPerson { get; set; }
}
The PersonPermissionsByPersonIdV1 retrieves a list of the permissions of the User who has been authenticated.
ALTER PROCEDURE [Website].[PersonPermissionsByPersonIdV1]
@PersonId AS int
AS
BEGIN
SELECT [Application].[Permissions].[Name]
FROM [Application].[Permissions]
INNER JOIN [Application].[PersonPermissions] ON ([Application].[Permissions].PermissionID = [Application].[PersonPermissions].[PermissionId] )
WHERE [Application].[PersonPermissions].[PersonId] = @PersonId
ORDER BY [Application].[Permissions].[Name]
END
The Person’s permissions(effectively roles) are added as claims, the IsSystemUser, IsEmployee and IsSalesPerson flags are also added to the list of claims so they can be used in the new application.
[HttpPost("logon")]
public async Task<ActionResult> Logon([FromBody] Models.LogonRequest request )
{
PersonAuthenticateLogonDetailsDto userLogonUserDetails;
IEnumerable<string> permissions;
var claims = new List<Claim>();
using (SqlConnection db = new SqlConnection(configuration.GetConnectionString("WorldWideImportersDatabase")))
{
userLogonUserDetails = await db.QuerySingleOrDefaultWithRetryAsync<PersonAuthenticateLogonDetailsDto>("[Website].[PersonAuthenticateLookupByLogonNameV2]", param: request, commandType: CommandType.StoredProcedure);
if (userLogonUserDetails == null)
{
logger.LogWarning("Login attempt by user {0} failed", request.LogonName);
return this.Unauthorized();
}
// Lookup the Person's permissions
permissions = await db.QueryWithRetryAsync<string>("[Website].[PersonPermissionsByPersonIdV1]", new { userLogonUserDetails.PersonID }, commandType: CommandType.StoredProcedure);
}
// Setup the primary SID + name info
claims.Add(new Claim(ClaimTypes.PrimarySid, userLogonUserDetails.PersonID.ToString()));
if (userLogonUserDetails.IsSystemUser)
{
claims.Add(new Claim(ClaimTypes.Role, "SystemUser"));
}
if (userLogonUserDetails.IsEmployee)
{
claims.Add(new Claim(ClaimTypes.Role, "Employee"));
}
if (userLogonUserDetails.IsSalesPerson)
{
claims.Add(new Claim(ClaimTypes.Role, "SalesPerson"));
}
foreach(string permission in permissions)
{
claims.Add(new Claim(ClaimTypes.Role, permission));
}
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtIssuerOptions.SecretKey));
var token = new JwtSecurityToken(
issuer: jwtIssuerOptions.Issuer,
audience: jwtIssuerOptions.Audience,
expires: DateTime.UtcNow.Add(jwtIssuerOptions.TokenExpiresAfter),
claims: claims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256));
return this.Ok(new
{
token = new JwtSecurityTokenHandler().WriteToken(token),
expiration = token.ValidTo,
});
}
We try to reduce the number of roles a User requires by having core roles (Administrator, Sales consultant, Warehouse administrator etc.) with additional roles for each task that can be added as required (ResetPassword, CustomerIsOnCreditHold Set/Clear etc.)
/// <summary>
/// Gets a list of the current User's roles.
/// </summary>
/// <response code="200">List of claims returned.</response>
/// <response code="401">Unauthorised, bearer token missing or expired.</response>
/// <returns>list of claims.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<string>))]
public List<string> Get()
{
List<string> claimNames = new List<string>();
foreach (var claim in this.User.Claims.Where(c => c.Type == ClaimTypes.Role))
{
claimNames.Add(claim.Value);
}
return claimNames;
}
We have found this approach to be a robust way to add granular authorisation for new functionality to a “legacy’ application without breaking the existing solution. I have ignored a user being disabled after a number of failed logins, password complexity rules etc. as these tend to be application specific and not really related to the use of Dapper.
Most blog posts talk about building “green fields” applications, I have found hardly any cover “muddy fields” development where you have to deal with “legacy” code.
Not all “legacy” code is bad, I work on one code base which is nearly 20years old. It started as a spreadsheet plug-in and has grown of time to a SaaS application. There is very little of the original code left it has just been carefully re-factored over the years with time allocated to chip away at technical debt.
I have started with a “nasty” Proof of Concept(PoC) to figure out how to connect to the Swarm Hive API.
The Swarm Hive API has been published with Swagger/OpenAPI which is really simple to use. I used NSwagStudio to generate a C# client to I didn’t have to “handcraft” one.
Initially the code would compile but I found a clue in a Github Issue from September 2017 which was to change the “Operation Generation Model” to SingleClientFromOperationId.(The setting is highlighted above).
I tried a couple of ways to attach the Swarm Hive API authorisation token (returned by the Login method) to client requests. After a couple for failed attempts, I “realised” that adding the “Authorization” header to the HttpClientdefaultRequestHeaders was by far the simplest approach.
My “nasty” console application calls the Login method, then requests the number of devices (I only have one), gets a list of the properties of all the devices(very short list) then gets the User Context and displays their ID, Name and Country.