Permissions api (#1244)

* Add basic permissions skeleton

* ...

* Update permissions api

* Wait for server to process management realm, add offer api

* Fix test of apply and get permissions, add offer test

* Accept permission offer

* Accept permission offer test

* Invalidate permission offer

* Add basic docs (still need some links etc)

* Refactor tests

* Only run permission tests where sync is enabled

* Use legal syntax for user name generation

* Add changelog entry

* Streamline permission tests

* Fix casing for access level names etc.

* Add basic definitions to index.d.ts

* Use settimeout for resolving promise from listener

* Complete typescript defs

* Improve docs

* Allow 'any' as default recipient in getGrantedPermissions

* Fix getSpecialPurposeRealm on iOS

* Response to PR comments

* Respond to PR comments

* Fix offer description

* Skip permission tests in chrome debugger :-/
This commit is contained in:
Kristian Dupont 2017-08-29 15:23:22 +02:00 committed by GitHub
parent f7bf9df6e5
commit 402bf48f88
9 changed files with 508 additions and 7 deletions

View File

@ -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

View File

@ -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) { }
}
/**

41
lib/index.d.ts vendored
View File

@ -271,6 +271,47 @@ declare namespace Realm.Sync {
logout(): void;
openManagementRealm(): Realm;
retrieveAccount(provider: string, username: string): Promise<Account>;
getGrantedPermissions(recipient: 'any' | 'currentUser' | 'otherUser'): Results<Permission>;
applyPermissions(condition: PermissionCondition, realmUrl: string, accessLevel: AccessLevel): Promise<PermissionChange>;
offerPermissions(realmUrl: string, accessLevel: AccessLevel, expiresAt?: Date): Promise<string>;
acceptPermissionOffer(token: string): Promise<string>
invalidatePermissionOffer(permissionOfferOrToken: PermissionOffer | string): Promise<void>;
}
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 {

View File

@ -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 },

274
lib/permission-api.js Normal file
View File

@ -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();
});
});
}
}

View File

@ -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 = {
}
});
},
},
};
// Append the permission apis
Object.assign(instanceMethods, permissionApis);
module.exports = {
static: staticMethods,
instance: instanceMethods
};

View File

@ -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); }

View File

@ -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);
}
});
});
});
},
}

View File

@ -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++;
}
}