Repository: meilisearch/strapi-plugin-meilisearch (js)

Issue:
Add a setting to allow private fields to be indexed.
**Description**
Currently, there is no way to index `private` fields to be used for `search`, `filter`, `sort` or `display`. 

**Basic example**
If I had a field that contains metadata or other value that I do not wish to include in the API responses I would make it a `private` field. However, I still may want to use this value for `search`, `filter` or `sort`. Currently, this is not possible without making the field non-private in which case it requires additional logic on the Strapi side to sanitize the field from the responses.

**Potential solutions**

1. Ignore sanitizing private fields if they appear on either `searchableAttributes`, `distinctAttribute`, `filterableAttributes`, `searchableAttributes` or `sortableAttributes`.

_Pros:_

- Does not deviate from the [Meilisearch settings spec](https://www.meilisearch.com/docs/reference/api/settings).
- Does not introduce an additional setting.

_Cons:_

- By default will be included when using `["*"]` which may not be ideal.
- Prone to be exposed by mistake.

<br/>

2. Add a new setting called `indexedPrivateAttributes` that takes an array of `private` attribute names.

_Pros:_

- Easier to distinguish and less likely to expose sensitive fields by mistake.
- Can work alongside `["*"]` as this is a separate setting.
- Can be more flexible depending on the implementation.

_Cons:_

- By default will allow full scope (`search`, `filter`, `sort`, `display`) unless an additional attribute is added to the same setting to get the `scope`.
- Will deviate from the [Meilisearch settings spec](https://www.meilisearch.com/docs/reference/api/settings).

<br/>

**Other**
PR that introduced the current behaviour: https://github.com/meilisearch/strapi-plugin-meilisearch/pull/703
Mentioned in https://github.com/meilisearch/strapi-plugin-meilisearch/issues/695


Pull request description:
Sanitize private fields selectively
# Pull Request

## Related issue
Fixes #773

## What does this PR do?
This allows user to selectively index private fields which are by default sanitized.

- Added a setting called `noSanitizePrivateFields` to the meilisearch config that allows user to define an array of private field names that need to be indexed.

## PR checklist
Please check if your PR fulfills the following requirements:
- [x] Does this PR fix an existing issue, or have you listed the changes applied in the PR description (and why they are needed)?
- [x] Have you read the contributing guidelines?
- [x] Have you made sure that the title is accurate and descriptive of the changes?


Patch:
diff --git a/README.md b/README.md
index 4fb33c9..dbe6071 100644
--- a/README.md
+++ b/README.md
@@ -211,6 +211,7 @@ Settings:
 - [🤚 Filter entries](#-filter-entries)
 - [🏗 Add Meilisearch settings](#-add-meilisearch-settings)
 - [🔎 Entries query](#-entries-query)
+- [🔐 Selectively index private fields](#-selectively-index-private-fields)
 
 ### 🏷 Custom index name
 
@@ -412,6 +413,27 @@ module.exports = {
 
 [See resources](./resources/entries-query) for more entriesQuery examples.
 
+### 🔐 Selectively index private fields
+
+Private fields are sanitized by default to prevent data leaks. However, you might want to allow some of these private fields to be used for `search`, `filter` or `sort`. This is possible with the `noSanitizePrivateFields`. For example, if you have a private field called `internal_notes` in your content-type schema that you wish to include in searching, you can add it to the `noSanitizePrivateFields` array to allow it to be indexed.
+
+```js
+// config/plugins.js
+
+module.exports = {
+  meilisearch: {
+    config: {
+      restaurant: {
+        noSanitizePrivateFields: ["internal_notes"], // All attributes: ["*"]
+        settings:  {
+          "searchableAttributes": ["internal_notes"],
+        }
+      },
+    },
+  },
+}
+```
+
 ### 🕵️‍♀️ Start Searching <!-- omit in toc -->
 
 Once you have a content-type indexed in Meilisearch, you can [start searching](https://www.meilisearch.com/docs/learn/getting_started/quick_start.html#search).
diff --git a/server/configuration-validation.js b/server/configuration-validation.js
index 4a24be1..a229daa 100644
--- a/server/configuration-validation.js
+++ b/server/configuration-validation.js
@@ -175,6 +175,7 @@ function CollectionConfig({ collectionName, configuration }) {
     filterEntry,
     settings,
     entriesQuery,
+    noSanitizePrivateFields,
     ...unknownFields
   } = configuration
   const options = {}
@@ -262,6 +263,21 @@ function CollectionConfig({ collectionName, configuration }) {
 
       return this
     },
+    validateNoSanitizePrivateFields() {
+      // noSanitizePrivateFields is either undefined or an array
+      if (
+        noSanitizePrivateFields !== undefined &&
+        !Array.isArray(noSanitizePrivateFields)
+      ) {
+        log.error(
+          `The "noSanitizePrivateFields" option of "${collectionName}" should be an array of strings.`
+        )
+      } else if (noSanitizePrivateFields !== undefined) {
+        options.noSanitizePrivateFields = noSanitizePrivateFields
+      }
+
+      return this
+    },
 
     validateNoInvalidKeys() {
       // Keys that should not be present in the configuration
@@ -323,8 +339,9 @@ function PluginConfig({ configuration }) {
             .validateFilterEntry()
             .validateTransformEntry()
             .validateMeilisearchSettings()
-            .validateNoInvalidKeys()
             .validateEntriesQuery()
+            .validateNoSanitizePrivateFields()
+            .validateNoInvalidKeys()
             .get()
         }
       }
diff --git a/server/services/meilisearch/config.js b/server/services/meilisearch/config.js
index 9541643..910d9f1 100644
--- a/server/services/meilisearch/config.js
+++ b/server/services/meilisearch/config.js
@@ -189,10 +189,22 @@ module.exports = ({ strapi }) => {
      * @return {Array<Object>} - Entries
      */
     removeSensitiveFields: function ({ contentType, entries }) {
+      const collection = contentTypeService.getCollectionName({ contentType })
+      const contentTypeConfig = meilisearchConfig[collection] || {}
+
+      const noSanitizePrivateFields =
+        contentTypeConfig.noSanitizePrivateFields || []
+
+      if (noSanitizePrivateFields.includes('*')) {
+        return entries
+      }
+
       // TODO: should be persisted somewhere to make it more performant
       const attrs = strapi.contentTypes[contentType].attributes
       const privateFields = Object.entries(attrs).map(([field, schema]) =>
-        schema.private ? field : false
+        schema.private && !noSanitizePrivateFields.includes(field)
+          ? field
+          : false
       )
 
       return entries.map(entry => {


Test patch:
diff --git a/server/__tests__/configuration-validation.test.js b/server/__tests__/configuration-validation.test.js
index 52fa631..57e4e40 100644
--- a/server/__tests__/configuration-validation.test.js
+++ b/server/__tests__/configuration-validation.test.js
@@ -245,6 +245,41 @@ describe('Test plugin configuration', () => {
     expect(strapiMock.log.error).toHaveBeenCalledTimes(0)
   })
 
+  test('Test noSanitizePrivateFields with wrong type', async () => {
+    validatePluginConfig({
+      restaurant: {
+        noSanitizePrivateFields: 0,
+      },
+    })
+    expect(strapiMock.log.warn).toHaveBeenCalledTimes(0)
+    expect(strapiMock.log.error).toHaveBeenCalledTimes(1)
+    expect(strapiMock.log.error).toHaveBeenCalledWith(
+      'The "noSanitizePrivateFields" option of "restaurant" should be an array of strings.'
+    )
+  })
+
+  test('Test noSanitizePrivateFields with array of strings', async () => {
+    const configuration = validatePluginConfig({
+      restaurant: {
+        noSanitizePrivateFields: ['test'],
+      },
+    })
+    expect(strapiMock.log.warn).toHaveBeenCalledTimes(0)
+    expect(strapiMock.log.error).toHaveBeenCalledTimes(0)
+    expect(configuration.restaurant.noSanitizePrivateFields).toEqual(['test'])
+  })
+
+  test('Test noSanitizePrivateFields with undefined', async () => {
+    validatePluginConfig({
+      restaurant: {
+        noSanitizePrivateFields: undefined,
+      },
+    })
+
+    expect(strapiMock.log.warn).toHaveBeenCalledTimes(0)
+    expect(strapiMock.log.error).toHaveBeenCalledTimes(0)
+  })
+
   test('Test configuration with random field ', async () => {
     validatePluginConfig({
       restaurant: {
diff --git a/server/__tests__/meilisearch.test.js b/server/__tests__/meilisearch.test.js
index 1d69ef5..fc78358 100644
--- a/server/__tests__/meilisearch.test.js
+++ b/server/__tests__/meilisearch.test.js
@@ -119,7 +119,7 @@ describe('Tests content types', () => {
     )
   })
 
-  test('sanitizes the private fields from the entries', async () => {
+  test('selectively sanitizes the private fields from the entries', async () => {
     const pluginMock = jest.fn(() => ({
       // This rewrites only the needed methods to reach the system under test (removeSensitiveFields)
       service: jest.fn().mockImplementation(() => {
@@ -127,8 +127,18 @@ describe('Tests content types', () => {
           async actionInBatches({ contentType = 'restaurant', callback }) {
             await callback({
               entries: [
-                { id: 1, title: 'title', secret: '123' },
-                { id: 2, title: 'abc', secret: '234' },
+                {
+                  id: 1,
+                  title: 'title',
+                  internal_notes: 'note123',
+                  secret: '123',
+                },
+                {
+                  id: 2,
+                  title: 'abc',
+                  internal_notes: 'note234',
+                  secret: '234',
+                },
               ],
               contentType,
             })
@@ -149,11 +159,18 @@ describe('Tests content types', () => {
             attributes: {
               id: { private: false },
               title: { private: false },
+              internal_notes: { private: true },
               secret: { private: true },
             },
           },
         },
-        config: { get: jest.fn(() => ({ restaurant: jest.fn() })) },
+        config: {
+          get: jest.fn(() => ({
+            restaurant: {
+              noSanitizePrivateFields: ['internal_notes'],
+            },
+          })),
+        },
         log: mockLogger,
       },
     })
@@ -169,11 +186,13 @@ describe('Tests content types', () => {
           _meilisearch_id: 'restaurant-1',
           id: 1,
           title: 'title',
+          internal_notes: 'note123',
         },
         {
           _meilisearch_id: 'restaurant-2',
           id: 2,
           title: 'abc',
+          internal_notes: 'note234',
         },
       ],
       { primaryKey: '_meilisearch_id' }