diff --git a/CHANGELOG.md b/CHANGELOG.md index dab2431f..fa26cea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ X.Y.Z Release notes * Added property `Realm.isInTransaction` which indicates if write transaction is in progress. * Added `shouldCompactOnLaunch` to configuration (#507). * Added `Realm.compact()` for manually compacting Realm files. +* Added various methods for permission management (#1204). ### Bug fixes * None diff --git a/docs/sync.js b/docs/sync.js index 770d8583..b8137ef0 100644 --- a/docs/sync.js +++ b/docs/sync.js @@ -242,6 +242,60 @@ class User { * } */ retrieveAccount(provider, username) {} + + /** + * Asynchronously retrieves all permissions associated with the user calling this method. + * @param {string} recipient the optional recipient of the permission. Can be either + * 'any' which is the default, or 'currentUser' or 'otherUser' if you want only permissions + * belonging to the user or *not* belonging to the user. + * @returns {Results} a queryable collection of permission objects that provides detailed + * information regarding the granted access. + * The collection is a live query similar to what you would get by callig Realm.objects, + * so the same features apply - you can listen for notifications or filter it. + */ + getGrantedPermissions(recipient) { } + + /** + * Changes the permissions of a Realm. + * @param {object} condition - A condition that will be used to match existing users against. + * This should be an object, containing either the key 'userId', or 'metadataKey' and 'metadataValue'. + * @param {string} realmUrl - The path to the Realm that you want to apply permissions to. + * @param {string} accessLevel - The access level you want to set: 'none', 'read', 'write' or 'admin'. + * @returns {Promise} a Promise that, upon completion, indicates that the permissions have been + * successfully applied by the server. It will be resolved with the + * {@link PermissionChange PermissionChange} object that refers to the applied permission. + */ + applyPermissions(condition, realmUrl, accessLevel) { } + + /** + * Generates a token that can be used for sharing a Realm. + * @param {string} realmUrl - The Realm URL whose permissions settings should be changed. Use * to change + * the permissions of all Realms managed by this user. + * @param {string} accessLevel - The access level to grant matching users. Note that the access level + * setting is additive, i.e. you cannot revoke permissions for users who previously had a higher access level. + * Can be 'read', 'write' or 'admin'. + * @param {Date} [expiresAt] - Optional expiration date of the offer. If set to null, the offer doesn't expire. + * @returns {string} - A token that can be shared with another user, e.g. via email or message and then consumed by + * User.acceptPermissionOffer to obtain permissions to a Realm. + */ + offerPermissions(realmUrl, accessLevel, expiresAt) { } + + /** + * Consumes a token generated by {@link Realm#Sync#User#offerPermissions offerPermissions} to obtain permissions to a shared Realm. + * @param {string} token - The token, generated by User.offerPermissions + * @returns {string} The url of the Realm that the token has granted permissions to. + */ + acceptPermissionOffer(token) { } + + /** + * Invalidates a permission offer. + * Invalidating an offer prevents new users from consuming its token. It doesn't revoke any permissions that have + * already been granted. + * @param {string|PermissionOffer} permissionOfferOrToken - Either the token or the entire + * {@link PermissionOffer PermissionOffer} object that was generated with + * {@link Realm#Sync#User#offerPermissions offerPermissions}. + */ + invalidatePermissionOffer(permissionOfferOrToken) { } } /** diff --git a/lib/index.d.ts b/lib/index.d.ts index b9d7a624..4ecfd612 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -271,6 +271,47 @@ declare namespace Realm.Sync { logout(): void; openManagementRealm(): Realm; retrieveAccount(provider: string, username: string): Promise; + + getGrantedPermissions(recipient: 'any' | 'currentUser' | 'otherUser'): Results; + applyPermissions(condition: PermissionCondition, realmUrl: string, accessLevel: AccessLevel): Promise; + offerPermissions(realmUrl: string, accessLevel: AccessLevel, expiresAt?: Date): Promise; + acceptPermissionOffer(token: string): Promise + invalidatePermissionOffer(permissionOfferOrToken: PermissionOffer | string): Promise; + } + + type PermissionCondition = + { [object_type: string]: userId } | + { [object_type: string]: metadataKey, [object_type: string]: metadataValue }; + + type AccessLevel = 'none' | 'read' | 'write' | 'admin'; + + class PermissionChange { + id: string; + createdAt: Date; + updatedAt: Date; + statusCode?: number; + statusMessage?: string; + userId: string; + metadataKey?: string; + metadataValue?: string; + realmUrl: string; + mayRead?: boolean; + mayWrite?: boolean; + mayManage?: boolean; + } + + class PermissionOffer { + id: string; + createdAt: Date; + updatedAt: Date; + statusCode?: number; + statusMessage?: string; + token?: string; + realmUrl: string; + mayRead?: boolean; + mayWrite?: boolean; + mayManage?: boolean; + expiresAt?: Date; } interface SyncConfiguration { diff --git a/lib/management-schema.js b/lib/management-schema.js index d5975645..3d14b80e 100644 --- a/lib/management-schema.js +++ b/lib/management-schema.js @@ -28,6 +28,8 @@ module.exports = [ statusCode: { type: 'int', optional: true }, statusMessage: { type: 'string', optional: true }, userId: { type: 'string' }, + metadataKey: { type: 'string', optional: true }, + metadataValue: { type: 'string', optional: true }, realmUrl: { type: 'string' }, mayRead: { type: 'bool', optional: true }, mayWrite: { type: 'bool', optional: true }, diff --git a/lib/permission-api.js b/lib/permission-api.js new file mode 100644 index 00000000..4ff5b843 --- /dev/null +++ b/lib/permission-api.js @@ -0,0 +1,274 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2017 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +'use strict'; + +const url_parse = require('url-parse'); +const managementSchema = require('./management-schema'); + +function generateUniqueId() { + const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + return uuid; +} + +const permissionSchema = [ + { + name: 'Permission', + properties: { + id: { type: 'string' }, + updatedAt: { type: 'date' }, + userId: { type: 'string' }, + path: { type: 'string' }, + mayRead: { type: 'bool' }, + mayWrite: { type: 'bool' }, + mayManage: { type: 'bool' }, + } + } +]; + +// Symbols are not supported on RN yet, so we use this for now: +const specialPurposeRealmsKey = '_specialPurposeRealms'; + +function getSpecialPurposeRealm(user, realmName, schema) { + if (!user.hasOwnProperty(specialPurposeRealmsKey)) { + user[specialPurposeRealmsKey] = {}; + } + + if (user[specialPurposeRealmsKey].hasOwnProperty(realmName)) { + return Promise.resolve(user[specialPurposeRealmsKey][realmName]); + } + + const url = url_parse(user.server); + if (url.protocol === 'http:') { + url.set('protocol', 'realm:'); + } else if (url.protocol === 'https:') { + url.set('protocol', 'realms:'); + } else { + throw new Error(`Unexpected user auth url: ${user.server}`); + } + + url.set('pathname', `/~/${realmName}`); + + const config = { + schema: schema, + sync: { + user, + url: url.href + } + }; + + const _Realm = user.constructor._realmConstructor; + + return new Promise((resolve, reject) => { + _Realm._waitForDownload(config, (error) => { + + // FIXME: I don't understand why, but removing the following setTimeout causes the subsequent + // setTimeout call (when resolving the promise) to hang on RN iOS. + // This might be related to our general makeCallback issue: #1255. + setTimeout(() => {}, 1); + + if (error) { + reject(error); + } + else { + try { + let syncedRealm = new _Realm(config); + user[specialPurposeRealmsKey][realmName] = syncedRealm; + //FIXME: RN hangs here. Remove when node's makeCallback alternative is implemented (#1255) + setTimeout(() => resolve(syncedRealm), 1); + } catch (e) { + reject(e); + } + } + }); + }); +} + +function createInManagementRealm(user, modelName, modelInitializer) { + return getSpecialPurposeRealm(user, '__management', managementSchema) + .then(managementRealm => { + return new Promise((resolve, reject) => { + try { + let o; + + const listener = () => { + if (!o) { + return; + } + + const statusCode = o.statusCode; + if (typeof statusCode === 'number') { + managementRealm.removeListener('change', listener); + + if (statusCode === 0) { + setTimeout(() => resolve(o), 1); + } + else { + const e = new Error(o.statusMessage); + e.statusCode = statusCode; + e.managementObject = o; + setTimeout(() => reject(e), 1); + } + } + } + + managementRealm.addListener('change', listener); + + managementRealm.write(() => { + o = managementRealm.create(modelName, modelInitializer); + }); + } + catch (e) { + reject(e); + } + }); + }); +} + +const accessLevels = ['none', 'read', 'write', 'admin']; +const offerAccessLevels = ['read', 'write', 'admin']; + +module.exports = { + getGrantedPermissions(recipient) { + if (recipient && ['currentUser', 'otherUser', 'any'].indexOf(recipient) === -1) { + return Promise.reject(new Error(`'${recipient}' is not a valid recipient type. Must be 'any', 'currentUser' or 'otherUser'.`)); + } + + return getSpecialPurposeRealm(this, '__permission', permissionSchema) + .then(permissionRealm => { + let permissions = permissionRealm.objects('Permission') + .filtered('NOT path ENDSWITH "__permission" AND NOT path ENDSWITH "__management"'); + + if (recipient === 'currentUser') { + permissions = permissions.filtered('userId = $0', this.identity); + } + else if (recipient === 'otherUser') { + permissions = permissions.filtered('userId != $0', this.identity); + } + return permissions; + }); + }, + + applyPermissions(condition, realmUrl, accessLevel) { + if (!realmUrl) { + return Promise.reject(new Error('realmUrl must be specified')); + } + + if (accessLevels.indexOf(accessLevel) === -1) { + return Promise.reject(new Error(`'${accessLevel}' is not a valid access level. Must be ${accessLevels.join(', ')}.`)); + } + + const mayRead = accessLevel === 'read' || accessLevel === 'write' || accessLevel === 'admin'; + const mayWrite = accessLevel === 'write' || accessLevel === 'admin'; + const mayManage = accessLevel === 'admin'; + + const permissionChange = { + id: generateUniqueId(), + createdAt: new Date(), + updatedAt: new Date(), + realmUrl, + mayRead, + mayWrite, + mayManage + }; + + if (condition.hasOwnProperty('userId')) { + permissionChange.userId = condition.userId; + } + else { + permissionChange.userId = ''; + permissionChange.metadataKey = condition.metadataKey; + permissionChange.metadataValue = condition.metadataValue; + } + + return createInManagementRealm(this, 'PermissionChange', permissionChange); + }, + + offerPermissions(realmUrl, accessLevel, expiresAt) { + if (!realmUrl) { + return Promise.reject(new Error('realmUrl must be specified')); + } + + if (offerAccessLevels.indexOf(accessLevel) === -1) { + return Promise.reject(new Error(`'${accessLevel}' is not a valid access level. Must be ${offerAccessLevels.join(', ')}.`)); + } + + const mayRead = true; + const mayWrite = accessLevel === 'write' || accessLevel === 'admin'; + const mayManage = accessLevel === 'admin'; + + const permissionOffer = { + id: generateUniqueId(), + createdAt: new Date(), + updatedAt: new Date(), + expiresAt, + realmUrl, + mayRead, + mayWrite, + mayManage + }; + + return createInManagementRealm(this, 'PermissionOffer', permissionOffer) + .then(appliedOffer => appliedOffer.token); + }, + + acceptPermissionOffer(token) { + if (!token) { + return Promise.reject(new Error('Offer token must be specified')); + } + + const permissionOfferResponse = { + id: generateUniqueId(), + createdAt: new Date(), + updatedAt: new Date(), + token + }; + + return createInManagementRealm(this, 'PermissionOfferResponse', permissionOfferResponse) + .then(appliedReponse => appliedReponse.realmUrl); + }, + + invalidatePermissionOffer(permissionOfferOrToken) { + return getSpecialPurposeRealm(this, '__management', managementSchema) + .then(managementRealm => { + let permissionOffer; + + if (typeof permissionOfferOrToken === 'string') { + // We were given a token, not an object. Find the matching object. + const q = managementRealm.objects('PermissionOffer') + .filtered('token = $0', permissionOfferOrToken); + + if (q.length === 0) { + throw new Error("No permission offers with the given token were found"); + } + + permissionOffer = q[0]; + } + else { + permissionOffer = permissionOfferOrToken; + } + + managementRealm.write(() => { + permissionOffer.expiresAt = new Date(); + }); + }); + } +} diff --git a/lib/user-methods.js b/lib/user-methods.js index 34815730..03363026 100644 --- a/lib/user-methods.js +++ b/lib/user-methods.js @@ -19,6 +19,7 @@ 'use strict'; const AuthError = require('./errors').AuthError; +const permissionApis = require('./permission-api'); function node_require(module) { return require(module); @@ -139,8 +140,7 @@ function _authenticate(userConstructor, server, json, callback) { .catch(callback); } -module.exports = { - static: { +const staticMethods = { get current() { const allUsers = this.all; const keys = Object.keys(allUsers); @@ -208,8 +208,9 @@ module.exports = { }, _refreshAccessToken: refreshAccessToken - }, - instance: { + }; + +const instanceMethods = { openManagementRealm() { let url = url_parse(this.server); if (url.protocol === 'http:') { @@ -257,5 +258,12 @@ module.exports = { } }); }, - }, -}; \ No newline at end of file + }; + +// Append the permission apis +Object.assign(instanceMethods, permissionApis); + +module.exports = { + static: staticMethods, + instance: instanceMethods +}; diff --git a/tests/js/index.js b/tests/js/index.js index d3f04904..730d7419 100644 --- a/tests/js/index.js +++ b/tests/js/index.js @@ -40,6 +40,12 @@ if (!(typeof process === 'object' && process.platform === 'win32')) { if (Realm.Sync) { TESTS.UserTests = require('./user-tests'); TESTS.SessionTests = require('./session-tests'); + + // FIXME: Permission tests currently fail in chrome debugging mode. + if (typeof navigator === 'undefined' || + !/Chrome/.test(navigator.userAgent)) { // eslint-disable-line no-undef + TESTS.PermissionTests = require('./permission-tests'); + } } function node_require(module) { return require(module); } diff --git a/tests/js/permission-tests.js b/tests/js/permission-tests.js new file mode 100644 index 00000000..02a9d10c --- /dev/null +++ b/tests/js/permission-tests.js @@ -0,0 +1,115 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2017 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + + +'use strict'; + +var Realm = require('realm'); +var TestCase = require('./asserts'); + +function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +function createUsersWithTestRealms(count) { + const createUserWithTestRealm = username => new Promise((resolve, reject) => { + Realm.Sync.User.register('http://localhost:9080', username, 'password', (error, user) => { + if (error) { + reject(error); + } + else { + new Realm({ sync: { user, url: 'realm://localhost:9080/~/test'}}).close(); + resolve(user); + } + }) + }); + + // Generate some usernames + const usernames = new Array(count).fill(undefined).map(uuid); + + // And turn them into users and realms + const userPromises = usernames.map(createUserWithTestRealm); + return Promise.all(userPromises); +} + +function wait(t) { + return new Promise(resolve => setTimeout(resolve, t)); +} + +module.exports = { + testApplyAndGetGrantedPermissions() { + return createUsersWithTestRealms(1) + .then(([user]) => { + return user.applyPermissions({ userId: '*' }, `/${user.identity}/test`, 'read') + .then(() => user.getGrantedPermissions('any')) + .then(permissions => { + TestCase.assertEqual(permissions[1].path, `/${user.identity}/test`); + TestCase.assertEqual(permissions[1].mayRead, true); + TestCase.assertEqual(permissions[1].mayWrite, false); + TestCase.assertEqual(permissions[1].mayManage, false); + }); + }); + }, + + testOfferPermissions() { + return createUsersWithTestRealms(2) + .then(([user1, user2]) => { + return user1.offerPermissions(`/${user1.identity}/test`, 'read') + .then(token => user2.acceptPermissionOffer(token)) + .then(realmUrl => { + TestCase.assertEqual(realmUrl, `/${user1.identity}/test`); + return user2.getGrantedPermissions('any') + .then(permissions => { + TestCase.assertEqual(permissions[1].path, `/${user1.identity}/test`); + TestCase.assertEqual(permissions[1].mayRead, true); + TestCase.assertEqual(permissions[1].mayWrite, false); + TestCase.assertEqual(permissions[1].mayManage, false); + }); + }); + }); + }, + + testInvalidatePermissionOffer() { + return createUsersWithTestRealms(2) + .then(([user1, user2]) => { + user1.offerPermissions(`/${user1.identity}/test`, 'read') + .then((token) => { + return user1.invalidatePermissionOffer(token) + // Since we don't yet support notification when the invalidation has gone through, + // wait for a bit and hope the server is done processing. + .then(wait(100)) + .then(user2.acceptPermissionOffer(token)) + // We want the call to fail, i.e. the catch() below should be called. + .then(() => { throw new Error("User was able to accept an invalid permission offer token"); }) + .catch(error => { + try { + TestCase.assertEqual(error.message, 'The permission offer is expired.'); + TestCase.assertEqual(error.statusCode, 701); + } + catch (e) { + throw new Error(e); + } + }); + }); + }); + }, +} + diff --git a/tests/react-test-app/index.android.js b/tests/react-test-app/index.android.js index 2b22be59..6e01d26b 100644 --- a/tests/react-test-app/index.android.js +++ b/tests/react-test-app/index.android.js @@ -51,7 +51,7 @@ async function runTests() { await runTest(suiteName, testName); } catch (e) { - itemTest.ele('error', {'message': ''}, e.message); + itemTest.ele('error', {'message': e.message, 'stacktrace': e.stack}, e.toString()); nbrFailures++; } }