Granular CLP pointer permissions (#6352)

* set pointer permissions per operatioon; tests

* more tests

* fixes addField permission; tests
This commit is contained in:
Old Grandpa
2020-01-28 09:21:30 +03:00
committed by Antonio Davi Macedo Coelho de Castro
parent 4beb89fc2e
commit 3c46117d9b
10 changed files with 1380 additions and 37 deletions

View File

@@ -313,7 +313,15 @@ describe('Parse.Object testing', () => {
it('invalid __type', function(done) { it('invalid __type', function(done) {
const item = new Parse.Object('Item'); const item = new Parse.Object('Item');
const types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes', 'Polygon']; const types = [
'Pointer',
'File',
'Date',
'GeoPoint',
'Bytes',
'Polygon',
'Relation',
];
const tests = types.map(type => { const tests = types.map(type => {
const test = new Parse.Object('Item'); const test = new Parse.Object('Item');
test.set('foo', { test.set('foo', {

File diff suppressed because it is too large Load Diff

View File

@@ -758,4 +758,37 @@ describe('ProtectedFields', function() {
}); });
}); });
}); });
describe('schema setup', () => {
const className = 'AObject';
async function updateCLP(clp) {
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
await schemaController.updateClass(className, {}, clp);
}
it('should fail setting non-existing protected field', async () => {
const object = new Parse.Object(className, {
revision: 0,
});
await object.save();
const field = 'non-existing';
const entity = '*';
await expectAsync(
updateCLP({
protectedFields: {
[entity]: [field],
},
})
).toBeRejectedWith(
new Parse.Error(
Parse.Error.INVALID_JSON,
`Field '${field}' in protectedFields:${entity} does not exist`
)
);
});
});
}); });

View File

@@ -1665,7 +1665,7 @@ describe('Class Level Permissions for requiredAuth', () => {
); );
}); });
it('required auth test get not authenitcated', done => { it('required auth test get not authenticated', done => {
config.database config.database
.loadSchema() .loadSchema()
.then(schema => { .then(schema => {
@@ -1704,7 +1704,7 @@ describe('Class Level Permissions for requiredAuth', () => {
); );
}); });
it('required auth test find not authenitcated', done => { it('required auth test find not authenticated', done => {
config.database config.database
.loadSchema() .loadSchema()
.then(schema => { .then(schema => {

View File

@@ -2752,6 +2752,115 @@ describe('schemas', () => {
); );
}); });
it('should reject creating class schema with field with invalid key', async done => {
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
const fieldName = '1invalid';
const schemaCreation = () =>
schemaController.addClassIfNotExists('AnObject', {
[fieldName]: { __type: 'String' },
});
await expectAsync(schemaCreation()).toBeRejectedWith(
new Parse.Error(
Parse.Error.INVALID_KEY_NAME,
`invalid field name: ${fieldName}`
)
);
done();
});
it('should reject creating invalid field name', async done => {
const object = new Parse.Object('AnObject');
await expectAsync(
object.save({
'!12field': 'field',
})
).toBeRejectedWith(new Parse.Error(Parse.Error.INVALID_KEY_NAME));
done();
});
it('should be rejected if CLP operation is not an object', async done => {
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
const operationKey = 'get';
const operation = true;
const schemaSetup = async () =>
await schemaController.addClassIfNotExists(
'AnObject',
{},
{
[operationKey]: operation,
}
);
await expectAsync(schemaSetup()).toBeRejectedWith(
new Parse.Error(
Parse.Error.INVALID_JSON,
`'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object`
)
);
done();
});
it('should be rejected if CLP protectedFields is not an object', async done => {
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
const operationKey = 'get';
const operation = 'wrongtype';
const schemaSetup = async () =>
await schemaController.addClassIfNotExists(
'AnObject',
{},
{
[operationKey]: operation,
}
);
await expectAsync(schemaSetup()).toBeRejectedWith(
new Parse.Error(
Parse.Error.INVALID_JSON,
`'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object`
)
);
done();
});
it('should be rejected if CLP read/writeUserFields is not an array', async done => {
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
const operationKey = 'readUserFields';
const operation = true;
const schemaSetup = async () =>
await schemaController.addClassIfNotExists(
'AnObject',
{},
{
[operationKey]: operation,
}
);
await expectAsync(schemaSetup()).toBeRejectedWith(
new Parse.Error(
Parse.Error.INVALID_JSON,
`'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array`
)
);
done();
});
describe('index management', () => { describe('index management', () => {
beforeEach(() => require('../lib/TestUtils').destroyAllDataPermanently()); beforeEach(() => require('../lib/TestUtils').destroyAllDataPermanently());
it('cannot create index if field does not exist', done => { it('cannot create index if field does not exist', done => {

View File

@@ -16,6 +16,8 @@ export type QueryOptions = {
readPreference?: ?string, readPreference?: ?string,
hint?: ?mixed, hint?: ?mixed,
explain?: Boolean, explain?: Boolean,
action?: string,
addsField?: boolean,
}; };
export type UpdateQueryOptions = { export type UpdateQueryOptions = {

View File

@@ -553,9 +553,10 @@ class DatabaseController {
className: string, className: string,
object: any, object: any,
query: any, query: any,
{ acl }: QueryOptions runOptions: QueryOptions
): Promise<boolean> { ): Promise<boolean> {
let schema; let schema;
const acl = runOptions.acl;
const isMaster = acl === undefined; const isMaster = acl === undefined;
var aclGroup: string[] = acl || []; var aclGroup: string[] = acl || [];
return this.loadSchema() return this.loadSchema()
@@ -564,7 +565,13 @@ class DatabaseController {
if (isMaster) { if (isMaster) {
return Promise.resolve(); return Promise.resolve();
} }
return this.canAddField(schema, className, object, aclGroup); return this.canAddField(
schema,
className,
object,
aclGroup,
runOptions
);
}) })
.then(() => { .then(() => {
return schema.validateObject(className, object, query); return schema.validateObject(className, object, query);
@@ -575,7 +582,7 @@ class DatabaseController {
className: string, className: string,
query: any, query: any,
update: any, update: any,
{ acl, many, upsert }: FullQueryOptions = {}, { acl, many, upsert, addsField }: FullQueryOptions = {},
skipSanitization: boolean = false, skipSanitization: boolean = false,
validateOnly: boolean = false, validateOnly: boolean = false,
validSchemaController: SchemaController.SchemaController validSchemaController: SchemaController.SchemaController
@@ -608,6 +615,21 @@ class DatabaseController {
query, query,
aclGroup aclGroup
); );
if (addsField) {
query = {
$and: [
query,
this.addPointerPermissions(
schemaController,
className,
'addField',
query,
aclGroup
),
],
};
}
} }
if (!query) { if (!query) {
return Promise.resolve(); return Promise.resolve();
@@ -994,7 +1016,8 @@ class DatabaseController {
schema: SchemaController.SchemaController, schema: SchemaController.SchemaController,
className: string, className: string,
object: any, object: any,
aclGroup: string[] aclGroup: string[],
runOptions: QueryOptions
): Promise<void> { ): Promise<void> {
const classSchema = schema.schemaData[className]; const classSchema = schema.schemaData[className];
if (!classSchema) { if (!classSchema) {
@@ -1014,7 +1037,11 @@ class DatabaseController {
return schemaFields.indexOf(field) < 0; return schemaFields.indexOf(field) < 0;
}); });
if (newKeys.length > 0) { if (newKeys.length > 0) {
return schema.validatePermission(className, aclGroup, 'addField'); // adds a marker that new field is being adding during update
runOptions.addsField = true;
const action = runOptions.action;
return schema.validatePermission(className, aclGroup, 'addField', action);
} }
return Promise.resolve(); return Promise.resolve();
} }
@@ -1525,28 +1552,50 @@ class DatabaseController {
}); });
} }
// Constraints query using CLP's pointer permissions (PP) if any.
// 1. Etract the user id from caller's ACLgroup;
// 2. Exctract a list of field names that are PP for target collection and operation;
// 3. Constraint the original query so that each PP field must
// point to caller's id (or contain it in case of PP field being an array)
addPointerPermissions( addPointerPermissions(
schema: SchemaController.SchemaController, schema: SchemaController.SchemaController,
className: string, className: string,
operation: string, operation: string,
query: any, query: any,
aclGroup: any[] = [] aclGroup: any[] = []
) { ): any {
// Check if class has public permission for operation // Check if class has public permission for operation
// If the BaseCLP pass, let go through // If the BaseCLP pass, let go through
if (schema.testPermissionsForClassName(className, aclGroup, operation)) { if (schema.testPermissionsForClassName(className, aclGroup, operation)) {
return query; return query;
} }
const perms = schema.getClassLevelPermissions(className); const perms = schema.getClassLevelPermissions(className);
const field =
['get', 'find'].indexOf(operation) > -1
? 'readUserFields'
: 'writeUserFields';
const userACL = aclGroup.filter(acl => { const userACL = aclGroup.filter(acl => {
return acl.indexOf('role:') != 0 && acl != '*'; return acl.indexOf('role:') != 0 && acl != '*';
}); });
const groupKey =
['get', 'find', 'count'].indexOf(operation) > -1
? 'readUserFields'
: 'writeUserFields';
const permFields = [];
if (perms[operation] && perms[operation].pointerFields) {
permFields.push(...perms[operation].pointerFields);
}
if (perms[groupKey]) {
for (const field of perms[groupKey]) {
if (!permFields.includes(field)) {
permFields.push(field);
}
}
}
// the ACL should have exactly 1 user // the ACL should have exactly 1 user
if (perms && perms[field] && perms[field].length > 0) { if (permFields.length > 0) {
// the ACL should have exactly 1 user
// No user set return undefined // No user set return undefined
// If the length is > 1, that means we didn't de-dupe users correctly // If the length is > 1, that means we didn't de-dupe users correctly
if (userACL.length != 1) { if (userACL.length != 1) {
@@ -1559,7 +1608,6 @@ class DatabaseController {
objectId: userId, objectId: userId,
}; };
const permFields = perms[field];
const ors = permFields.flatMap(key => { const ors = permFields.flatMap(key => {
// constraint for single pointer setup // constraint for single pointer setup
const q = { const q = {
@@ -1588,7 +1636,7 @@ class DatabaseController {
query: any = {}, query: any = {},
aclGroup: any[] = [], aclGroup: any[] = [],
auth: any = {} auth: any = {}
) { ): null | string[] {
const perms = schema.getClassLevelPermissions(className); const perms = schema.getClassLevelPermissions(className);
if (!perms) return null; if (!perms) return null;

View File

@@ -182,11 +182,14 @@ const publicRegex = /^\*$/;
const requireAuthenticationRegex = /^requiresAuthentication$/; const requireAuthenticationRegex = /^requiresAuthentication$/;
const pointerFieldsRegex = /^pointerFields$/;
const permissionKeyRegex = Object.freeze([ const permissionKeyRegex = Object.freeze([
roleRegex, roleRegex,
pointerPermissionRegex, pointerPermissionRegex,
publicRegex, publicRegex,
requireAuthenticationRegex, requireAuthenticationRegex,
pointerFieldsRegex,
]); ]);
function validatePermissionKey(key, userIdRegExp) { function validatePermissionKey(key, userIdRegExp) {
@@ -238,26 +241,19 @@ function validateCLP(
} }
const operation = perms[operationKey]; const operation = perms[operationKey];
if (!operation) { // proceed with next operationKey
// proceed with next operationKey
continue; // throws when root fields are of wrong type
} validateCLPjson(operation, operationKey);
// validate grouped pointer permissions
if ( if (
operationKey === 'readUserFields' || operationKey === 'readUserFields' ||
operationKey === 'writeUserFields' operationKey === 'writeUserFields'
) { ) {
// validate grouped pointer permissions
// must be an array with field names // must be an array with field names
if (!Array.isArray(operation)) { for (const fieldName of operation) {
throw new Parse.Error( validatePointerPermission(fieldName, fields, operationKey);
Parse.Error.INVALID_JSON,
`'${operation}' is not a valid value for class level permissions ${operationKey}`
);
} else {
for (const fieldName of operation) {
validatePointerPermission(fieldName, fields, operationKey);
}
} }
// readUserFields and writerUserFields do not have nesdted fields // readUserFields and writerUserFields do not have nesdted fields
// proceed with next operationKey // proceed with next operationKey
@@ -299,11 +295,29 @@ function validateCLP(
// "*" - Public, // "*" - Public,
// "requiresAuthentication" - authenticated users, // "requiresAuthentication" - authenticated users,
// "objectId" - _User id, // "objectId" - _User id,
// "role:objectId", // "role:rolename",
// "pointerFields" - array of field names containing pointers to users
for (const entity in operation) { for (const entity in operation) {
// throws on unexpected key // throws on unexpected key
validatePermissionKey(entity, userIdRegExp); validatePermissionKey(entity, userIdRegExp);
if (entity === 'pointerFields') {
const pointerFields = operation[entity];
if (Array.isArray(pointerFields)) {
for (const pointerField of pointerFields) {
validatePointerPermission(pointerField, fields, operation);
}
} else {
throw new Parse.Error(
Parse.Error.INVALID_JSON,
`'${pointerFields}' is not a valid value for protectedFields[${entity}] - expected an array.`
);
}
// proceed with next entity key
continue;
}
const permit = operation[entity]; const permit = operation[entity];
if (permit !== true) { if (permit !== true) {
@@ -316,13 +330,34 @@ function validateCLP(
} }
} }
function validateCLPjson(operation: any, operationKey: string) {
if (operationKey === 'readUserFields' || operationKey === 'writeUserFields') {
if (!Array.isArray(operation)) {
throw new Parse.Error(
Parse.Error.INVALID_JSON,
`'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array`
);
}
} else {
if (typeof operation === 'object' && operation !== null) {
// ok to proceed
return;
} else {
throw new Parse.Error(
Parse.Error.INVALID_JSON,
`'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object`
);
}
}
}
function validatePointerPermission( function validatePointerPermission(
fieldName: string, fieldName: string,
fields: Object, fields: Object,
operation: string operation: string
) { ) {
// Uses collection schema to ensure the field is of type: // Uses collection schema to ensure the field is of type:
// - Pointer<_User> (pointers/relations) // - Pointer<_User> (pointers)
// - Array // - Array
// //
// It's not possible to enforce type on Array's items in schema // It's not possible to enforce type on Array's items in schema
@@ -1340,7 +1375,8 @@ export default class SchemaController {
classPermissions: ?any, classPermissions: ?any,
className: string, className: string,
aclGroup: string[], aclGroup: string[],
operation: string operation: string,
action?: string
) { ) {
if ( if (
SchemaController.testPermissions(classPermissions, aclGroup, operation) SchemaController.testPermissions(classPermissions, aclGroup, operation)
@@ -1394,6 +1430,16 @@ export default class SchemaController {
) { ) {
return Promise.resolve(); return Promise.resolve();
} }
const pointerFields = classPermissions[operation].pointerFields;
if (Array.isArray(pointerFields) && pointerFields.length > 0) {
// any op except 'addField as part of create' is ok.
if (operation !== 'addField' || action === 'update') {
// We can allow adding field on update flow only.
return Promise.resolve();
}
}
throw new Parse.Error( throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN, Parse.Error.OPERATION_FORBIDDEN,
`Permission denied for action ${operation} on class ${className}.` `Permission denied for action ${operation} on class ${className}.`
@@ -1401,12 +1447,18 @@ export default class SchemaController {
} }
// Validates an operation passes class-level-permissions set in the schema // Validates an operation passes class-level-permissions set in the schema
validatePermission(className: string, aclGroup: string[], operation: string) { validatePermission(
className: string,
aclGroup: string[],
operation: string,
action?: string
) {
return SchemaController.validatePermission( return SchemaController.validatePermission(
this.getClassLevelPermissions(className), this.getClassLevelPermissions(className),
className, className,
aclGroup, aclGroup,
operation operation,
action
); );
} }

View File

@@ -31,7 +31,8 @@ function RestWrite(
query, query,
data, data,
originalData, originalData,
clientSDK clientSDK,
action
) { ) {
if (auth.isReadOnly) { if (auth.isReadOnly) {
throw new Parse.Error( throw new Parse.Error(
@@ -47,6 +48,10 @@ function RestWrite(
this.runOptions = {}; this.runOptions = {};
this.context = {}; this.context = {};
if (action) {
this.runOptions.action = action;
}
if (!query) { if (!query) {
if (this.config.allowCustomObjectId) { if (this.config.allowCustomObjectId) {
if ( if (

View File

@@ -251,7 +251,8 @@ function update(config, auth, className, restWhere, restObject, clientSDK) {
restWhere, restWhere,
restObject, restObject,
originalRestObject, originalRestObject,
clientSDK clientSDK,
'update'
).execute(); ).execute();
}) })
.catch(error => { .catch(error => {