Skip to content

Commit

Permalink
Encrypted Value Support (#3)
Browse files Browse the repository at this point in the history
* added support for encrypted vlaue columns

* update comments

* pr suggestions

* added tests for encrypted fields
  • Loading branch information
amullan-sage authored Feb 27, 2024
1 parent 9a0e24c commit 076fbd6
Show file tree
Hide file tree
Showing 8 changed files with 162 additions and 9 deletions.
9 changes: 9 additions & 0 deletions src/Searchlight/Attributes/SearchlightField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,14 @@ public class SearchlightField : Attribute
/// - Is (Not) Null
/// </summary>
public bool IsJson { get; set; } = false;

/// <summary>
/// (optional) Set to true if the database column is encrypted.
///
/// If the column is encrypted, the Searchlight engine will use the provided ISearchlightStringEncryptor to encrypt the value before querying.
/// The column must be of type string.
/// Encrypted columns can only use equality, nullity, or in operators.
/// </summary>
public bool IsEncrypted { get; set; } = false;
}
}
4 changes: 2 additions & 2 deletions src/Searchlight/DataSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public class DataSource
/// <returns></returns>
public DataSource WithColumn(string columnName, Type columnType)
{
return WithRenamingColumn(new ColumnInfo(columnName, columnName, null, columnType, null, null, false));
return WithRenamingColumn(new ColumnInfo(columnName, columnName, null, columnType, null, null, false, false));
}

/// <summary>
Expand Down Expand Up @@ -202,7 +202,7 @@ public static DataSource Create(SearchlightEngine engine, Type modelType, Attrib
var t = filter.FieldType ?? pi.PropertyType;
var columnName = filter.OriginalName ?? pi.Name;
var aliases = filter.Aliases ?? Array.Empty<string>();
src.WithRenamingColumn(new ColumnInfo(pi.Name, columnName, aliases, t, filter.EnumType, filter.Description, filter.IsJson));
src.WithRenamingColumn(new ColumnInfo(pi.Name, columnName, aliases, t, filter.EnumType, filter.Description, filter.IsJson, filter.IsEncrypted));
}

var collection = pi.GetCustomAttributes<SearchlightCollection>().FirstOrDefault();
Expand Down
15 changes: 15 additions & 0 deletions src/Searchlight/Encryption/ISearchlightStringEncryptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Searchlight.Encryption
{
/// <summary>
/// An interface for encrypting strings prior to being used in a query filter.
/// </summary>
public interface ISearchlightStringEncryptor
{
/// <summary>
/// A method to encrypt a string using the same algorithm used to store the encrypted data.
/// </summary>
/// <param name="plainText"></param>
/// <returns></returns>
string Encrypt(string plainText);
}
}
23 changes: 23 additions & 0 deletions src/Searchlight/Exceptions/InvalidOperation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#pragma warning disable CS1591
namespace Searchlight.Exceptions
{
/// <summary>
/// The operation used in the filter on a given field is not supported.
///
/// Example: `(someField gt 5)` where `someField` is an encrypted field.
/// </summary>
public class InvalidOperation : SearchlightException
{
public string OriginalFilter { get; internal set; }

public string FieldName { get; internal set; }

public string Operation { get; internal set; }

public string ErrorMessage
{
get =>
$"The query filter, {OriginalFilter}, uses {Operation} on {FieldName} which is not supported.";
}
}
}
14 changes: 13 additions & 1 deletion src/Searchlight/Parsing/ColumnInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public class ColumnInfo
/// <param name="enumType">The type of the enum that the column is mapped to</param>
/// <param name="description">A description of the column for autocomplete</param>
/// <param name="isJson"></param>
public ColumnInfo(string filterName, string columnName, string[] aliases, Type columnType, Type enumType, string description, bool isJson)
/// <param name="isEncrypted">Is the column an encrypted column</param>
public ColumnInfo(string filterName, string columnName, string[] aliases, Type columnType, Type enumType, string description, bool isJson, bool isEncrypted)
{
FieldName = filterName;
OriginalName = columnName;
Expand All @@ -31,6 +32,12 @@ public ColumnInfo(string filterName, string columnName, string[] aliases, Type c
EnumType = enumType;
Description = description;
IsJson = isJson;

if(isEncrypted && columnType != typeof(string))
{
throw new ArgumentException($"Field {FieldName} is marked as encrypted but is not of type string. Encrypted columns must be of type string", nameof(isEncrypted));
}
IsEncrypted = isEncrypted;
}

/// <summary>
Expand Down Expand Up @@ -68,5 +75,10 @@ public ColumnInfo(string filterName, string columnName, string[] aliases, Type c
/// (optional) Set to true if the database column is storing JSON.
/// </summary>
public bool IsJson { get; set; } = false;

/// <summary>
/// (optional) Set to true if the database column is encrypted.
/// </summary>
public bool IsEncrypted { get; set; } = false;
}
}
45 changes: 39 additions & 6 deletions src/Searchlight/Parsing/SyntaxParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ namespace Searchlight.Parsing
/// </summary>
public static class SyntaxParser
{
private static readonly OperationType[] EncryptionOperations = new OperationType[]
{
OperationType.Equals,
OperationType.IsNull,
OperationType.In
};

/// <summary>
/// Shortcut for Parse using a syntax tree.
/// </summary>
Expand Down Expand Up @@ -410,6 +417,17 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
{
return null;
}

if (columnInfo.IsEncrypted && !EncryptionOperations.Contains(op))
{
syntax.AddError(new InvalidOperation()
{
OriginalFilter = tokens.OriginalText,
FieldName = columnInfo.FieldName,
Operation = op.ToString()
});
return null;
}

switch (op)
{
Expand All @@ -419,11 +437,11 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
{
Negated = negated,
Column = columnInfo,
LowerValue = ParseParameter(syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens),
LowerValue = ParseParameter(source, syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens),
JsonKeys = jsonKeys.ToArray()
};
syntax.Expect(StringConstants.AND, tokens.TokenQueue.Dequeue().Value, tokens.OriginalText);
b.UpperValue = ParseParameter(syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens);
b.UpperValue = ParseParameter(source, syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens);
return b;

// Safe syntax for an "IN" expression is "column IN (param[, param][, param]...)"
Expand All @@ -441,7 +459,7 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
{
while (tokens.TokenQueue.Count > 1)
{
i.Values.Add(ParseParameter(syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens));
i.Values.Add(ParseParameter(source, syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens));
var commaOrParen = tokens.TokenQueue.Dequeue();
syntax.Expect(StringConstants.SAFE_LIST_TOKENS, commaOrParen.Value, tokens.OriginalText);
if (commaOrParen.Value == StringConstants.CLOSE_PARENTHESIS) break;
Expand Down Expand Up @@ -479,7 +497,7 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
Negated = negated,
Operation = op,
Column = columnInfo,
Value = ParseParameter(syntax, columnInfo, valueToken.Value, tokens),
Value = ParseParameter(source, syntax, columnInfo, valueToken.Value, tokens),
JsonKeys = jsonKeys.ToArray()
};

Expand All @@ -503,7 +521,7 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
/// <summary>
/// Parse one value out of a token
/// </summary>
private static IExpressionValue ParseParameter(SyntaxTree syntax, ColumnInfo column, string valueToken, TokenStream tokens)
private static IExpressionValue ParseParameter(DataSource source, SyntaxTree syntax, ColumnInfo column, string valueToken, TokenStream tokens)
{
var fieldType = column.FieldType;
try
Expand Down Expand Up @@ -591,11 +609,26 @@ private static IExpressionValue ParseParameter(SyntaxTree syntax, ColumnInfo col
}
}

if (column.IsEncrypted)
{
if (source.Engine.Encryptor == null)
{
throw new NullReferenceException("No encryptor was provided to the Searchlight engine");
}

return ConstantValue.From(Convert.ChangeType(source.Engine.Encryptor.Encrypt(valueToken), fieldType));
}

// All other types use a basic type changer
return ConstantValue.From(Convert.ChangeType(valueToken, fieldType));
}
catch
catch (Exception ex)
{
if (ex.GetType() == typeof(NullReferenceException))
{
throw;
}

syntax.AddError(new FieldTypeMismatch {
FieldName = column.FieldName,
FieldType = fieldType.ToString(),
Expand Down
6 changes: 6 additions & 0 deletions src/Searchlight/SearchlightEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Linq;
using System.Reflection;
using Searchlight.Autocomplete;
using Searchlight.Encryption;
using Searchlight.Exceptions;
using Searchlight.Parsing;
using Searchlight.Query;
Expand Down Expand Up @@ -64,6 +65,11 @@ public class SearchlightEngine
/// DEFAULT: True.
/// </summary>
public bool useNoCount { get; set; } = true;

/// <summary>
/// Encryption implementation to use for encrypted fields.
/// </summary>
public ISearchlightStringEncryptor Encryptor { get; set; } = null;

/// <summary>
/// Adds a new class to the engine
Expand Down
55 changes: 55 additions & 0 deletions tests/Searchlight.Tests/ParseModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Searchlight.Exceptions;
using Searchlight.Expressions;
using Searchlight.Tests.Models;
using Searchlight.Encryption;

namespace Searchlight.Tests
{
Expand Down Expand Up @@ -543,5 +544,59 @@ public void TestValidEnumFilters()
CollectionAssert.AreEqual(new string[] { "None", "Special", "Generic" }, ex2.ExpectedTokens);
Assert.AreEqual("The filter statement contained an unexpected token, 'InvalidValue'. Searchlight expects to find one of these next: None, Special, Generic", ex2.ErrorMessage);
}

[SearchlightModel(DefaultSort = nameof(Name))]
public class TestEncrypted
{
[SearchlightField(IsEncrypted = true)]
public string Name { get; set; }
}

public class TestEncryptor : ISearchlightStringEncryptor
{
public string Encrypt(string plainText)
{
return "encrypted" + plainText;
}
}

[TestMethod]
public void Test_EncryptedField_NoEncryptionAddedToEngine()
{
var engine = new SearchlightEngine().AddClass(typeof(TestEncrypted));

var source = engine.FindTable("TestEncrypted");
Assert.ThrowsException<NullReferenceException>(() => source.ParseFilter("Name eq 'test'"));
}

[TestMethod]
public void Test_EncryptedField_InvalidOperation()
{
var engine = new SearchlightEngine
{
Encryptor = new TestEncryptor()
}.AddClass(typeof(TestEncrypted));

var source = engine.FindTable("TestEncrypted");
Assert.ThrowsException<InvalidOperation>(() => source.ParseFilter("Name > 'test'"));
}


[TestMethod]
public void TestEncryptedFieldFilter()
{
var engine = new SearchlightEngine
{
Encryptor = new TestEncryptor()
}.AddClass(typeof(TestEncrypted));

var source = engine.FindTable("TestEncrypted");
var syntax = source.ParseFilter("Name eq 'test'");

Assert.IsNotNull(syntax);
var cc = syntax.Filter[0] as CriteriaClause;

Assert.AreEqual("encryptedtest", cc.Value.GetValue());
}
}
}

0 comments on commit 076fbd6

Please sign in to comment.