Skip to content

ACL for Parse.File #7000

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

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"lru-cache": "5.1.1",
"mime": "2.4.6",
"mongodb": "3.6.2",
"otplib": "^12.0.1",
"parse": "2.17.0",
"pg-promise": "10.6.2",
"pluralize": "8.0.0",
Expand Down
104 changes: 100 additions & 4 deletions spec/ParseFile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,19 +304,19 @@ describe('Parse.File testing', () => {
let firstName;
let secondName;

const firstSave = file.save().then(function() {
const firstSave = file.save().then(function () {
firstName = file.name();
});
const secondSave = file.save().then(function() {
const secondSave = file.save().then(function () {
secondName = file.name();
});

Promise.all([firstSave, secondSave]).then(
function() {
function () {
equal(firstName, secondName);
done();
},
function(error) {
function (error) {
ok(false, error);
done();
}
Expand Down Expand Up @@ -872,4 +872,100 @@ describe('Parse.File testing', () => {
});
});
});
it('can save file and get', async done => {
const user = new Parse.User();
await user.signUp({
username: 'hello',
password: 'password',
});
const file = new Parse.File('hello.txt', data, 'text/plain');
const acl = new Parse.ACL();
acl.setPublicReadAccess(false);
acl.setReadAccess(user, true);
file.setTags({ acl: acl.toJSON() });
const result = await file.save({ sessionToken: user.getSessionToken() });
strictEqual(result, file);
ok(file.name());
ok(file.url());
notEqual(file.name(), 'hello.txt');
const object = new Parse.Object('TestObject');
await object.save({ file: file }, { sessionToken: user.getSessionToken() });

const query = await new Parse.Query('TestObject').get(object.id, {
sessionToken: user.getSessionToken(),
});
const aclFile = query.get('file');
expect(aclFile instanceof Parse.File);
expect(aclFile.url()).toBeDefined();
expect(aclFile.url()).toContain('token');
try {
const response = await request({
url: aclFile.url(),
});
expect(response.text).toEqual('Hello World!');
done();
} catch (e) {
fail('should have been able to get file.');
}
});
it('can save file and not get public', async done => {
const user = new Parse.User();
await user.signUp({
username: 'hello',
password: 'password',
});
const file = new Parse.File('hello.txt', data, 'text/plain');
const acl = new Parse.ACL();
acl.setPublicReadAccess(false);
acl.setReadAccess(user, true);
file.setTags({ acl: acl.toJSON() });
const result = await file.save({ sessionToken: user.getSessionToken() });
strictEqual(result, file);
ok(file.name());
ok(file.url());
notEqual(file.name(), 'hello.txt');
const object = new Parse.Object('TestObject');
await object.save({ file: file }, { sessionToken: user.getSessionToken() });

await Parse.User.logOut();
const query = await new Parse.Query('TestObject').get(object.id);
const aclFile = query.get('file');
expect(aclFile instanceof Parse.File);
expect(aclFile.url()).toBeDefined();
expect(aclFile.url()).not.toContain('token');
try {
await request({
url: aclFile.url(),
});
fail('should not have been able to get file.');
} catch (e) {
expect(e.text).toBe('File not found.');
expect(e.status).toBe(404);
done();
}
});
it('can query file data', async done => {
const user = new Parse.User();
await user.signUp({
username: 'hello',
password: 'password',
});
const file = new Parse.File('hello.txt', data, 'text/plain');
const acl = new Parse.ACL();
acl.setPublicReadAccess(false);
acl.setReadAccess(user, true);
file.setTags({ acl: acl.toJSON() });
await file.save({ sessionToken: user.getSessionToken() });
const query = new Parse.Query('_File');
try {
await query.first();
fail('Should not have been able to query _Files');
} catch (e) {
expect(e.code).toBe(119);
expect(e.message).toBe(
"Clients aren't allowed to perform the find operation on the _File collection."
);
done();
}
});
});
1 change: 1 addition & 0 deletions spec/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ afterEach(function (done) {
'_Installation',
'_Role',
'_Session',
'_File',
'_Product',
'_Audience',
'_Idempotency',
Expand Down
10 changes: 5 additions & 5 deletions src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
'_GraphQLConfig',
'_Audience',
'_Idempotency',
'_File',
...results.map(result => result.className),
...joins,
];
Expand Down Expand Up @@ -2492,11 +2493,10 @@ export class PostgresStorageAdapter implements StorageAdapter {
return (conn || this._client).tx(t =>
t.batch(
indexes.map(i => {
return t.none('CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)', [
i.name,
className,
i.key,
]);
return t.none(
'CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)',
[i.name, className, i.key]
);
})
)
);
Expand Down
57 changes: 55 additions & 2 deletions src/Controllers/FilesController.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import AdaptableController from './AdaptableController';
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
import path from 'path';
import mime from 'mime';
import { authenticator } from 'otplib';
const Parse = require('parse').Parse;

const legacyFilesRegex = new RegExp(
Expand Down Expand Up @@ -51,15 +52,65 @@ export class FilesController extends AdaptableController {
}
return Promise.resolve({});
}
async getAuthForFile(config, file, auth) {
const [fileObject] = await config.database.find('_File', {
file,
});
const user = auth.user;
if (fileObject && fileObject.authACL) {
const acl = new Parse.ACL(fileObject.authACL);
if (!acl || acl.getPublicReadAccess() || !user) {
return;
}
const isAllowed = () => {
if (acl.getReadAccess(user.id)) {
return true;
}

// Check if the user has any roles that match the ACL
return Promise.resolve()
.then(async () => {
// Resolve false right away if the acl doesn't have any roles
const acl_has_roles = Object.keys(acl.permissionsById).some(key =>
key.startsWith('role:')
);
if (!acl_has_roles) {
return false;
}

const roleNames = await auth.getUserRoles();
// Finally, see if any of the user's roles allow them read access
for (const role of roleNames) {
// We use getReadAccess as `role` is in the form `role:roleName`
if (acl.getReadAccess(role)) {
return true;
}
}
return false;
})
.catch(() => {
return false;
});
};
const allowed = await isAllowed();
if (allowed) {
const token = authenticator.generate(fileObject.authSecret);
file.url = file.url + '?token=' + token;
}
}
}
/**
* Find file references in REST-format object and adds the url key
* with the current mount point and app id.
* Object may be a single object or list of REST-format objects.
*/
expandFilesInObject(config, object) {
async expandFilesInObject(config, object, auth) {
if (object instanceof Array) {
object.map(obj => this.expandFilesInObject(config, obj));
await Promise.all(
object.map(
async obj => await this.expandFilesInObject(config, obj, auth)
)
);
return;
}
if (typeof object !== 'object') {
Expand All @@ -69,6 +120,7 @@ export class FilesController extends AdaptableController {
const fileObject = object[key];
if (fileObject && fileObject['__type'] === 'File') {
if (fileObject['url']) {
await this.getAuthForFile(config, fileObject, auth);
continue;
}
const filename = fileObject['name'];
Expand All @@ -94,6 +146,7 @@ export class FilesController extends AdaptableController {
fileObject['url'] = this.adapter.getFileLocation(config, filename);
}
}
await this.getAuthForFile(config, fileObject, auth);
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/Controllers/SchemaController.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({
expiresAt: { type: 'Date' },
createdWith: { type: 'Object' },
},
_File: {
file: { type: 'File' },
references: { type: 'Number' },
authSecret: { type: 'String' },
authACL: { type: 'Object' },
},
_Product: {
productIdentifier: { type: 'String' },
download: { type: 'File' },
Expand Down Expand Up @@ -160,6 +166,7 @@ const systemClasses = Object.freeze([
'_Installation',
'_Role',
'_Session',
'_File',
'_Product',
'_PushStatus',
'_JobStatus',
Expand All @@ -177,6 +184,7 @@ const volatileClasses = Object.freeze([
'_JobSchedule',
'_Audience',
'_Idempotency',
'_File',
]);

// Anything that start with role
Expand Down Expand Up @@ -673,6 +681,13 @@ const _IdempotencySchema = convertSchemaToAdapterSchema(
classLevelPermissions: {},
})
);
const _FileSchema = convertSchemaToAdapterSchema(
injectDefaultSchema({
className: '_File',
fields: defaultColumns._File,
classLevelPermissions: {},
})
);
const VolatileClassesSchemas = [
_HooksSchema,
_JobStatusSchema,
Expand All @@ -682,6 +697,7 @@ const VolatileClassesSchemas = [
_GraphQLConfigSchema,
_AudienceSchema,
_IdempotencySchema,
_FileSchema,
];

const dbTypeMatchesObjectType = (
Expand Down
13 changes: 10 additions & 3 deletions src/RestQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -663,17 +663,24 @@ RestQuery.prototype.runFind = function (options = {}) {
if (options.op) {
findOptions.op = options.op;
}
let results = [];
return this.config.database
.find(this.className, this.restWhere, findOptions, this.auth)
.then(results => {
.then(response => {
results = response;
if (this.className === '_User' && findOptions.explain !== true) {
for (var result of results) {
cleanResultAuthData(result);
}
}

this.config.filesController.expandFilesInObject(this.config, results);

return this.config.filesController.expandFilesInObject(
this.config,
results,
this.auth
);
})
.then(() => {
if (this.redirectClassName) {
for (var r of results) {
r.className = this.redirectClassName;
Expand Down
14 changes: 9 additions & 5 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,11 @@ RestWrite.prototype.runBeforeLoginTrigger = async function (userData) {
const extraData = { className: this.className };

// Expand file objects
this.config.filesController.expandFilesInObject(this.config, userData);

await this.config.filesController.expandFilesInObject(
this.config,
userData,
this.auth
);
const user = triggers.inflate(extraData, userData);

// no need to return a response
Expand Down Expand Up @@ -1394,12 +1397,13 @@ RestWrite.prototype.handleInstallation = function () {
// If we short-circuted the object response - then we need to make sure we expand all the files,
// since this might not have a query, meaning it won't return the full result back.
// TODO: (nlutsenko) This should die when we move to per-class based controllers on _Session/_User
RestWrite.prototype.expandFilesForExistingObjects = function () {
RestWrite.prototype.expandFilesForExistingObjects = async function () {
// Check whether we have a short-circuited response - only then run expansion.
if (this.response && this.response.response) {
this.config.filesController.expandFilesInObject(
await this.config.filesController.expandFilesInObject(
this.config,
this.response.response
this.response.response,
this.auth
);
}
};
Expand Down
Loading