Skip to content

Commit

Permalink
Merge pull request #51 from Kashoo/issue-24-dynamic-channel-access
Browse files Browse the repository at this point in the history
Issue #24: Allow assignment of channels to users and roles
  • Loading branch information
dkichler authored Nov 23, 2016
2 parents fcef051 + 0ba418e commit 16e52ff
Show file tree
Hide file tree
Showing 8 changed files with 514 additions and 16 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
- [#28](https://github.com/Kashoo/synctos/issues/28): Parameter to allow unknown properties in a document or object
- [#49](https://github.com/Kashoo/synctos/issues/49): Explicitly declare JSHint rules
- [#24](https://github.com/Kashoo/synctos/issues/24): Support dynamic assignment of channels to roles and users

## [1.2.0]
### Added
Expand Down
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,32 @@ And an example of a more complex custom type filter:
}
```

* `accessAssignments`: (optional) Defines the channels to dynamically assign to users and/or roles when a document of the corresponding type is successfully created, replaced or deleted. It is specified as a list, where each entry is an object that defines `users`, `roles` and/or `channels` properties. The value of each property can be either a list of strings that specify the raw user/role/channel names or a function that returns the corresponding values as a list and accepts the following parameters: (1) the new document, (2) the old document that is being replaced/deleted (if any). NOTE: In cases where the document is in the process of being deleted, the first parameter's `_deleted` property will be true, so be sure to account for such cases. An example:

```
accessAssignments: [
{
users: [ 'user1', 'user2' ],
channels: [ 'channel1' ]
},
{
roles: [ 'role1', 'role2' ],
channels: [ 'channel2' ]
},
{
users: function(doc, oldDoc) {
return doc.users;
},
roles: function(doc, oldDoc) {
return doc.roles;
},
channels: function(doc, oldDoc) {
return [ doc._id + '-channel3', doc._id + '-channel4' ];
}
},
]
```

* `allowAttachments`: (optional) Whether to allow the addition of [file attachments](http://developer.couchbase.com/documentation/mobile/current/develop/references/sync-gateway/rest-api/document-public/put-db-doc-attachment/index.html) for the document type. Defaults to `false` to prevent malicious/misbehaving clients from polluting the bucket/database with unwanted files.
* `allowUnknownProperties`: (optional) Whether to allow the existence of properties that are not explicitly declared in the document type definition. Not applied recursively to objects that are nested within documents of this type. Defaults to `false`.
* `immutable`: (optional) The document cannot be replaced or deleted after it is created. Note that, even if attachments are allowed for this document type (see the `allowAttachments` parameter for more info), it will not be possible to create, modify or delete attachments in a document that already exists, which means that they must be created inline in the document's `_attachments` property when the document is first created. Defaults to `false`.
Expand Down Expand Up @@ -406,10 +432,23 @@ it('can create a myDocType document', function() {
_id: 'myDocId',
type: 'myDocType',
foo: 'bar',
bar: -32
bar: -32,
members: [ 'joe', 'nancy' ]
}
testHelper.verifyDocumentCreated(doc, [ 'my-add-channel1', 'my-add-channel2' ]);
testHelper.verifyDocumentCreated(
doc,
[ 'my-add-channel1', 'my-add-channel2' ],
[
{
expectedUsers: function(doc, oldDoc) {
return doc.members;
},
expectedChannels: function(doc, oldDoc) {
return 'view-' + doc._id;
}
}
]);
});
```

Expand Down
66 changes: 66 additions & 0 deletions etc/sync-function-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,68 @@ function synctos(doc, oldDoc) {
}
}

function prefixItem(item, prefix) {
return (prefix ? prefix + item : item.toString());
}

function resolveCollectionItems(originalItems, itemPrefix) {
if (isValueNullOrUndefined(originalItems)) {
return [ ];
} else if (originalItems instanceof Array) {
var resultItems = [ ];
for (var i = 0; i < originalItems.length; i++) {
var item = originalItems[i];

if (isValueNullOrUndefined(item)) {
continue;
}

resultItems.push(prefixItem(item, itemPrefix));
}

return resultItems;
} else {
// Represents a single item
return [ prefixItem(originalItems, itemPrefix) ];
}
}

function resolveCollectionDefinition(doc, oldDoc, collectionDefinition, itemPrefix) {
if (isValueNullOrUndefined(collectionDefinition)) {
return [ ];
} else {
if (typeof(collectionDefinition) === 'function') {
var fnResults = collectionDefinition(doc, oldDoc);

return resolveCollectionItems(fnResults, itemPrefix);
} else {
return resolveCollectionItems(collectionDefinition, itemPrefix);
}
}
}

function assignUserAccess(doc, oldDoc, accessAssignmentDefinitions) {
for (var assignmentIndex = 0; assignmentIndex < accessAssignmentDefinitions.length; assignmentIndex++) {
var definition = accessAssignmentDefinitions[assignmentIndex];
var usersAndRoles = [ ];

var users = resolveCollectionDefinition(doc, oldDoc, definition.users);
for (var userIndex = 0; userIndex < users.length; userIndex++) {
usersAndRoles.push(users[userIndex]);
}

// Role names must begin with the special token "role:" to distinguish them from users
var roles = resolveCollectionDefinition(doc, oldDoc, definition.roles, 'role:');
for (var roleIndex = 0; roleIndex < roles.length; roleIndex++) {
usersAndRoles.push(roles[roleIndex]);
}

var channels = resolveCollectionDefinition(doc, oldDoc, definition.channels);

access(usersAndRoles, channels);
}
}

var rawDocDefinitions = %SYNC_DOCUMENT_DEFINITIONS%;

var docDefinitions;
Expand Down Expand Up @@ -642,6 +704,10 @@ function synctos(doc, oldDoc) {

validateDoc(doc, oldDoc, theDocDefinition, theDocType);

if (theDocDefinition.accessAssignments) {
assignUserAccess(doc, oldDoc, theDocDefinition.accessAssignments);
}

// Getting here means the document write is authorized and valid, and the appropriate channel(s) should now be assigned
channel(getAllDocChannels(doc, oldDoc, theDocDefinition));
}
123 changes: 116 additions & 7 deletions etc/test-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ var validationErrorFormatter = require('./validation-error-message-formatter.js'
// More info: http://developer.couchbase.com/mobile/develop/guides/sync-gateway/sync-function-api-guide/index.html
var requireAccess;
var channel;
var access;

var syncFunction;

Expand All @@ -20,6 +21,7 @@ function init(syncFunctionPath) {

requireAccess = simple.stub();
channel = simple.stub();
access = simple.stub();
}

function verifyRequireAccess(expectedChannels) {
Expand Down Expand Up @@ -57,11 +59,98 @@ function checkChannels(expectedChannels, actualChannels) {
}
}

function verifyDocumentAccepted(doc, oldDoc, expectedChannels) {
function areUnorderedListsEqual(set1, set2) {
if (set1.length !== set2.length) {
return false;
}

for (var setIndex = 0; setIndex < set1.length; setIndex++) {
if (set2.indexOf(set1[setIndex]) < 0) {
return false;
} else if (set1.indexOf(set2[setIndex]) < 0) {
return false;
}
}

// If we got here, the two sets are equal
return true;
}

function accessAssignmentCallExists(expectedUsersAndRoles, expectedChannels) {
// Try to find an actual access assignment call that matches the expected call
for (var accessCallIndex = 0; accessCallIndex < access.callCount; accessCallIndex++) {
var accessCall = access.calls[accessCallIndex];
if (areUnorderedListsEqual(accessCall.args[0], expectedUsersAndRoles) && areUnorderedListsEqual(accessCall.args[1], expectedChannels)) {
return true;
}
}

return false;
}

function verifyAccessAssignments(expectedAccessAssignments) {
var assignmentIndex;
for (assignmentIndex = 0; assignmentIndex < expectedAccessAssignments.length; assignmentIndex++) {
var expectedAssignment = expectedAccessAssignments[assignmentIndex];

var expectedUsersAndRoles = [ ];
if (expectedAssignment.expectedUsers) {
if (expectedAssignment.expectedUsers instanceof Array) {
for (var userIndex = 0; userIndex < expectedAssignment.expectedUsers.length; userIndex++) {
expectedUsersAndRoles.push(expectedAssignment.expectedUsers[userIndex]);
}
} else {
expectedUsersAndRoles.push(expectedAssignment.expectedUsers);
}
}

if (expectedAssignment.expectedRoles) {
// The prefix "role:" must be applied to roles when calling the access function, as specified by
// http://developer.couchbase.com/documentation/mobile/current/develop/guides/sync-gateway/channels/developing/index.html#programmatic-authorization
if (expectedAssignment.expectedRoles instanceof Array) {
for (var roleIndex = 0; roleIndex < expectedAssignment.expectedRoles.length; roleIndex++) {
expectedUsersAndRoles.push('role:' + expectedAssignment.expectedRoles[roleIndex]);
}
} else {
expectedUsersAndRoles.push('role:' + expectedAssignment.expectedRoles);
}
}

var expectedChannels = [ ];
if (expectedAssignment.expectedChannels) {
if (expectedAssignment.expectedChannels instanceof Array) {
for (var channelIndex = 0; channelIndex < expectedAssignment.expectedChannels.length; channelIndex++) {
expectedChannels.push(expectedAssignment.expectedChannels[channelIndex]);
}
} else {
expectedChannels.push(expectedAssignment.expectedChannels);
}
}

if (!accessAssignmentCallExists(expectedUsersAndRoles, expectedChannels)) {
expect().fail(
'Missing expected call to assign channel access (' +
JSON.stringify(expectedChannels) +
') to users and roles (' +
JSON.stringify(expectedUsersAndRoles) +
')');
}
}

if (access.callCount !== assignmentIndex) {
expect().fail('Number of calls to assign channel access (' + access.callCount + ') does not match expected (' + assignmentIndex + ')');
}
}

function verifyDocumentAccepted(doc, oldDoc, expectedChannels, expectedAccessAssignments) {
syncFunction(doc, oldDoc);

verifyRequireAccess(expectedChannels);

if (expectedAccessAssignments) {
verifyAccessAssignments(expectedAccessAssignments);
}

expect(channel.callCount).to.equal(1);

var actualChannels = channel.calls[0].arg;
Expand All @@ -74,16 +163,16 @@ function verifyDocumentAccepted(doc, oldDoc, expectedChannels) {
}
}

function verifyDocumentCreated(doc, expectedChannels) {
verifyDocumentAccepted(doc, undefined, expectedChannels || defaultWriteChannel);
function verifyDocumentCreated(doc, expectedChannels, expectedAccessAssignments) {
verifyDocumentAccepted(doc, undefined, expectedChannels || defaultWriteChannel, expectedAccessAssignments);
}

function verifyDocumentReplaced(doc, oldDoc, expectedChannels) {
verifyDocumentAccepted(doc, oldDoc, expectedChannels || defaultWriteChannel);
function verifyDocumentReplaced(doc, oldDoc, expectedChannels, expectedAccessAssignments) {
verifyDocumentAccepted(doc, oldDoc, expectedChannels || defaultWriteChannel, expectedAccessAssignments);
}

function verifyDocumentDeleted(oldDoc, expectedChannels) {
verifyDocumentAccepted({ _id: oldDoc._id, _deleted: true }, oldDoc, expectedChannels || defaultWriteChannel);
function verifyDocumentDeleted(oldDoc, expectedChannels, expectedAccessAssignments) {
verifyDocumentAccepted({ _id: oldDoc._id, _deleted: true }, oldDoc, expectedChannels || defaultWriteChannel, expectedAccessAssignments);
}

function verifyDocumentRejected(doc, oldDoc, docType, expectedErrorMessages, expectedChannels) {
Expand Down Expand Up @@ -173,6 +262,11 @@ exports.init = init;
* create operation.
* @param {string[]} expectedChannels The list of channels that are required to perform the operation. May be a string if only one channel
* is expected.
* @param {Object[]} [expectedAccessAssignments] An optional list of expected user and role channel assignments. Each entry is an object
* that contains the following fields:
* - channels: an optional list of channels to assign to the users and roles
* - users: an optional list of users to which to assign the channels
* - roles: an optional list of roles to which to assign the channels
*/
exports.verifyDocumentAccepted = verifyDocumentAccepted;

Expand All @@ -182,6 +276,11 @@ exports.verifyDocumentAccepted = verifyDocumentAccepted;
* @param {Object} doc The new document
* @param {string[]} [expectedChannels] The list of channels that are required to perform the operation. May be a string if only one channel
* is expected. Set to "write" by default if omitted.
* @param {Object[]} [expectedAccessAssignments] An optional list of expected user and role channel assignments. Each entry is an object
* that contains the following fields:
* - channels: an optional list of channels to assign to the users and roles
* - users: an optional list of users to which to assign the channels
* - roles: an optional list of roles to which to assign the channels
*/
exports.verifyDocumentCreated = verifyDocumentCreated;

Expand All @@ -192,6 +291,11 @@ exports.verifyDocumentCreated = verifyDocumentCreated;
* @param {Object} oldDoc The document to replace
* @param {string[]} [expectedChannels] The list of channels that are required to perform the operation. May be a string if only one channel
* is expected. Set to "write" by default if omitted.
* @param {Object[]} [expectedAccessAssignments] An optional list of expected user and role channel assignments. Each entry is an object
* that contains the following fields:
* - channels: an optional list of channels to assign to the users and roles
* - users: an optional list of users to which to assign the channels
* - roles: an optional list of roles to which to assign the channels
*/
exports.verifyDocumentReplaced = verifyDocumentReplaced;

Expand All @@ -201,6 +305,11 @@ exports.verifyDocumentReplaced = verifyDocumentReplaced;
* @param {Object} oldDoc The document to delete
* @param {string[]} [expectedChannels] The list of channels that are required to perform the operation. May be a string if only one channel
* is expected. Set to "write" by default if omitted.
* @param {Object[]} [expectedAccessAssignments] An optional list of expected user and role channel assignments. Each entry is an object
* that contains the following fields:
* - channels: an optional list of channels to assign to the users and roles
* - users: an optional list of users to which to assign the channels
* - roles: an optional list of roles to which to assign the channels
*/
exports.verifyDocumentDeleted = verifyDocumentDeleted;

Expand Down
31 changes: 30 additions & 1 deletion samples/sample-sync-doc-definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function() {

// Only service users can create new notifications
return {
view: [ toSyncChannel(businessId, 'VIEW_NOTIFICATIONS'), serviceChannel ],
view: [ toSyncChannel(businessId, 'VIEW_NOTIFICATIONS'), doc._id + '-VIEW', serviceChannel ],
add: serviceChannel,
replace: [ toSyncChannel(businessId, 'CHANGE_NOTIFICATIONS'), serviceChannel ],
remove: [ toSyncChannel(businessId, 'REMOVE_NOTIFICATIONS'), serviceChannel ]
Expand All @@ -135,6 +135,17 @@ function() {
typeFilter: function(doc, oldDoc) {
return createBusinessEntityRegex('notification\\.[A-Za-z0-9_-]+$').test(doc._id);
},
accessAssignments: [
{
users: function(doc, oldDoc) {
return doc.users;
},
roles: function(doc, oldDoc) {
return doc.groups;
},
channels: [ doc._id + '-VIEW' ]
}
],
propertyValidators: {
sender: {
// Which Kashoo app/service generated the notification
Expand All @@ -143,6 +154,24 @@ function() {
mustNotBeEmpty: true,
immutable: true
},
users: {
type: 'array',
immutable: true,
arrayElementsValidator: {
type: 'string',
required: true,
mustNotBeEmpty: true
}
},
groups: {
type: 'array',
immutable: true,
arrayElementsValidator: {
type: 'string',
required: true,
mustNotBeEmpty: true
}
},
type: {
// The type of notification. Corresponds to an entry in the business' notificationsConfig.notificationTypes property.
type: 'string',
Expand Down
Loading

0 comments on commit 16e52ff

Please sign in to comment.