diff --git a/src/restricted-endpoints/replication/bulk-document/bulk-document.service.spec.ts b/src/restricted-endpoints/replication/bulk-document/bulk-document.service.spec.ts index 535e2c2..3898900 100644 --- a/src/restricted-endpoints/replication/bulk-document/bulk-document.service.spec.ts +++ b/src/restricted-endpoints/replication/bulk-document/bulk-document.service.spec.ts @@ -111,6 +111,32 @@ describe('BulkDocumentService', () => { }); }); + it('should apply field permissions to CREATE operations in BulkDocs', async () => { + const request: BulkDocsRequest = { + new_edits: true, + docs: [Object.assign({}, childDoc), Object.assign(schoolDoc)], + }; + jest.spyOn(mockRulesService, 'getRulesForUser').mockReturnValue([ + { action: 'create', subject: 'Child', fields: 'someProperty' }, + { action: ['read', 'update'], subject: 'School' }, + ]); + jest.spyOn(mockCouchDBService, 'post').mockReturnValue(of({ rows: [] })); + + const result = await service.filterBulkDocsRequest(request, normalUser, ''); + + expect(result).toEqual({ + new_edits: true, + docs: [ + { + _id: 'Child:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + someProperty: 'someValue', + }, + ], + }); + }); + it('should apply permissions to UPDATE operations in BulkDocs', async () => { const request: BulkDocsRequest = { new_edits: false, @@ -128,11 +154,64 @@ describe('BulkDocumentService', () => { }); }); + it('should apply field permissions to UPDATE operations in BulkDocs', async () => { + const request: BulkDocsRequest = { + new_edits: false, + docs: [ + { + _id: 'Child:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + someProperty: 'newSomeValue', + otherProperty: 'newOtherValue', + }, + { + _id: 'School:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + anotherProperty: 'newAnotherValue', + }, + ], + }; + + jest.spyOn(mockRulesService, 'getRulesForUser').mockReturnValue([ + { action: 'update', subject: 'Child', fields: 'someProperty' }, + { action: ['read', 'update'], subject: 'School' }, + ]); + + jest + .spyOn(mockCouchDBService, 'post') + .mockReturnValue(of(createAllDocsResponse(childDoc, schoolDoc))); + + const result = await service.filterBulkDocsRequest(request, normalUser, ''); + + expect(result).toEqual({ + new_edits: false, + docs: [ + { + _id: 'Child:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + someProperty: 'newSomeValue', + otherProperty: 'otherValue', + }, + { + _id: 'School:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + anotherProperty: 'newAnotherValue', + }, + ], + }); + }); + it('should apply permissions to DELETE operations in BulkDocs', async () => { const deletedChildDoc = getChildDoc(); deletedChildDoc._deleted = true; + const deletedSchoolDoc = getSchoolDoc(); deletedSchoolDoc._deleted = true; + const request: BulkDocsRequest = { new_edits: false, docs: [deletedChildDoc, deletedSchoolDoc], @@ -153,12 +232,61 @@ describe('BulkDocumentService', () => { }); }); + it('should apply field permissions to DELETE operations in BulkDocs', async () => { + const request: BulkDocsRequest = { + new_edits: false, + docs: [ + { + _id: 'Child:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + _deleted: true, + someProperty: 'newSomeValue', + otherProperty: 'otherValue', + }, + { + _id: 'School:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + _deleted: true, + anotherProperty: 'newAnotherValue', + }, + ], + }; + + jest.spyOn(mockRulesService, 'getRulesForUser').mockReturnValue([ + { action: 'delete', subject: 'Child', fields: ['name'] }, + { action: ['read', 'update'], subject: 'School' }, + ]); + jest + .spyOn(mockCouchDBService, 'post') + .mockReturnValue(of(createAllDocsResponse(childDoc, schoolDoc))); + + const result = await service.filterBulkDocsRequest(request, normalUser, ''); + + expect(result).toEqual({ + new_edits: false, + docs: [ + { + _id: 'Child:1', + _rev: 'someRev', + _revisions: { start: 1, ids: ['someRev'] }, + _deleted: true, + someProperty: 'someValue', + otherProperty: 'otherValue', + }, + ], + }); + }); + it('should check the permissions on the document from the database', async () => { const privateSchool = getSchoolDoc(); privateSchool.privateSchool = true; + const publicSchool = getSchoolDoc(); publicSchool._id = 'School:2'; publicSchool.privateSchool = false; + jest.spyOn(mockRulesService, 'getRulesForUser').mockReturnValue([ { action: 'update', subject: 'Child' }, { @@ -167,20 +295,26 @@ describe('BulkDocumentService', () => { conditions: { privateSchool: false }, // User is only allowed to update/delete public schools }, ]); + jest .spyOn(mockCouchDBService, 'post') .mockReturnValue(of(createAllDocsResponse(privateSchool, publicSchool))); + // User makes change to a document on which no permissions are given const updatedPrivateSchool = getSchoolDoc(); updatedPrivateSchool.privateSchool = false; updatedPrivateSchool.name = 'Not so Private School'; + // User deletes a document, permissions can't be checked directly const deletedPublicSchool: DatabaseDocument = { _id: publicSchool._id, _rev: publicSchool._rev, _revisions: publicSchool._revisions, _deleted: true, + anotherProperty: 'anotherValue', + privateSchool: false, }; + const request: BulkDocsRequest = { new_edits: false, docs: [updatedPrivateSchool, deletedPublicSchool], @@ -209,6 +343,7 @@ describe('BulkDocumentService', () => { _rev: 'someRev', _revisions: { start: 1, ids: ['someRev'] }, someProperty: 'someValue', + otherProperty: 'otherValue', }; } diff --git a/src/restricted-endpoints/replication/bulk-document/bulk-document.service.ts b/src/restricted-endpoints/replication/bulk-document/bulk-document.service.ts index 99c74e1..c04afd0 100644 --- a/src/restricted-endpoints/replication/bulk-document/bulk-document.service.ts +++ b/src/restricted-endpoints/replication/bulk-document/bulk-document.service.ts @@ -29,6 +29,15 @@ import { CouchdbService } from '../../../couchdb/couchdb.service'; */ @Injectable() export class BulkDocumentService { + private DEFAULT_FIELDS = [ + '_id', + '_rev', + '_revisions', + '_deleted', + 'updated', + 'created', + ]; + constructor( private permissionService: PermissionService, private couchdbService: CouchdbService, @@ -96,16 +105,79 @@ export class BulkDocumentService { ); return { new_edits: request.new_edits, - docs: request.docs.filter((doc) => - this.hasPermissionsForDoc( - doc, - response.rows.find((responseDoc) => responseDoc.id === doc._id), - ability, + docs: request.docs + .filter((doc) => + this.hasPermissionsForDoc( + doc, + response.rows.find((responseDoc) => responseDoc.id === doc._id), + ability, + ), + ) + .map((doc) => + this.removeFieldsWithoutPermissions( + doc, + response.rows.find((responseDoc) => responseDoc.id === doc._id), + ability, + ), ), - ), }; } + private deleteEmptyValues( + updatedDoc: DatabaseDocument, + existingDoc: DocMetaInf, + ) { + const fieldKeys = this.getCustomFieldKeys(updatedDoc); + + for (let i = 0; i < fieldKeys.length; i++) { + if ( + updatedDoc[fieldKeys[i]] === '' || + updatedDoc[fieldKeys[i]] === undefined || + updatedDoc[fieldKeys[i]] === null + ) { + delete existingDoc.doc[fieldKeys[i]]; + delete updatedDoc[fieldKeys[i]]; + } + } + + return Object.assign(existingDoc ? existingDoc.doc : {}, updatedDoc); + } + + private removeFieldsWithoutPermissions( + updatedDoc: DatabaseDocument, + existingDoc: DocMetaInf, + ability: DocumentAbility, + ): DatabaseDocument { + let action: any; + + if (existingDoc) { + if (updatedDoc._deleted) { + existingDoc.doc._deleted = true; + return existingDoc.doc; + } else { + action = 'update'; + } + } else { + action = 'create'; + } + + const fieldKeys = this.getCustomFieldKeys(updatedDoc); + + for (let i = 0; i < fieldKeys.length; i++) { + if (!ability.can(action, updatedDoc, fieldKeys[i])) { + delete updatedDoc[fieldKeys[i]]; + } + } + + return this.deleteEmptyValues(updatedDoc, existingDoc); + } + + private getCustomFieldKeys(updatedDoc: DatabaseDocument) { + return Object.keys(updatedDoc).filter( + (key: string) => !this.DEFAULT_FIELDS.includes(key), + ); + } + private hasPermissionsForDoc( updatedDoc: DatabaseDocument, existingDoc: DocMetaInf,