Skip to content

Commit d0e8d45

Browse files
bors[bot]meili-botalallema
authored
Merge #407
407: Changes related to the next Meilisearch release (v0.26.0) r=alallema 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._ Done: - #410 - #412 - #415 Co-authored-by: meili-bot <[email protected]> Co-authored-by: bors[bot] <26634292+bors[bot]@users.noreply.github.com> Co-authored-by: alallema <[email protected]> Co-authored-by: Amélie <[email protected]>
2 parents 834dfbd + 3b16725 commit d0e8d45

File tree

8 files changed

+211
-9
lines changed

8 files changed

+211
-9
lines changed

.code-samples.meilisearch.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,3 +473,15 @@ security_guide_delete_key_1: |-
473473
authorization_header_1: |-
474474
client = Client('http://127.0.0.1:7700', 'masterKey')
475475
client.get_keys()
476+
tenant_token_guide_generate_sdk_1: |-
477+
api_key = 'B5KdX2MY2jV6EXfUs6scSfmC...'
478+
expires_at = datetime(2025, 12, 20)
479+
search_rules = {
480+
'patient_medical_records': {
481+
'filter': 'user_id = 1'
482+
}
483+
}
484+
token = client.generate_tenant_token(search_rules=search_rules, api_key=api_key, expires_at=expires_at)
485+
tenant_token_guide_search_sdk_1: |-
486+
front_end_client = Client('http://127.0.0.1:7700', token)
487+
front_end_client.index('patient_medical_records').search('blood test')

.github/workflows/pre-release-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@ jobs:
2929
- name: Get the latest Meilisearch RC
3030
run: echo "MEILISEARCH_VERSION=$(curl https://raw.githubusercontent.com/meilisearch/integration-guides/main/scripts/get-latest-meilisearch-rc.sh | bash)" >> $GITHUB_ENV
3131
- name: Meilisearch (${{ env.MEILISEARCH_VERSION }}) setup with Docker
32-
run: docker run -d -p 7700:7700 getmeili/meilisearch:${{ env.MEILISEARCH_VERSION }} ./meilisearch --master-key=masterKey --no-analytics=true
32+
run: docker run -d -p 7700:7700 getmeili/meilisearch:${{ env.MEILISEARCH_VERSION }} ./meilisearch --master-key=masterKey --no-analytics
3333
- name: Test with pytest
3434
run: pipenv run pytest

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
- name: Install dependencies
3232
run: pipenv install --dev
3333
- name: Meilisearch (latest version) setup with Docker
34-
run: docker run -d -p 7700:7700 getmeili/meilisearch:latest ./meilisearch --no-analytics=true --master-key=masterKey
34+
run: docker run -d -p 7700:7700 getmeili/meilisearch:latest ./meilisearch --no-analytics --master-key=masterKey
3535
- name: Test with pytest
3636
run: pipenv run pytest
3737

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Each PR should pass the tests, mypy type checking, and the linter to be accepted
3939
```bash
4040
# Tests
4141
curl -L https://install.meilisearch.com | sh # download Meilisearch
42-
./meilisearch --master-key=masterKey --no-analytics=true # run Meilisearch
42+
./meilisearch --master-key=masterKey --no-analytics # run Meilisearch
4343
pipenv run pytest meilisearch
4444
# MyPy
4545
pipenv run mypy meilisearch
@@ -51,7 +51,7 @@ Optionally tox can be used to run test on all supported version of Python, mypy,
5151

5252
```bash
5353
docker pull getmeili/meilisearch:latest # Fetch the latest version of Meilisearch image from Docker Hub
54-
docker run -p 7700:7700 getmeili/meilisearch:latest ./meilisearch --master-key=masterKey --no-analytics=true
54+
docker run -p 7700:7700 getmeili/meilisearch:latest ./meilisearch --master-key=masterKey --no-analytics
5555
pipenv run tox
5656
```
5757

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ index.search(
197197

198198
## 🤖 Compatibility with Meilisearch
199199

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

202202
## 💡 Learn More
203203

meilisearch/client.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
from typing import Any, Dict, List, Optional
2-
1+
import base64
2+
import hashlib
3+
import hmac
4+
import json
5+
import datetime
6+
from typing import Any, Dict, List, Optional, Union
37
from meilisearch.index import Index
48
from meilisearch.config import Config
59
from meilisearch.task import get_task, get_tasks, wait_for_task
@@ -464,3 +468,75 @@ def wait_for_task(
464468
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
465469
"""
466470
return wait_for_task(self.config, uid, timeout_in_ms, interval_in_ms)
471+
472+
def generate_tenant_token(
473+
self,
474+
search_rules: Union[Dict[str, Any], List[str]],
475+
*,
476+
expires_at: Optional[datetime.datetime] = None,
477+
api_key: Optional[str] = None
478+
) -> str:
479+
"""Generate a JWT token for the use of multitenancy.
480+
481+
Parameters
482+
----------
483+
search_rules:
484+
A Dictionary or list of string which contains the rules to be enforced at search time for all or specific
485+
accessible indexes for the signing API Key.
486+
In the specific case where you do not want to have any restrictions you can also use a list ["*"].
487+
expires_at (optional):
488+
Date and time when the key will expire. Note that if an expires_at value is included it should be in UTC time.
489+
api_key (optional):
490+
The API key parent of the token. If you leave it empty the client API Key will be used.
491+
492+
Returns
493+
-------
494+
jwt_token:
495+
A string containing the jwt tenant token.
496+
Note: If your token does not work remember that the search_rules is mandatory and should be well formatted.
497+
`exp` must be a `datetime` in the future. It's not possible to create a token from the master key.
498+
"""
499+
# Validate all fields
500+
if api_key == '' or api_key is None and self.config.api_key is None:
501+
raise Exception('An api key is required in the client or should be passed as an argument.')
502+
if not search_rules or search_rules == ['']:
503+
raise Exception('The search_rules field is mandatory and should be defined.')
504+
if expires_at and expires_at < datetime.datetime.utcnow():
505+
raise Exception('The date expires_at should be in the future.')
506+
507+
# Standard JWT header for encryption with SHA256/HS256 algorithm
508+
header = {
509+
"typ": "JWT",
510+
"alg": "HS256"
511+
}
512+
513+
api_key = str(self.config.api_key) if api_key is None else api_key
514+
515+
# Add the required fields to the payload
516+
payload = {
517+
'apiKeyPrefix': api_key[0:8],
518+
'searchRules': search_rules,
519+
'exp': int(datetime.datetime.timestamp(expires_at)) if expires_at is not None else None
520+
}
521+
522+
# Serialize the header and the payload
523+
json_header = json.dumps(header, separators=(",",":")).encode()
524+
json_payload = json.dumps(payload, separators=(",",":")).encode()
525+
526+
# Encode the header and the payload to Base64Url String
527+
header_encode = self._base64url_encode(json_header)
528+
payload_encode = self._base64url_encode(json_payload)
529+
530+
secret_encoded = api_key.encode()
531+
# Create Signature Hash
532+
signature = hmac.new(secret_encoded, (header_encode + "." + payload_encode).encode(), hashlib.sha256).digest()
533+
# Create JWT
534+
jwt_token = header_encode + '.' + payload_encode + '.' + self._base64url_encode(signature)
535+
536+
return jwt_token
537+
538+
@staticmethod
539+
def _base64url_encode(
540+
data: bytes
541+
) -> str:
542+
return base64.urlsafe_b64encode(data).decode('utf-8').replace('=','')
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# pylint: disable=invalid-name
2+
3+
from re import search
4+
import pytest
5+
import meilisearch
6+
from tests import BASE_URL, MASTER_KEY
7+
from meilisearch.errors import MeiliSearchApiError
8+
import datetime
9+
10+
def test_generate_tenant_token_with_search_rules(get_private_key, index_with_documents):
11+
"""Tests create a tenant token with only search rules."""
12+
index_with_documents()
13+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
14+
15+
token = client.generate_tenant_token(search_rules=["*"])
16+
17+
token_client = meilisearch.Client(BASE_URL, token)
18+
response = token_client.index('indexUID').search('', {
19+
'limit': 5
20+
})
21+
assert isinstance(response, dict)
22+
assert len(response['hits']) == 5
23+
assert response['query'] == ''
24+
25+
def test_generate_tenant_token_with_search_rules_on_one_index(get_private_key, empty_index):
26+
"""Tests create a tenant token with search rules set for one index."""
27+
empty_index()
28+
empty_index('tenant_token')
29+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
30+
31+
token = client.generate_tenant_token(search_rules=['indexUID'])
32+
33+
token_client = meilisearch.Client(BASE_URL, token)
34+
response = token_client.index('indexUID').search('')
35+
assert isinstance(response, dict)
36+
assert response['query'] == ''
37+
with pytest.raises(MeiliSearchApiError):
38+
response = token_client.index('tenant_token').search('')
39+
40+
def test_generate_tenant_token_with_api_key(client, get_private_key, empty_index):
41+
"""Tests create a tenant token with search rules and an api key."""
42+
empty_index()
43+
token = client.generate_tenant_token(search_rules=["*"], api_key=get_private_key['key'])
44+
45+
token_client = meilisearch.Client(BASE_URL, token)
46+
response = token_client.index('indexUID').search('')
47+
assert isinstance(response, dict)
48+
assert response['query'] == ''
49+
50+
def test_generate_tenant_token_with_expires_at(client, get_private_key, empty_index):
51+
"""Tests create a tenant token with search rules and expiration date."""
52+
empty_index()
53+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
54+
tomorrow = datetime.datetime.utcnow() + datetime.timedelta(days=1)
55+
56+
token = client.generate_tenant_token(search_rules=["*"], expires_at=tomorrow)
57+
58+
token_client = meilisearch.Client(BASE_URL, token)
59+
response = token_client.index('indexUID').search('')
60+
assert isinstance(response, dict)
61+
assert response['query'] == ''
62+
63+
def test_generate_tenant_token_with_empty_search_rules_in_list(get_private_key):
64+
"""Tests create a tenant token without search rules."""
65+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
66+
67+
with pytest.raises(Exception):
68+
client.generate_tenant_token(search_rules=[''])
69+
70+
def test_generate_tenant_token_without_search_rules_in_list(get_private_key):
71+
"""Tests create a tenant token without search rules."""
72+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
73+
74+
with pytest.raises(Exception):
75+
client.generate_tenant_token(search_rules=[])
76+
77+
def test_generate_tenant_token_without_search_rules_in_dict(get_private_key):
78+
"""Tests create a tenant token without search rules."""
79+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
80+
81+
with pytest.raises(Exception):
82+
client.generate_tenant_token(search_rules={})
83+
84+
def test_generate_tenant_token_with_empty_search_rules_in_dict(get_private_key):
85+
"""Tests create a tenant token without search rules."""
86+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
87+
88+
with pytest.raises(Exception):
89+
client.generate_tenant_token(search_rules={''})
90+
91+
def test_generate_tenant_token_with_bad_expires_at(client, get_private_key):
92+
"""Tests create a tenant token with a bad expires at."""
93+
client = meilisearch.Client(BASE_URL, get_private_key['key'])
94+
95+
yesterday = datetime.datetime.utcnow() + datetime.timedelta(days=-1)
96+
97+
with pytest.raises(Exception):
98+
client.generate_tenant_token(search_rules=["*"], expires_at=yesterday)
99+
100+
def test_generate_tenant_token_with_no_api_key(client):
101+
"""Tests create a tenant token with no api key."""
102+
client = meilisearch.Client(BASE_URL)
103+
104+
with pytest.raises(Exception):
105+
client.generate_tenant_token(search_rules=["*"])
106+

tests/conftest.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from tests import common
66
import meilisearch
77
from meilisearch.errors import MeiliSearchApiError
8+
from typing import Optional
89

910
@fixture(scope='session')
1011
def client():
@@ -67,8 +68,9 @@ def songs_ndjson():
6768
return song_ndjson_file.read().encode('utf-8')
6869

6970
@fixture(scope='function')
70-
def empty_index(client):
71-
def index_maker(index_name=common.INDEX_UID):
71+
def empty_index(client, index_uid: Optional[str] = None):
72+
index_uid = index_uid if index_uid else common.INDEX_UID
73+
def index_maker(index_name=index_uid):
7274
task = client.create_index(uid=index_name)
7375
client.wait_for_task(task['uid'])
7476
return client.get_index(uid=index_name)
@@ -109,3 +111,9 @@ def test_key_info(client):
109111
client.delete_key(key['key'])
110112
except MeiliSearchApiError:
111113
pass
114+
115+
@fixture(scope='function')
116+
def get_private_key(client):
117+
keys = client.get_keys()['results']
118+
key = next(x for x in keys if 'Default Search API' in x['description'])
119+
return key

0 commit comments

Comments
 (0)