Skip to content

Commit 076fbd6

Browse files
authored
Encrypted Value Support (#3)
* added support for encrypted vlaue columns * update comments * pr suggestions * added tests for encrypted fields
1 parent 9a0e24c commit 076fbd6

File tree

8 files changed

+162
-9
lines changed

8 files changed

+162
-9
lines changed

src/Searchlight/Attributes/SearchlightField.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,14 @@ public class SearchlightField : Attribute
4444
/// - Is (Not) Null
4545
/// </summary>
4646
public bool IsJson { get; set; } = false;
47+
48+
/// <summary>
49+
/// (optional) Set to true if the database column is encrypted.
50+
///
51+
/// If the column is encrypted, the Searchlight engine will use the provided ISearchlightStringEncryptor to encrypt the value before querying.
52+
/// The column must be of type string.
53+
/// Encrypted columns can only use equality, nullity, or in operators.
54+
/// </summary>
55+
public bool IsEncrypted { get; set; } = false;
4756
}
4857
}

src/Searchlight/DataSource.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public class DataSource
6565
/// <returns></returns>
6666
public DataSource WithColumn(string columnName, Type columnType)
6767
{
68-
return WithRenamingColumn(new ColumnInfo(columnName, columnName, null, columnType, null, null, false));
68+
return WithRenamingColumn(new ColumnInfo(columnName, columnName, null, columnType, null, null, false, false));
6969
}
7070

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

208208
var collection = pi.GetCustomAttributes<SearchlightCollection>().FirstOrDefault();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace Searchlight.Encryption
2+
{
3+
/// <summary>
4+
/// An interface for encrypting strings prior to being used in a query filter.
5+
/// </summary>
6+
public interface ISearchlightStringEncryptor
7+
{
8+
/// <summary>
9+
/// A method to encrypt a string using the same algorithm used to store the encrypted data.
10+
/// </summary>
11+
/// <param name="plainText"></param>
12+
/// <returns></returns>
13+
string Encrypt(string plainText);
14+
}
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#pragma warning disable CS1591
2+
namespace Searchlight.Exceptions
3+
{
4+
/// <summary>
5+
/// The operation used in the filter on a given field is not supported.
6+
///
7+
/// Example: `(someField gt 5)` where `someField` is an encrypted field.
8+
/// </summary>
9+
public class InvalidOperation : SearchlightException
10+
{
11+
public string OriginalFilter { get; internal set; }
12+
13+
public string FieldName { get; internal set; }
14+
15+
public string Operation { get; internal set; }
16+
17+
public string ErrorMessage
18+
{
19+
get =>
20+
$"The query filter, {OriginalFilter}, uses {Operation} on {FieldName} which is not supported.";
21+
}
22+
}
23+
}

src/Searchlight/Parsing/ColumnInfo.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ public class ColumnInfo
1717
/// <param name="enumType">The type of the enum that the column is mapped to</param>
1818
/// <param name="description">A description of the column for autocomplete</param>
1919
/// <param name="isJson"></param>
20-
public ColumnInfo(string filterName, string columnName, string[] aliases, Type columnType, Type enumType, string description, bool isJson)
20+
/// <param name="isEncrypted">Is the column an encrypted column</param>
21+
public ColumnInfo(string filterName, string columnName, string[] aliases, Type columnType, Type enumType, string description, bool isJson, bool isEncrypted)
2122
{
2223
FieldName = filterName;
2324
OriginalName = columnName;
@@ -31,6 +32,12 @@ public ColumnInfo(string filterName, string columnName, string[] aliases, Type c
3132
EnumType = enumType;
3233
Description = description;
3334
IsJson = isJson;
35+
36+
if(isEncrypted && columnType != typeof(string))
37+
{
38+
throw new ArgumentException($"Field {FieldName} is marked as encrypted but is not of type string. Encrypted columns must be of type string", nameof(isEncrypted));
39+
}
40+
IsEncrypted = isEncrypted;
3441
}
3542

3643
/// <summary>
@@ -68,5 +75,10 @@ public ColumnInfo(string filterName, string columnName, string[] aliases, Type c
6875
/// (optional) Set to true if the database column is storing JSON.
6976
/// </summary>
7077
public bool IsJson { get; set; } = false;
78+
79+
/// <summary>
80+
/// (optional) Set to true if the database column is encrypted.
81+
/// </summary>
82+
public bool IsEncrypted { get; set; } = false;
7183
}
7284
}

src/Searchlight/Parsing/SyntaxParser.cs

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ namespace Searchlight.Parsing
1313
/// </summary>
1414
public static class SyntaxParser
1515
{
16+
private static readonly OperationType[] EncryptionOperations = new OperationType[]
17+
{
18+
OperationType.Equals,
19+
OperationType.IsNull,
20+
OperationType.In
21+
};
22+
1623
/// <summary>
1724
/// Shortcut for Parse using a syntax tree.
1825
/// </summary>
@@ -410,6 +417,17 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
410417
{
411418
return null;
412419
}
420+
421+
if (columnInfo.IsEncrypted && !EncryptionOperations.Contains(op))
422+
{
423+
syntax.AddError(new InvalidOperation()
424+
{
425+
OriginalFilter = tokens.OriginalText,
426+
FieldName = columnInfo.FieldName,
427+
Operation = op.ToString()
428+
});
429+
return null;
430+
}
413431

414432
switch (op)
415433
{
@@ -419,11 +437,11 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
419437
{
420438
Negated = negated,
421439
Column = columnInfo,
422-
LowerValue = ParseParameter(syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens),
440+
LowerValue = ParseParameter(source, syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens),
423441
JsonKeys = jsonKeys.ToArray()
424442
};
425443
syntax.Expect(StringConstants.AND, tokens.TokenQueue.Dequeue().Value, tokens.OriginalText);
426-
b.UpperValue = ParseParameter(syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens);
444+
b.UpperValue = ParseParameter(source, syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens);
427445
return b;
428446

429447
// Safe syntax for an "IN" expression is "column IN (param[, param][, param]...)"
@@ -441,7 +459,7 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
441459
{
442460
while (tokens.TokenQueue.Count > 1)
443461
{
444-
i.Values.Add(ParseParameter(syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens));
462+
i.Values.Add(ParseParameter(source, syntax, columnInfo, tokens.TokenQueue.Dequeue().Value, tokens));
445463
var commaOrParen = tokens.TokenQueue.Dequeue();
446464
syntax.Expect(StringConstants.SAFE_LIST_TOKENS, commaOrParen.Value, tokens.OriginalText);
447465
if (commaOrParen.Value == StringConstants.CLOSE_PARENTHESIS) break;
@@ -479,7 +497,7 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
479497
Negated = negated,
480498
Operation = op,
481499
Column = columnInfo,
482-
Value = ParseParameter(syntax, columnInfo, valueToken.Value, tokens),
500+
Value = ParseParameter(source, syntax, columnInfo, valueToken.Value, tokens),
483501
JsonKeys = jsonKeys.ToArray()
484502
};
485503

@@ -503,7 +521,7 @@ private static BaseClause ParseOneClause(SyntaxTree syntax, DataSource source, T
503521
/// <summary>
504522
/// Parse one value out of a token
505523
/// </summary>
506-
private static IExpressionValue ParseParameter(SyntaxTree syntax, ColumnInfo column, string valueToken, TokenStream tokens)
524+
private static IExpressionValue ParseParameter(DataSource source, SyntaxTree syntax, ColumnInfo column, string valueToken, TokenStream tokens)
507525
{
508526
var fieldType = column.FieldType;
509527
try
@@ -591,11 +609,26 @@ private static IExpressionValue ParseParameter(SyntaxTree syntax, ColumnInfo col
591609
}
592610
}
593611

612+
if (column.IsEncrypted)
613+
{
614+
if (source.Engine.Encryptor == null)
615+
{
616+
throw new NullReferenceException("No encryptor was provided to the Searchlight engine");
617+
}
618+
619+
return ConstantValue.From(Convert.ChangeType(source.Engine.Encryptor.Encrypt(valueToken), fieldType));
620+
}
621+
594622
// All other types use a basic type changer
595623
return ConstantValue.From(Convert.ChangeType(valueToken, fieldType));
596624
}
597-
catch
625+
catch (Exception ex)
598626
{
627+
if (ex.GetType() == typeof(NullReferenceException))
628+
{
629+
throw;
630+
}
631+
599632
syntax.AddError(new FieldTypeMismatch {
600633
FieldName = column.FieldName,
601634
FieldType = fieldType.ToString(),

src/Searchlight/SearchlightEngine.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Reflection;
55
using Searchlight.Autocomplete;
6+
using Searchlight.Encryption;
67
using Searchlight.Exceptions;
78
using Searchlight.Parsing;
89
using Searchlight.Query;
@@ -64,6 +65,11 @@ public class SearchlightEngine
6465
/// DEFAULT: True.
6566
/// </summary>
6667
public bool useNoCount { get; set; } = true;
68+
69+
/// <summary>
70+
/// Encryption implementation to use for encrypted fields.
71+
/// </summary>
72+
public ISearchlightStringEncryptor Encryptor { get; set; } = null;
6773

6874
/// <summary>
6975
/// Adds a new class to the engine

tests/Searchlight.Tests/ParseModelTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Searchlight.Exceptions;
77
using Searchlight.Expressions;
88
using Searchlight.Tests.Models;
9+
using Searchlight.Encryption;
910

1011
namespace Searchlight.Tests
1112
{
@@ -543,5 +544,59 @@ public void TestValidEnumFilters()
543544
CollectionAssert.AreEqual(new string[] { "None", "Special", "Generic" }, ex2.ExpectedTokens);
544545
Assert.AreEqual("The filter statement contained an unexpected token, 'InvalidValue'. Searchlight expects to find one of these next: None, Special, Generic", ex2.ErrorMessage);
545546
}
547+
548+
[SearchlightModel(DefaultSort = nameof(Name))]
549+
public class TestEncrypted
550+
{
551+
[SearchlightField(IsEncrypted = true)]
552+
public string Name { get; set; }
553+
}
554+
555+
public class TestEncryptor : ISearchlightStringEncryptor
556+
{
557+
public string Encrypt(string plainText)
558+
{
559+
return "encrypted" + plainText;
560+
}
561+
}
562+
563+
[TestMethod]
564+
public void Test_EncryptedField_NoEncryptionAddedToEngine()
565+
{
566+
var engine = new SearchlightEngine().AddClass(typeof(TestEncrypted));
567+
568+
var source = engine.FindTable("TestEncrypted");
569+
Assert.ThrowsException<NullReferenceException>(() => source.ParseFilter("Name eq 'test'"));
570+
}
571+
572+
[TestMethod]
573+
public void Test_EncryptedField_InvalidOperation()
574+
{
575+
var engine = new SearchlightEngine
576+
{
577+
Encryptor = new TestEncryptor()
578+
}.AddClass(typeof(TestEncrypted));
579+
580+
var source = engine.FindTable("TestEncrypted");
581+
Assert.ThrowsException<InvalidOperation>(() => source.ParseFilter("Name > 'test'"));
582+
}
583+
584+
585+
[TestMethod]
586+
public void TestEncryptedFieldFilter()
587+
{
588+
var engine = new SearchlightEngine
589+
{
590+
Encryptor = new TestEncryptor()
591+
}.AddClass(typeof(TestEncrypted));
592+
593+
var source = engine.FindTable("TestEncrypted");
594+
var syntax = source.ParseFilter("Name eq 'test'");
595+
596+
Assert.IsNotNull(syntax);
597+
var cc = syntax.Filter[0] as CriteriaClause;
598+
599+
Assert.AreEqual("encryptedtest", cc.Value.GetValue());
600+
}
546601
}
547602
}

0 commit comments

Comments
 (0)