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

Add privateAttributes to global and per model response #7331

Merged
merged 18 commits into from
Sep 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/v3.x/concepts/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,25 @@ module.exports = ({ env }) => ({
| `admin.forgotPassword.from` | Sender mail address | string | Default value defined in your [provider configuration](../plugins/email.md#configure-the-plugin) |
| `admin.forgotPassword.replyTo` | Default address or addresses the receiver is asked to reply to | string | Default value defined in your [provider configuration](../plugins/email.md#configure-the-plugin) |

## API

**Path —** `./config/api.js`.

```js
module.exports = ({ env }) => ({
responses: {
privateAttributes: ['_v', 'id', 'created_at'],
},
});
```

**Available options**

| Property | Description | Type | Default |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------- |
| `responses` | Global API response configuration | Object | |
| `responses.privateAttributes` | Set of globally defined attributes to be treated as private. E.g. `_v` when using MongoDb or timestamps like `created_at`, `updated_at` can be treated as private | String array | `[]` |

## Functions

The `./config/functions/` folder contains a set of JavaScript files in order to add dynamic and logic based configurations.
Expand Down
7 changes: 5 additions & 2 deletions docs/v3.x/concepts/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Use the CLI, and run the following command `strapi generate:model restaurant nam

This will create two files located at `./api/restaurant/models`:

- `Restaurant.settings.json`: contains the list of attributes and settings. The JSON format makes the file easily editable.
- `Restaurant.settings.json`: contains the list of attributes and settings. The JSON format makes the file easily editable.
- `Restaurant.js`: imports `Restaurant.settings.json` and extends it with additional settings and life cycle callbacks.

::: tip
Expand Down Expand Up @@ -164,12 +164,15 @@ The options key on the model-json states.

- `timestamps`: This tells the model which attributes to use for timestamps. Accepts either `boolean` or `Array` of strings where first element is create date and second element is update date. Default value when set to `true` for Bookshelf is `["created_at", "updated_at"]` and for MongoDB is `["createdAt", "updatedAt"]`.

- `privateAttributes`: This configuration allows to treat a set of attributes as private, even if they're not actually defined as attributes in the model. Accepts an `Array` of strings. It could be used to remove from API responses timestamps or `_v` when using MongoDB. The set of `privateAttributes` defined in the model are merged with the `privateAttributes` defined in the global Strapi configuration.

**Path —** `User.settings.json`.

```json
{
"options": {
"timestamps": true
"timestamps": true,
"privateAttributes": ["id", "created_at"]
}
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ describe('Permissions Manager', () => {
options: {},
};
},
config: {
get: jest.fn,
},
};

test('Pick all fields (output) using default model', () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/strapi-plugin-graphql/services/type-definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

const _ = require('lodash');
const { getPrivateAttributes } = require('strapi-utils');

const DynamicZoneScalar = require('../types/dynamiczoneScalar');

Expand Down Expand Up @@ -64,6 +65,14 @@ const buildTypeDefObj = model => {
delete typeDef[association.alias];
});

// Remove private attributes defined per model or globally
const privateAttributes = getPrivateAttributes(model);
privateAttributes.forEach(attr => {
if (typeDef[attr]) {
delete typeDef[attr];
}
});

return typeDef;
};

Expand Down
129 changes: 97 additions & 32 deletions packages/strapi-utils/lib/__tests__/sanitize-entity.test.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
'use strict';

const _ = require('lodash');
const sanitizeEntity = require('../sanitize-entity');
const { sanitizeEntity } = require('../sanitize-entity');

describe('Sanitize Entity', () => {
const input = {
id: 1,
email: 'foo@bar.com',
firstname: 'foo',
lastname: 'bar',
Expand Down Expand Up @@ -103,53 +104,58 @@ describe('Sanitize Entity', () => {
userDz: userDzModel,
};

global.strapi = {
getModel(name) {
return models[name];
},
};
beforeEach(() => {
global.strapi = {
getModel(name) {
return models[name];
},
config: {
get: jest.fn,
},
};
});

describe('Basic', () => {
const tests = [
[
{ withPrivate: false, isOutput: true, includeFields: null },
_.pick(input, ['firstname', 'lastname']),
_.pick(input, ['id', 'firstname', 'lastname']),
],
[{ withPrivate: false, isOutput: false, includeFields: null }, input],
[
{ withPrivate: false, isOutput: true, includeFields: ['firstname'] },
_.pick(input, ['firstname']),
_.pick(input, ['id', 'firstname']),
],
[
{ withPrivate: false, isOutput: true, includeFields: ['email', 'firstname'] },
_.pick(input, ['firstname']),
_.pick(input, ['id', 'firstname']),
],
[{ withPrivate: false, isOutput: true, includeFields: ['password'] }, {}],
[{ withPrivate: false, isOutput: true, includeFields: ['password'] }, _.pick(input, ['id'])],
[
{ withPrivate: true, isOutput: true, includeFields: null },
_.pick(input, ['email', 'firstname', 'lastname']),
_.pick(input, ['id', 'email', 'firstname', 'lastname']),
],
[{ withPrivate: true, isOutput: false, includeFields: null }, input],
[
{ withPrivate: true, isOutput: true, includeFields: ['firstname'] },
_.pick(input, ['firstname']),
_.pick(input, ['id', 'firstname']),
],
[
{ withPrivate: true, isOutput: true, includeFields: ['email', 'firstname'] },
_.pick(input, ['email', 'firstname']),
_.pick(input, ['id', 'email', 'firstname']),
],
[{ withPrivate: true, isOutput: true, includeFields: ['password'] }, {}],
[{ withPrivate: true, isOutput: true, includeFields: ['password'] }, _.pick(input, ['id'])],
[
{ withPrivate: true, isOutput: false, includeFields: ['firstname'] },
_.pick(input, ['firstname']),
_.pick(input, ['id', 'firstname']),
],
[
{ withPrivate: true, isOutput: false, includeFields: ['email', 'firstname'] },
_.pick(input, ['email', 'firstname']),
_.pick(input, ['id', 'email', 'firstname']),
],
[
{ withPrivate: true, isOutput: false, includeFields: ['password'] },
_.pick(input, ['password']),
_.pick(input, ['id', 'password']),
],
];

Expand All @@ -159,43 +165,102 @@ describe('Sanitize Entity', () => {
});
});

describe('With private attributes', () => {
describe('When options.privateAttributes exists in model, the attributes in options.privateAttributes must be hidden', () => {
const tests = [
[{ withPrivate: false, isOutput: true, includeFields: null }, _.pick(input, ['lastname'])],
[{ withPrivate: false, isOutput: false, includeFields: null }, input],
[{ withPrivate: false, isOutput: true, includeFields: ['firstname'] }, {}],
[{ withPrivate: false, isOutput: true, includeFields: ['email', 'firstname'] }, {}],
[{ withPrivate: false, isOutput: true, includeFields: ['password'] }, {}],
[
{ withPrivate: true, isOutput: true, includeFields: null },
_.pick(input, ['id', 'email', 'firstname', 'lastname']),
],
[{ withPrivate: true, isOutput: false, includeFields: null }, input],
[
{ withPrivate: true, isOutput: true, includeFields: ['firstname'] },
_.pick(input, ['id', 'firstname']),
],
[
{ withPrivate: true, isOutput: true, includeFields: ['email', 'firstname'] },
_.pick(input, ['id', 'email', 'firstname']),
],
[{ withPrivate: true, isOutput: true, includeFields: ['password'] }, _.pick(input, ['id'])],
[
{ withPrivate: true, isOutput: false, includeFields: ['firstname'] },
_.pick(input, ['id', 'firstname']),
],
[
{ withPrivate: true, isOutput: false, includeFields: ['email', 'firstname'] },
_.pick(input, ['id', 'email', 'firstname']),
],
[
{ withPrivate: true, isOutput: false, includeFields: ['password'] },
_.pick(input, ['id', 'password']),
],
];

const model = {
...models.user,
options: {
...models.user.options,
privateAttributes: ['firstname'],
},
};

test.each(tests)(`Test n°%#`, (options, expected) => {
global.strapi = {
config: {
get: jest.fn(path => {
return path === 'api.responses.privateAttributes' ? ['id'] : [];
}),
},
};

expect(sanitizeEntity(input, { ...options, model })).toStrictEqual(expected);
});
});
});

describe('With relation', () => {
const tests = [
[
inputWithRelation,
{ withPrivate: false, isOutput: true, includeFields: null },
{
..._.pick(inputWithRelation, ['firstname', 'lastname']),
article: _.pick(inputWithRelation.article, ['name', 'content']),
},
_.pick(inputWithRelation, [
'id',
'firstname',
'lastname',
'article.name',
'article.content',
]),
],
[
inputWithRelation,
{ withPrivate: false, isOutput: true, includeFields: ['firstname', 'lastname'] },
_.pick(inputWithRelation, ['firstname', 'lastname']),
_.pick(inputWithRelation, ['id', 'firstname', 'lastname']),
],
[
inputWithRelation,
{ withPrivate: false, isOutput: true, includeFields: ['article'] },
{
article: _.pick(inputWithRelation.article, ['name', 'content']),
},
_.pick(inputWithRelation, ['id', 'article.name', 'article.content']),
],
[
inputWithRelation,
{ withPrivate: false, isOutput: true, includeFields: ['article.name'] },
_.pick(inputWithRelation, ['article.name']),
_.pick(inputWithRelation, ['id', 'article.name']),
],
[
inputWithRelation,
{ withPrivate: true, isOutput: true, includeFields: null },
_.pick(inputWithRelation, ['email', 'firstname', 'lastname', 'article']),
_.pick(inputWithRelation, ['id', 'email', 'firstname', 'lastname', 'article']),
],
[
{ ...inputWithRelation, article: _.times(3, () => _.clone(article)) },
{ withPrivate: false, isOutput: true, includeFields: null },
{
..._.pick(inputWithRelation, 'firstname', 'lastname'),
..._.pick(inputWithRelation, ['id', 'firstname', 'lastname']),
article: _.times(3, () => _.pick(article, ['name', 'content'])),
},
],
Expand All @@ -212,7 +277,7 @@ describe('Sanitize Entity', () => {
const { userDz: model } = models;
const dataSource = { ...inputWithDz, dz: null };

const expected = _.pick(dataSource, ['firstname', 'lastname', 'dz']);
const expected = _.pick(dataSource, ['id', 'firstname', 'lastname', 'dz']);

expect(sanitizeEntity(dataSource, { model })).toStrictEqual(expected);
});
Expand All @@ -221,7 +286,7 @@ describe('Sanitize Entity', () => {
const { userDz: model } = models;

const expected = {
..._.pick(inputWithDz, ['firstname', 'lastname']),
..._.pick(inputWithDz, ['id', 'firstname', 'lastname']),
dz: inputWithDz.dz.map(comp => _.pick(comp, ['__component', 'name', 'content'])),
};

Expand All @@ -246,8 +311,8 @@ describe('Sanitize Entity', () => {
const dataSource = [_.clone(input), _.clone(input)];

const expected = [
_.pick(input, 'firstname', 'lastname'),
_.pick(input, 'firstname', 'lastname'),
_.pick(input, 'id', 'firstname', 'lastname'),
_.pick(input, 'id', 'firstname', 'lastname'),
];

const { user: model } = models;
Expand Down
3 changes: 2 additions & 1 deletion packages/strapi-utils/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
const { buildQuery, hasDeepFilters } = require('./build-query');
const { convertRestQueryParams, VALID_REST_OPERATORS } = require('./convert-rest-query-params');
const parseMultipartData = require('./parse-multipart');
const sanitizeEntity = require('./sanitize-entity');
const { sanitizeEntity, getPrivateAttributes } = require('./sanitize-entity');
const parseType = require('./parse-type');
const finder = require('./finder');
const logger = require('./logger');
Expand Down Expand Up @@ -40,6 +40,7 @@ module.exports = {
hasDeepFilters,
parseMultipartData,
sanitizeEntity,
getPrivateAttributes,
parseType,
nameToSlug,
nameToCollectionName,
Expand Down
24 changes: 17 additions & 7 deletions packages/strapi-utils/lib/sanitize-entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const sanitizeEntity = (dataSource, options) => {
const attribute = attributes[key];
const allowedFieldsHasKey = allowedFields.includes(key);

if (shouldRemoveAttribute(attribute, { withPrivate, isOutput })) {
if (shouldRemoveAttribute(model, key, attribute, { withPrivate, isOutput })) {
return acc;
}

Expand Down Expand Up @@ -120,18 +120,28 @@ const getNextFields = (fields, key, { allowedFieldsHasKey }) => {
return [nextFields, isAllowed];
};

const shouldRemoveAttribute = (attribute, { withPrivate, isOutput }) => {
if (_.isNil(attribute)) {
return false;
}
const getPrivateAttributes = model => {
const allPrivatesAttributes = _.union(
strapi.config.get('api.responses.privateAttributes', []),
_.get(model, 'options.privateAttributes', [])
);

return allPrivatesAttributes;
};

const shouldRemoveAttribute = (model, key, attribute = {}, { withPrivate, isOutput }) => {
const privateAttributes = getPrivateAttributes(model);

const isPassword = attribute.type === 'password';
const isPrivate = attribute.private === true;
const isPrivate = attribute.private === true || privateAttributes.includes(key);
dalbitresb12 marked this conversation as resolved.
Show resolved Hide resolved

const shouldRemovePassword = isOutput;
const shouldRemovePrivate = !withPrivate && isOutput;

return !!((isPassword && shouldRemovePassword) || (isPrivate && shouldRemovePrivate));
};

module.exports = sanitizeEntity;
module.exports = {
sanitizeEntity,
getPrivateAttributes,
};