Skip to content

Commit

Permalink
Merge #239
Browse files Browse the repository at this point in the history
239: Changes related to the next Meilisearch release (v0.26.0) r=brunoocasali a=meili-bot

Related to this issue: meilisearch/integration-guides#181

This PR:
- gathers the changes related to the next Meilisearch release (v0.26.0) so that this package is ready when the official release is out.
- should pass the tests against the [latest pre-release of Meilisearch](https://github.com/meilisearch/meilisearch/releases).
- might eventually contain test failures until the Meilisearch v0.26.0 is out.

⚠️ This PR should NOT be merged until the next release of Meilisearch (v0.26.0) is out.

_This PR is auto-generated for the [pre-release week](https://github.com/meilisearch/integration-guides/blob/master/guides/pre-release-week.md) purpose._


Co-authored-by: meili-bot <[email protected]>
Co-authored-by: Bruno Casali <[email protected]>
  • Loading branch information
3 people authored Mar 14, 2022
2 parents 787e9cb + 0a8a02c commit e7e58e4
Show file tree
Hide file tree
Showing 12 changed files with 323 additions and 2 deletions.
15 changes: 15 additions & 0 deletions .code-samples.meilisearch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,18 @@ security_guide_list_keys_1: |-
security_guide_delete_key_1: |-
MeilisearchClient client = new MeilisearchClient("http://127.0.0.1:7700", "masterKey");
client.DeleteKeyAsync("d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4")
tenant_token_guide_generate_sdk_1: |-
var apiKey = "B5KdX2MY2jV6EXfUs6scSfmC...";
var expiresAt = new DateTime(2025, 12, 20);
var searchRules = new TenantTokenRules(new Dictionary<string, object> {
{ "patient_medical_records", new Dictionary<string, object> { { "filter", "user_id = 1" } } }
});
token = client.GenerateTenantToken(
searchRules,
apiKey: apiKey // optional,
expiresAt: expiresAt // optional
);
tenant_token_guide_search_sdk_1: |-
frontEndClient = new MeilisearchClient("http://127.0.0.1:7700", token);
SearchResult<Patient> searchResult = await frontEndClient.Index("patient_medical_records").SearchAsync<Patient>("blood test");
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ You need to install the [.NET Core command-line interface (CLI) tools](https://d

```bash
curl -L https://install.meilisearch.com | sh # download Meilisearch
./meilisearch --master-key=masterKey --no-analytics=true # run Meilisearch
./meilisearch --master-key=masterKey --no-analytics # run Meilisearch
dotnet restore
dotnet test
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ JSON Output:

## 🤖 Compatibility with Meilisearch

This package only guarantees the compatibility with the [version v0.25.0 of Meilisearch](https://github.com/meilisearch/meilisearch/releases/tag/v0.25.0).
This package only guarantees the compatibility with the [version v0.26.0 of Meilisearch](https://github.com/meilisearch/meilisearch/releases/tag/v0.26.0).

## 🎬 Examples

Expand Down
19 changes: 19 additions & 0 deletions src/Meilisearch/Errors/MeilisearchTenantTokenApiKeyInvalid.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;

namespace Meilisearch
{
/// <summary>
/// Represents an exception thrown when `apiKey` is not present
/// to sign correctly the Tenant Token generation.
/// </summary>
public class MeilisearchTenantTokenApiKeyInvalid : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MeilisearchTenantTokenApiKeyInvalid"/> class.
/// </summary>
public MeilisearchTenantTokenApiKeyInvalid()
: base("Cannot generate a signed token without a valid apiKey. Provide one in the MeilisearchClient instance or in the method params.")
{
}
}
}
18 changes: 18 additions & 0 deletions src/Meilisearch/Errors/MeilisearchTenantTokenExpired.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace Meilisearch
{
/// <summary>
/// Represents an exception thrown when the provided expiration date is invalid or in the past.
/// </summary>
public class MeilisearchTenantTokenExpired : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MeilisearchTenantTokenExpired"/> class.
/// </summary>
public MeilisearchTenantTokenExpired()
: base("Provide a valid UTC DateTime in the future.")
{
}
}
}
1 change: 1 addition & 0 deletions src/Meilisearch/Meilisearch.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@

<ItemGroup>
<PackageReference Include="System.Net.Http.Json" Version="6.0.0" />
<PackageReference Include="JWT" Version="8.9.0" />
</ItemGroup>
</Project>
18 changes: 18 additions & 0 deletions src/Meilisearch/MeilisearchClient.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
Expand All @@ -18,6 +19,7 @@ public class MeilisearchClient
{
private readonly HttpClient _http;
private TaskEndpoint _taskEndpoint;
public string ApiKey { get; }

/// <summary>
/// Initializes a new instance of the <see cref="MeilisearchClient"/> class.
Expand All @@ -31,6 +33,7 @@ public MeilisearchClient(string url, string apiKey = default)
_http.AddApiKeyToHeader(apiKey);
_http.AddDefaultUserAgent();
_taskEndpoint = null;
ApiKey = apiKey;
}

/// <summary>
Expand All @@ -45,6 +48,7 @@ public MeilisearchClient(HttpClient client, string apiKey = default)
_http = client;
_http.AddApiKeyToHeader(apiKey);
_http.AddDefaultUserAgent();
ApiKey = apiKey;
}

/// <summary>
Expand Down Expand Up @@ -327,6 +331,20 @@ public async Task<bool> DeleteKeyAsync(string keyUid, CancellationToken cancella
return responseMessage.StatusCode == HttpStatusCode.NoContent;
}

/// <summary>
/// Generate a tenant token string to be used during search.
/// </summary>
/// <param name="searchRules">Object with the rules applied in a search call.</param>
/// <param name="apiKey">API Key which signs the generated token.</param>
/// <param name="expiresAt">Date to express how long the generated token will last. If null the token will last forever.</param>
/// <exception cref="MeilisearchTenantTokenApiKeyInvalid">When there is no <paramref name="apiKey"/> defined in the client or as argument.</exception>
/// <exception cref="MeilisearchTenantTokenExpired">When the sent <paramref name="expiresAt"/> param is in the past</exception>
/// <returns>Returns a generated tenant token.</returns>
public string GenerateTenantToken(TenantTokenRules searchRules, string apiKey = null, DateTime? expiresAt = null)
{
return TenantToken.GenerateToken(searchRules, apiKey ?? ApiKey, expiresAt);
}

/// <summary>
/// Create a local reference to a task, without doing an HTTP call.
/// </summary>
Expand Down
41 changes: 41 additions & 0 deletions src/Meilisearch/TenantToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;

using JWT.Algorithms;
using JWT.Builder;

namespace Meilisearch
{
public class TenantToken
{
/// <summary>
/// Generates a Tenant Token in a JWT string format.
/// </summary>
/// <returns>JWT string</returns>
public static string GenerateToken(TenantTokenRules searchRules, string apiKey, DateTime? expiresAt)
{
if (String.IsNullOrEmpty(apiKey) || apiKey.Length < 8)
{
throw new MeilisearchTenantTokenApiKeyInvalid();
}

var builder = JwtBuilder
.Create()
.WithAlgorithm(new HMACSHA256Algorithm())
.AddClaim("apiKeyPrefix", apiKey.Substring(0, 8))
.AddClaim("searchRules", searchRules.ToClaim())
.WithSecret(apiKey);

if (expiresAt.HasValue)
{
if (DateTime.Compare(DateTime.UtcNow, (DateTime)expiresAt) > 0)
{
throw new MeilisearchTenantTokenExpired();
}

builder.AddClaim("exp", ((DateTimeOffset)expiresAt).ToUnixTimeSeconds());
}

return builder.Encode();
}
}
}
32 changes: 32 additions & 0 deletions src/Meilisearch/TenantTokenRules.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Collections.Generic;

namespace Meilisearch
{
/// <summary>
/// Wrapper class used to map all the supported types to be used in
/// the `searchRules` claim in the Tenant Tokens.
/// </summary>
public class TenantTokenRules
{
private object _rules;

public TenantTokenRules(Dictionary<string, object> rules)
{
_rules = rules;
}

public TenantTokenRules(string[] rules)
{
_rules = rules;
}

/// <summary>
/// Accessor method used to retrieve the searchRules claim.
/// </summary>
/// <returns>A object with the supported type representing the `searchRules`.</returns>
public object ToClaim()
{
return _rules;
}
}
}
8 changes: 8 additions & 0 deletions tests/Meilisearch.Tests/ServerConfigs/BaseUriServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,13 @@ public TaskInfoTests(ConfigFixture fixture) : base(fixture)
{
}
}

[Collection(CollectionFixtureName)]
public class TenantTokenTests : TenantTokenTests<ConfigFixture>
{
public TenantTokenTests(ConfigFixture fixture) : base(fixture)
{
}
}
}
}
8 changes: 8 additions & 0 deletions tests/Meilisearch.Tests/ServerConfigs/ProxiedUriServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,13 @@ public TaskInfoTests(ConfigFixture fixture) : base(fixture)
{
}
}

[Collection(CollectionFixtureName)]
public class TenantTokenTests : TenantTokenTests<ConfigFixture>
{
public TenantTokenTests(ConfigFixture fixture) : base(fixture)
{
}
}
}
}
161 changes: 161 additions & 0 deletions tests/Meilisearch.Tests/TenantTokenTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

using JWT.Algorithms;
using JWT.Builder;
using JWT.Exceptions;

using Xunit;

namespace Meilisearch.Tests
{
public abstract class TenantTokenTests<TFixture> : IAsyncLifetime where TFixture : IndexFixture
{
private TenantTokenRules _searchRules = new TenantTokenRules(new string[] { "*" });

private readonly TFixture _fixture;
private JwtBuilder _builder;
private Index _basicIndex;
private readonly MeilisearchClient _client;
private readonly string _indexName = "books";
private string _key;

public TenantTokenTests(TFixture fixture)
{
_fixture = fixture;
_client = fixture.DefaultClient;
_key = Guid.NewGuid().ToString();
}

public async Task InitializeAsync()
{
await _fixture.DeleteAllIndexes();
_basicIndex = await _fixture.SetUpBasicIndex(_indexName);
_builder = JwtBuilder
.Create()
.WithAlgorithm(new HMACSHA256Algorithm())
.MustVerifySignature();
}

public Task DisposeAsync() => Task.CompletedTask;

[Fact]
public void DoesNotGenerateASignedTokenWithoutAKey()
{
Assert.Throws<MeilisearchTenantTokenApiKeyInvalid>(
() => TenantToken.GenerateToken(_searchRules, null, null)
);
}

[Fact]
public void SignsTokenWithGivenKey()
{
var token = TenantToken.GenerateToken(_searchRules, _key, null);

Assert.Throws<SignatureVerificationException>(
() => _builder.WithSecret("other-key").Decode(token)
);

_builder.WithSecret(_key).Decode(token);
}

[Fact]
public void GeneratesTokenWithExpiresAt()
{
var expiration = DateTimeOffset.UtcNow.AddDays(1).DateTime;
var token = TenantToken.GenerateToken(_searchRules, _key, expiration);

_builder.WithSecret(_key).Decode(token);
}

[Fact]
public void ThrowsExceptionWhenExpiresAtIsInThePast()
{
var expiresAt = new DateTime(1995, 12, 20);

Assert.Throws<MeilisearchTenantTokenExpired>(
() => TenantToken.GenerateToken(_searchRules, _key, expiresAt)
);
}

[Fact]
public void ContainsValidClaims()
{
var token = TenantToken.GenerateToken(_searchRules, _key, null);

var claims = _builder.WithSecret(_key).Decode<IDictionary<string, object>>(token);

Assert.Equal(claims["apiKeyPrefix"], _key.Substring(0, 8));
Assert.Equal(claims["searchRules"], _searchRules.ToClaim());
}

[Fact]
public void ClientDecodesSuccessfullyUsingApiKeyFromInstance()
{
var token = _client.GenerateTenantToken(_searchRules);

_builder.WithSecret(_client.ApiKey).Decode(token);
}

[Fact]
public void ClientDecodesSuccessfullyUsingApiKeyFromArgument()
{
var token = _client.GenerateTenantToken(_searchRules, apiKey: _key);

_builder.WithSecret(_key).Decode(token);
}

[Fact]
public void ClientThrowsIfNoKeyIsAvailable()
{
var customClient = new MeilisearchClient(_fixture.MeilisearchAddress);

Assert.Throws<MeilisearchTenantTokenApiKeyInvalid>(
() => customClient.GenerateTenantToken(_searchRules)
);
}

[Theory]
[MemberData(nameof(PossibleSearchRules))]
public async void SearchesSuccessfullyWithTheNewToken(dynamic data)
{
var keyOptions = new Key
{
Description = "Key generate a tenant token",
Actions = new string[] { "*" },
Indexes = new string[] { "*" },
ExpiresAt = null,
};
var createdKey = await _client.CreateKeyAsync(keyOptions);
var admClient = new MeilisearchClient(_fixture.MeilisearchAddress, createdKey.KeyUid);
var task = await admClient.Index(_indexName).UpdateFilterableAttributesAsync(new string[] { "tag", "book_id" });
await admClient.Index(_indexName).WaitForTaskAsync(task.Uid);

var token = admClient.GenerateTenantToken(new TenantTokenRules(data));
var customClient = new MeilisearchClient(_fixture.MeilisearchAddress, token);

await customClient.Index(_indexName).SearchAsync<Movie>(string.Empty);
}

public static IEnumerable<object[]> PossibleSearchRules()
{
// {'*': {}}
yield return new object[] { new Dictionary<string, object> { { "*", new Dictionary<string, object> { } } } };
// {'books': {}}
yield return new object[] { new Dictionary<string, object> { { "books", new Dictionary<string, object> { } } } };
// {'*': null}
yield return new object[] { new Dictionary<string, object> { { "*", null } } };
// {'books': null}
yield return new object[] { new Dictionary<string, object> { { "books", null } } };
// ['*']
yield return new object[] { new string[] { "*" } };
// ['books']
yield return new object[] { new string[] { "books" } };
// {'*': {"filter": 'tag = Tale'}}
yield return new object[] { new Dictionary<string, object> { { "*", new Dictionary<string, object> { { "filter", "tag = Tale" } } } } };
// {'books': {"filter": 'tag = Tale'}}
yield return new object[] { new Dictionary<string, object> { { "books", new Dictionary<string, object> { { "filter", "tag = Tale" } } } } };
}
}
}

0 comments on commit e7e58e4

Please sign in to comment.