Sometimes, you don’t want your users to search your entire index, but only a subset that concerns them. That can be all the movies they added to their watch list, the items they added to their shopping cart, or the content shared by their friends.
This doesn’t mean you need one index per user. By generating a Secured API key for the current user, you can restrict the records they can retrieve.
Adding an attribute for filtering in your dataset
Algolia is schemaless and doesn’t have any concept of relationships between objects, so you need to put all the relevant information in each record.
Take a dataset for a video streaming service as an example. The index contains the entire list of available movies, but on a specific section of the app, users can search through the movies they added to their watch list.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| [
{
"title": "The Shawshank Redemption",
"watch_list_of": [1, 2, 3],
"objectID": "myID1"
},
{
"title": "The Godfather",
"watch_list_of": [3],
"objectID": "myID2"
},
{
"title": "The Dark Knight",
"watch_list_of": [1, 2],
"objectID": "myID3"
},
{
"title": "12 Angry Men",
"watch_list_of": [2, 3],
"objectID": "myID4"
}
]
|
Each record has a watch_list_of
attribute, which contains a list of user IDs. Only listed users have added the movie on their watch list. When searching through it, they should only be able to find those movies.
Making the attribute filterable
To make your watch_list_of
attribute filterable, you should add it in attributesForFaceting
.
1
2
3
4
5
| $index->setSettings([
'attributesForFaceting' => [
'filterOnly(watch_list_of)'
]
]);
|
1
2
3
4
5
| index.set_settings(
attributesForFaceting: [
'filterOnly(watch_list_of)'
]
)
|
1
2
3
4
5
| index.setSettings({
attributesForFaceting: [
'filterOnly(watch_list_of)'
]
});
|
1
2
3
4
5
| index.set_settings({
'attributesForFaceting': [
'filterOnly(watch_list_of)'
]
})
|
1
2
3
4
5
6
7
| IndexSettings settings = new IndexSettings
{
AttributesForFaceting = new List<string>
{
"filterOnly(watch_list_of)"
}
};
|
1
2
| index.setSettings(new IndexSettings()
.setAttributesForFaceting(Collections.singletonList("watch_list_of")));
|
1
2
3
| res, err := index.SetSettings(search.Settings{
AttributesForFaceting: opt.AttributesForFaceting("filterOnly(watch_list_of)"),
})
|
1
2
3
4
5
6
7
| client.execute {
changeSettings of "your_index_name" `with` IndexSettings(
attributesForFaceting = Some(Seq(
"filterOnly(watch_list_of)",
))
)
}
|
1
2
3
4
5
6
7
| val settings = settings {
attributesForFaceting {
+FilterOnly("watch_list_of")
}
}
index.setSettings(settings)
|
In this case, you only want to filter on this attribute, and not use it for facet counts. That means you should add the filterOnly
modifier. This improves performance because the engine doesn’t have to compute the count for each value.
If you need faceting on this attribute, you can remove the filterOnly
modifier.
Adding and removing users
Whenever someone adds or removes a movie from their watch list, you need to update the watch_list_of
attribute.
1
2
3
4
5
6
| $index->partialUpdateObject(
[
'watch_list_of' => [1, 2],
'objectID' => 'myID1'
]
);
|
1
2
3
4
| index.partial_update_object({
watch_list_of: [1, 2],
objectID: 'myID1'
})
|
1
2
3
4
| index.partialUpdateObject({
watch_list_of: [1, 2],
objectID: 'myID1'
});
|
1
| index.partial_update_object({'objectID': 'myID1', 'watch_list_of': [1, 2]})
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public class Post
{
[JsonProperty(PropertyName = "objectID")]
public string ObjectId { get; set; }
[JsonProperty("watch_list_of")]
public List<int> ViewableBy { get; set; }
}
List<Post> posts = new List<Post>
{
new Post { ObjectID = "myID1", ViewableBy = new List<int> { 1, 2 } },
};
index.PartialUpdateObjects(posts);
|
1
2
3
4
5
6
7
8
| class DemoClass {
// getters setters omitted
@JsonProperty("watch_list_of")
private List<String> viewableBy;
}
index.partialUpdateObject(new DemoClass()
.setViewableBy(Arrays.aslist("1", "2")));
|
1
2
3
4
| res, err := index.PartialUpdateObject(map[string]interface{}{
"watch_list_of": []int{1, 2},
"objectID": "myID1",
})
|
1
2
3
4
5
| client.execute {
partialUpdate from "index" objects Seq(
Post("myID1", Some(Seq(1, 2)))
)
}
|
1
2
3
4
5
6
7
8
9
10
| index.partialUpdateObject(
ObjectID("myID1"),
Partial.Update(
Attribute("watch_list_of"),
jsonArray {
+(1 as Number)
+(2 as Number)
}
)
)
|
The partialUpdateObjects
allows you to partially update an attribute instead of replacing the entire record, or even the entire index.
Generating a Secured API key
With front-end search, malicious users can tweak the request to impersonate another user and see the movies on their watch list.
To prevent this, you can generate a Secured API key, a special key that you can generate on the fly, and within which you can embed a set of filters. End users can’t alter those filters.
1
2
3
4
5
6
7
8
| $currentUserID = 1;
$securedApiKey = \Algolia\AlgoliaSearch\SearchClient::generateSecuredApiKey(
'YourSearchOnlyAPIKey', // A search key that you keep private
[
'filters' => 'watch_list_of:'.$currentUserID
]
);
|
1
2
3
4
5
6
| current_user_id = 1
secured_api_key = Algolia::Search::Client.generate_secured_api_key(
'YourSearchOnlyAPIKey', # A search key that you keep private
{ filters: 'watch_list_of:' + current_user_id.to_s }
)
|
1
2
3
4
5
6
7
8
9
10
| // Only in Node.js
const currentUserID = 1;
const publicKey = client.generateSecuredApiKey(
'YourSearchOnlyAPIKey', // A search key that you keep private
{
filters: `watch_list_of:${currentUserID}`
}
);
|
1
2
3
4
5
6
7
8
9
| from algoliasearch.search_client import SearchClient
current_user_id = 1
public_key = SearchClient.generate_secured_api_key(
'YourSearchOnlyAPIKey', { # A search key that you keep private
'filters': 'watch_list_of:' + str(current_user_id)
}
)
|
1
2
3
4
5
6
7
8
9
| int currentUserId = 1;
SecuredApiKeyRestriction restriction = new SecuredApiKeyRestriction
{
Query = new Query { Filters = $"watch_list_of:{currentUserId}" },
};
// A search key that you keep private
client.GenerateSecuredApiKeys("YourSearchOnlyAPIKey", restriction);
|
1
2
3
4
5
6
7
8
9
10
| int currentUserID = 1;
SecuredApiKeyRestriction restriction =
new SecuredApiKeyRestriction()
.setQuery(new Query().setFilters(String.format("watch_list_of:%s", currentUserID)));
String publicKey = client.generateSecuredAPIKey(
"YourSearchOnlyAPIKey", // A search key that you keep private
restriction
);
|
1
2
3
4
5
6
7
| currentUserID := 1
filter := fmt.Sprintf("watch_list_of:%d", currentUserID)
key, err := search.GenerateSecuredAPIKey(
"YourYourSearchOnlyAPIKey", // A search key that you keep private
opt.Filters(filter),
)
|
1
2
3
4
5
6
| val currentUserID: Int = 1
val publicKey = client.generateSecuredApiKey(
"YourSearchOnlyAPIKey", // A search key that you keep private
Query(filters = Some("watch_list_of:%s".format(currentUserID)))
)
|
1
2
3
4
5
| val currentUserId = 1
val restriction = SecuredAPIKeyRestriction(Query(filters = "watch_list_of:$currentUserId"))
// A search key that you keep private
ClientSearch.generateAPIKey(APIKey("YourSearchOnlyAPIKey"), restriction)
|
Note that this needs to happen on the back end. For example, when a user logs in to your application, you could generate the key from your back end and return it for the current session.
To invalidate Secured API keys, you need to invalidate the search API key you used to generate it.
Making sensitive attributes unretrievable
When using a Secured API key with an embedded filter, users can only retrieve movies from their own watch list. Since the API returns the watch_list_of
attribute for each record, they can find out what other users have the same movies in their watch list if they inspect the response.
To mitigate this privacy concern, you can leverage the unretrievableAttributes
parameter. It allows you to ensure that the watch_list_of
parameter is never part of the Algolia response, even though you use it for filtering on the engine side.
1
2
3
4
5
| $index->setSettings([
'unretrievableAttributes' => [
'watch_list_of'
]
]);
|
1
2
3
4
5
| index.set_settings({
unretrievableAttributes: [
'watch_list_of'
]
})
|
1
2
3
4
5
6
7
| index.setSettings({
unretrievableAttributes: [
'watch_list_of'
]
}).then(() => {
// done
});
|
1
2
3
4
5
| index.set_settings({
'unretrievableAttributes': [
'watch_list_of'
]
})
|
1
2
3
4
5
| index.setSettings([
"unretrievableAttributes": [
"watch_list_of"
]
])
|
1
2
3
4
5
6
7
| index.setSettings(
new JSONObject().put(
"unretrievableAttributes",
new JSONArray()
.put("watch_list_of")
)
);
|
1
2
3
4
5
6
| IndexSettings settings = new IndexSettings
{
UnretrievableAttributes = new List<string> { "watch_list_of" }
};
index.SetSettings(settings);
|
1
2
3
4
5
| index.setSettings(
new IndexSettings().setUnretrievableAttributes(Collections.singletonList(
"watch_list_of"
))
);
|
1
2
3
4
5
| res, err := index.SetSettings(search.Settings{
UnretrievableAttributes: opt.UnretrievableAttributes(
"watch_list_of",
),
})
|
1
2
3
4
5
6
7
| client.execute {
changeSettings of "myIndex" `with` IndexSettings(
unretrievableAttributes = Some(Seq(
"watch_list_of"
))
)
}
|
1
2
3
4
5
6
7
| val settings = settings {
unretrieveableAttributes {
+"watch_list_of"
}
}
index.setSettings(settings)
|
Searching in the subset
You can now search
on the front end using the API key generated from your back end. This API key has an embedded filter on the watch_list_of
attribute, so you have a guarantee that the current user only sees results that they’re allowed to access.