-
Notifications
You must be signed in to change notification settings - Fork 1
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
feat: respect field level permissions on bulk-update #120
base: master
Are you sure you want to change the base?
Changes from all commits
f83e6d1
faa6c41
22bc1bb
9908d91
0ac7d6e
e3b1851
879bea0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We explicitly implemented the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was not aware of this regulation, thanks. |
||
}; | ||
|
||
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', | ||
}; | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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( | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the review and later following the code I think it would help to sort the functions in a file according to their importance and call order. Having this function before |
||||||||||||||||||||||
updatedDoc: DatabaseDocument, | ||||||||||||||||||||||
existingDoc: DocMetaInf, | ||||||||||||||||||||||
) { | ||||||||||||||||||||||
const fieldKeys = this.getCustomFieldKeys(updatedDoc); | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Its a bit weird that you have to call this again. You could just pass the values from the other function. |
||||||||||||||||||||||
|
||||||||||||||||||||||
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]]; | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you add a test for this behavior? |
||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
|
||||||||||||||||||||||
return Object.assign(existingDoc ? existingDoc.doc : {}, updatedDoc); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
|
||||||||||||||||||||||
private removeFieldsWithoutPermissions( | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we move this logic into a separate service so that it can be used by all endpoints that send changes to the CouchDB? |
||||||||||||||||||||||
updatedDoc: DatabaseDocument, | ||||||||||||||||||||||
existingDoc: DocMetaInf, | ||||||||||||||||||||||
ability: DocumentAbility, | ||||||||||||||||||||||
): DatabaseDocument { | ||||||||||||||||||||||
let action: any; | ||||||||||||||||||||||
|
||||||||||||||||||||||
if (existingDoc) { | ||||||||||||||||||||||
if (updatedDoc._deleted) { | ||||||||||||||||||||||
existingDoc.doc._deleted = true; | ||||||||||||||||||||||
return existingDoc.doc; | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's make sure to delete all properties on the doc too (maybe keeping |
||||||||||||||||||||||
} else { | ||||||||||||||||||||||
action = 'update'; | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} else { | ||||||||||||||||||||||
action = 'create'; | ||||||||||||||||||||||
} | ||||||||||||||||||||||
|
||||||||||||||||||||||
const fieldKeys = this.getCustomFieldKeys(updatedDoc); | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think using the CASL function |
||||||||||||||||||||||
|
||||||||||||||||||||||
for (let i = 0; i < fieldKeys.length; i++) { | ||||||||||||||||||||||
if (!ability.can(action, updatedDoc, fieldKeys[i])) { | ||||||||||||||||||||||
delete updatedDoc[fieldKeys[i]]; | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
Comment on lines
+166
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, any reason why you did not got with the approach we already implemented in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this function in a Controller? Did not saw it there. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I showed it to you initially when we talked about the requirements for this feature. It was only in the controller because we did not yet use it in other places. |
||||||||||||||||||||||
|
||||||||||||||||||||||
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, | ||||||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure whether this kind of rule makes sense for us to support. I am not even sure how I should understand the rule here: Does it mean I am allowed to delete the
name
property or does it mean that I am allowed to delete the entity and change thename
property?. How we (or PouchDB) currently delete is by only sending a doc with the_deleted
property set without the remaining properties (see screenshot below). So what you are describing here is more like aupdate
anddelete
. I think it would make sense that for delete rule we do not implement any field level logic but only use this forread
andupdate
(and alsocreate
?) rules.