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

Adding generate_tenant_token method #412

Merged
merged 13 commits into from
Feb 24, 2022
69 changes: 69 additions & 0 deletions meilisearch/client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from typing import Any, Dict, List, Optional

import base64
import hashlib
import hmac
brunoocasali marked this conversation as resolved.
Show resolved Hide resolved
import json
from meilisearch.index import Index
from meilisearch.config import Config
from meilisearch.task import get_task, get_tasks, wait_for_task
from meilisearch._httprequests import HttpRequests
from meilisearch.errors import MeiliSearchError
from typing import Any, Dict, List, Optional, Union

class Client():
"""
Expand Down Expand Up @@ -464,3 +469,67 @@ def wait_for_task(
An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://docs.meilisearch.com/errors/#meilisearch-errors
"""
return wait_for_task(self.config, uid, timeout_in_ms, interval_in_ms)

def generate_tenant_token(
alallema marked this conversation as resolved.
Show resolved Hide resolved
self,
search_rules: Union[Dict[str, Any], List[str]],
expires_at: Optional[int] = None,
alallema marked this conversation as resolved.
Show resolved Hide resolved
api_key: Optional[str] = None
) -> str:
"""Generate a JWT token for the use of multitenancy.

Parameters
----------
search_rules:
A Dictionary or list of string which contains the rules to be enforced at search time for all or specific
accessible indexes for the signing API Key.
In the specific case where you do not want to have any restrictions you can also use a list ["*"].
expires_at (optional):
alallema marked this conversation as resolved.
Show resolved Hide resolved
Date and time when the key will expire.
Note that if an expires_at value is included it should a `timestamp`.
api_key (optional):
alallema marked this conversation as resolved.
Show resolved Hide resolved
The API key parent of the token. If you leave it empty the client API Key will be used.

Returns
-------
jwt_token:
A string containing the jwt tenant token.
Note: If your token does not work remember that the search_rules is mandatory and should be well formatted.
`exp` must be a timestamp in the future.
"""
# Standard JWT header for encryption with SHA256/HS256 algorithm
header = {
"typ": "JWT",
"alg": "HS256"
}

api_key = str(self.config.api_key) if api_key is None else str(api_key)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can handle empty strings? ''

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api_key = self.config.api_key if api_key is None else api_key

api_key is a string so no need to convert.

Also, the master key can't be used right? self.config.api_key could be the master key. There is a default search key available that can be used as a default instead.

Copy link
Contributor Author

@alallema alallema Feb 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sanders41, yes I didn't convert it at first but mypy wasn't agree at all with it and generate this error:

meilisearch/client.py:510: error: Value of type "Optional[str]" is not indexable
meilisearch/client.py:523: error: Item "None" of "Optional[str]" has no attribute "encode"
Found 2 errors in 1 file (checked 7 source files)

How can I fix this in a better way than cast it in string?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think mypy is complaining because self.config.api_key could also be None and there isn’t a check for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can handle empty strings? ''

I can handle empty string search_rule is Union[Dict[str, Any], List[str]] not a string. I can check if it's not an empty Dict or an empty List but I'm not sure it's relevant.


# Add the required fields to the payload
payload = {
'apiKeyPrefix': api_key[0:8],
'searchRules': search_rules,
'exp': expires_at
}

# Serialize the header and the payload
json_header = json.dumps(header, separators=(",",":")).encode()
json_payload = json.dumps(payload, separators=(",",":")).encode()

# Encode the header and the payload to Base64Url String
header_encode = self._base64url_encode(json_header)
header_payload = self._base64url_encode(json_payload)

secret_encoded = api_key.encode()
# Create Signature Hash
signature = hmac.new(secret_encoded, (header_encode + "." + header_payload).encode(), hashlib.sha256).digest()
# Create JWT
jwt_token = header_encode + '.' + header_payload + '.' + self._base64url_encode(signature)

return jwt_token

@staticmethod
def _base64url_encode(
data: bytes
) -> str:
sanders41 marked this conversation as resolved.
Show resolved Hide resolved
return base64.urlsafe_b64encode(data).decode('utf-8').replace('=','')
92 changes: 92 additions & 0 deletions tests/client/test_client_tenant_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# pylint: disable=invalid-name

from re import search
import pytest
import meilisearch
from tests import BASE_URL, MASTER_KEY
from meilisearch.errors import MeiliSearchApiError
import datetime

def test_generate_tenant_token_with_search_rules(get_private_key, index_with_documents):
"""Tests create a tenant token with only search rules."""
index_with_documents()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you have a setup handler without documents or a simple index creation? Because it's good to avoid unnecessarily setup :D

Copy link
Contributor Author

@alallema alallema Feb 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had documents to check the search return at least a few documents. I find that doing a search without results was less convincing

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And same as above I just let this specific verification on the first one with search_rules ['*']

key = get_private_key

client = meilisearch.Client(BASE_URL, key['key'])

token = client.generate_tenant_token(search_rules=["*"])

token_client = meilisearch.Client(BASE_URL, token)
response = token_client.index('indexUID').search('')
assert isinstance(response, dict)
assert len(response['hits']) == 20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, you don't really care about the size of the response, because it's not related to the key generation itself. But you do care if the /search request answer with a success status!

Copy link
Contributor Author

@alallema alallema Feb 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure to understand what you mean. There is no status field in the search response so I just check if the response has enough results to be valid. It could for example don't have the right on my index or had filters

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I want to mean is, if your request returned just 10 records caused by a new version of the engine v0.27 for example, that's an error? this test should break because of that change? In this case no, because your test just wanted to check if your generated token can make a request successfully.

But, since the assertion is check if the returned records are equal to 20 now you have to fix this test too because the core engine changed internally.

Giving more context: this idea does not apply everywhere, because there are some use-cases we will need to handle that, ie. the tests regarding the filters or the search itself.

A different approach is to use more unit testing for some cases instead of e2e tests give a look here:

Copy link
Contributor Author

@alallema alallema Feb 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get it you mean if the search limit by default is modified from 20 to 10.
I can change the request with a specific limit like:

    response = token_client.index('indexUID').search('', {
        'limit': 5
    })
    assert len(response['hits']) == 5

This way I'm sure the search works well and the test will not be breaking. What do you think?
Also, I just let it in the first test when the search_rule is set to ['*']

assert response['query'] == ''

def test_generate_tenant_token_with_api_key(client, get_private_key, index_with_documents):
"""Tests create a tenant token with only search rules."""
index_with_documents()
key = get_private_key

token = client.generate_tenant_token(search_rules=["*"], api_key=key['key'])

token_client = meilisearch.Client(BASE_URL, token)
response = token_client.index('indexUID').search('')
assert isinstance(response, dict)
assert len(response['hits']) == 20
assert response['query'] == ''

def test_generate_tenant_token_with_expires_at(client, get_private_key, index_with_documents):
"""Tests create a tenant token with search rules and expiration date."""
index_with_documents()
key = get_private_key

client = meilisearch.Client(BASE_URL, key['key'])

tomorrow = datetime.datetime.now() + datetime.timedelta(days=1)
timestamp = datetime.datetime.timestamp(tomorrow)

token = client.generate_tenant_token(search_rules=["*"], expires_at=int(timestamp))
alallema marked this conversation as resolved.
Show resolved Hide resolved

token_client = meilisearch.Client(BASE_URL, token)
response = token_client.index('indexUID').search('')
assert isinstance(response, dict)
assert len(response['hits']) == 20
assert response['query'] == ''

def test_generate_tenant_token_without_search_rules(get_private_key, index_with_documents):
"""Tests create a tenant token without search rules."""
index_with_documents()
key = get_private_key

client = meilisearch.Client(BASE_URL, key['key'])

token = client.generate_tenant_token(search_rules='')

token_client = meilisearch.Client(BASE_URL, token)
with pytest.raises(MeiliSearchApiError):
token_client.index('indexUID').search('')

def test_generate_tenant_token_with_master_key(client, get_private_key, index_with_documents):
alallema marked this conversation as resolved.
Show resolved Hide resolved
"""Tests create a tenant token with master key."""
index_with_documents()
token = client.generate_tenant_token(search_rules=['*'])

token_client = meilisearch.Client(BASE_URL, token)
with pytest.raises(MeiliSearchApiError):
token_client.index('indexUID').search('')

def test_generate_tenant_token_with_bad_expires_at(client, get_private_key, index_with_documents):
"""Tests create a tenant token with only search rules."""
alallema marked this conversation as resolved.
Show resolved Hide resolved
index_with_documents()
key = get_private_key

client = meilisearch.Client(BASE_URL, key['key'])

yesterday = datetime.datetime.now() + datetime.timedelta(days=-1)
timestamp = datetime.datetime.timestamp(yesterday)

token = client.generate_tenant_token(search_rules=["*"], expires_at=int(timestamp))

token_client = meilisearch.Client(BASE_URL, token)
with pytest.raises(MeiliSearchApiError):
token_client.index('indexUID').search('')
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,9 @@ def test_key_info(client):
client.delete_key(key['key'])
except MeiliSearchApiError:
pass

@fixture(scope='function')
def get_private_key(client):
keys = client.get_keys()['results']
key = next(x for x in keys if 'Default Admin API' in x['description'])
alallema marked this conversation as resolved.
Show resolved Hide resolved
return key