Skip to content

Commit 16e52ff

Browse files
authored
Merge pull request #51 from Kashoo/issue-24-dynamic-channel-access
Issue #24: Allow assignment of channels to users and roles
2 parents fcef051 + 0ba418e commit 16e52ff

8 files changed

Lines changed: 514 additions & 16 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
66
### Added
77
- [#28](https://github.com/Kashoo/synctos/issues/28): Parameter to allow unknown properties in a document or object
88
- [#49](https://github.com/Kashoo/synctos/issues/49): Explicitly declare JSHint rules
9+
- [#24](https://github.com/Kashoo/synctos/issues/24): Support dynamic assignment of channels to roles and users
910

1011
## [1.2.0]
1112
### Added

README.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,32 @@ And an example of a more complex custom type filter:
216216
}
217217
```
218218

219+
* `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:
220+
221+
```
222+
accessAssignments: [
223+
{
224+
users: [ 'user1', 'user2' ],
225+
channels: [ 'channel1' ]
226+
},
227+
{
228+
roles: [ 'role1', 'role2' ],
229+
channels: [ 'channel2' ]
230+
},
231+
{
232+
users: function(doc, oldDoc) {
233+
return doc.users;
234+
},
235+
roles: function(doc, oldDoc) {
236+
return doc.roles;
237+
},
238+
channels: function(doc, oldDoc) {
239+
return [ doc._id + '-channel3', doc._id + '-channel4' ];
240+
}
241+
},
242+
]
243+
```
244+
219245
* `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.
220246
* `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`.
221247
* `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`.
@@ -406,10 +432,23 @@ it('can create a myDocType document', function() {
406432
_id: 'myDocId',
407433
type: 'myDocType',
408434
foo: 'bar',
409-
bar: -32
435+
bar: -32,
436+
members: [ 'joe', 'nancy' ]
410437
}
411438
412-
testHelper.verifyDocumentCreated(doc, [ 'my-add-channel1', 'my-add-channel2' ]);
439+
testHelper.verifyDocumentCreated(
440+
doc,
441+
[ 'my-add-channel1', 'my-add-channel2' ],
442+
[
443+
{
444+
expectedUsers: function(doc, oldDoc) {
445+
return doc.members;
446+
},
447+
expectedChannels: function(doc, oldDoc) {
448+
return 'view-' + doc._id;
449+
}
450+
}
451+
]);
413452
});
414453
```
415454

etc/sync-function-template.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,68 @@ function synctos(doc, oldDoc) {
605605
}
606606
}
607607

608+
function prefixItem(item, prefix) {
609+
return (prefix ? prefix + item : item.toString());
610+
}
611+
612+
function resolveCollectionItems(originalItems, itemPrefix) {
613+
if (isValueNullOrUndefined(originalItems)) {
614+
return [ ];
615+
} else if (originalItems instanceof Array) {
616+
var resultItems = [ ];
617+
for (var i = 0; i < originalItems.length; i++) {
618+
var item = originalItems[i];
619+
620+
if (isValueNullOrUndefined(item)) {
621+
continue;
622+
}
623+
624+
resultItems.push(prefixItem(item, itemPrefix));
625+
}
626+
627+
return resultItems;
628+
} else {
629+
// Represents a single item
630+
return [ prefixItem(originalItems, itemPrefix) ];
631+
}
632+
}
633+
634+
function resolveCollectionDefinition(doc, oldDoc, collectionDefinition, itemPrefix) {
635+
if (isValueNullOrUndefined(collectionDefinition)) {
636+
return [ ];
637+
} else {
638+
if (typeof(collectionDefinition) === 'function') {
639+
var fnResults = collectionDefinition(doc, oldDoc);
640+
641+
return resolveCollectionItems(fnResults, itemPrefix);
642+
} else {
643+
return resolveCollectionItems(collectionDefinition, itemPrefix);
644+
}
645+
}
646+
}
647+
648+
function assignUserAccess(doc, oldDoc, accessAssignmentDefinitions) {
649+
for (var assignmentIndex = 0; assignmentIndex < accessAssignmentDefinitions.length; assignmentIndex++) {
650+
var definition = accessAssignmentDefinitions[assignmentIndex];
651+
var usersAndRoles = [ ];
652+
653+
var users = resolveCollectionDefinition(doc, oldDoc, definition.users);
654+
for (var userIndex = 0; userIndex < users.length; userIndex++) {
655+
usersAndRoles.push(users[userIndex]);
656+
}
657+
658+
// Role names must begin with the special token "role:" to distinguish them from users
659+
var roles = resolveCollectionDefinition(doc, oldDoc, definition.roles, 'role:');
660+
for (var roleIndex = 0; roleIndex < roles.length; roleIndex++) {
661+
usersAndRoles.push(roles[roleIndex]);
662+
}
663+
664+
var channels = resolveCollectionDefinition(doc, oldDoc, definition.channels);
665+
666+
access(usersAndRoles, channels);
667+
}
668+
}
669+
608670
var rawDocDefinitions = %SYNC_DOCUMENT_DEFINITIONS%;
609671

610672
var docDefinitions;
@@ -642,6 +704,10 @@ function synctos(doc, oldDoc) {
642704

643705
validateDoc(doc, oldDoc, theDocDefinition, theDocType);
644706

707+
if (theDocDefinition.accessAssignments) {
708+
assignUserAccess(doc, oldDoc, theDocDefinition.accessAssignments);
709+
}
710+
645711
// Getting here means the document write is authorized and valid, and the appropriate channel(s) should now be assigned
646712
channel(getAllDocChannels(doc, oldDoc, theDocDefinition));
647713
}

etc/test-helper.js

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ var validationErrorFormatter = require('./validation-error-message-formatter.js'
77
// More info: http://developer.couchbase.com/mobile/develop/guides/sync-gateway/sync-function-api-guide/index.html
88
var requireAccess;
99
var channel;
10+
var access;
1011

1112
var syncFunction;
1213

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

2122
requireAccess = simple.stub();
2223
channel = simple.stub();
24+
access = simple.stub();
2325
}
2426

2527
function verifyRequireAccess(expectedChannels) {
@@ -57,11 +59,98 @@ function checkChannels(expectedChannels, actualChannels) {
5759
}
5860
}
5961

60-
function verifyDocumentAccepted(doc, oldDoc, expectedChannels) {
62+
function areUnorderedListsEqual(set1, set2) {
63+
if (set1.length !== set2.length) {
64+
return false;
65+
}
66+
67+
for (var setIndex = 0; setIndex < set1.length; setIndex++) {
68+
if (set2.indexOf(set1[setIndex]) < 0) {
69+
return false;
70+
} else if (set1.indexOf(set2[setIndex]) < 0) {
71+
return false;
72+
}
73+
}
74+
75+
// If we got here, the two sets are equal
76+
return true;
77+
}
78+
79+
function accessAssignmentCallExists(expectedUsersAndRoles, expectedChannels) {
80+
// Try to find an actual access assignment call that matches the expected call
81+
for (var accessCallIndex = 0; accessCallIndex < access.callCount; accessCallIndex++) {
82+
var accessCall = access.calls[accessCallIndex];
83+
if (areUnorderedListsEqual(accessCall.args[0], expectedUsersAndRoles) && areUnorderedListsEqual(accessCall.args[1], expectedChannels)) {
84+
return true;
85+
}
86+
}
87+
88+
return false;
89+
}
90+
91+
function verifyAccessAssignments(expectedAccessAssignments) {
92+
var assignmentIndex;
93+
for (assignmentIndex = 0; assignmentIndex < expectedAccessAssignments.length; assignmentIndex++) {
94+
var expectedAssignment = expectedAccessAssignments[assignmentIndex];
95+
96+
var expectedUsersAndRoles = [ ];
97+
if (expectedAssignment.expectedUsers) {
98+
if (expectedAssignment.expectedUsers instanceof Array) {
99+
for (var userIndex = 0; userIndex < expectedAssignment.expectedUsers.length; userIndex++) {
100+
expectedUsersAndRoles.push(expectedAssignment.expectedUsers[userIndex]);
101+
}
102+
} else {
103+
expectedUsersAndRoles.push(expectedAssignment.expectedUsers);
104+
}
105+
}
106+
107+
if (expectedAssignment.expectedRoles) {
108+
// The prefix "role:" must be applied to roles when calling the access function, as specified by
109+
// http://developer.couchbase.com/documentation/mobile/current/develop/guides/sync-gateway/channels/developing/index.html#programmatic-authorization
110+
if (expectedAssignment.expectedRoles instanceof Array) {
111+
for (var roleIndex = 0; roleIndex < expectedAssignment.expectedRoles.length; roleIndex++) {
112+
expectedUsersAndRoles.push('role:' + expectedAssignment.expectedRoles[roleIndex]);
113+
}
114+
} else {
115+
expectedUsersAndRoles.push('role:' + expectedAssignment.expectedRoles);
116+
}
117+
}
118+
119+
var expectedChannels = [ ];
120+
if (expectedAssignment.expectedChannels) {
121+
if (expectedAssignment.expectedChannels instanceof Array) {
122+
for (var channelIndex = 0; channelIndex < expectedAssignment.expectedChannels.length; channelIndex++) {
123+
expectedChannels.push(expectedAssignment.expectedChannels[channelIndex]);
124+
}
125+
} else {
126+
expectedChannels.push(expectedAssignment.expectedChannels);
127+
}
128+
}
129+
130+
if (!accessAssignmentCallExists(expectedUsersAndRoles, expectedChannels)) {
131+
expect().fail(
132+
'Missing expected call to assign channel access (' +
133+
JSON.stringify(expectedChannels) +
134+
') to users and roles (' +
135+
JSON.stringify(expectedUsersAndRoles) +
136+
')');
137+
}
138+
}
139+
140+
if (access.callCount !== assignmentIndex) {
141+
expect().fail('Number of calls to assign channel access (' + access.callCount + ') does not match expected (' + assignmentIndex + ')');
142+
}
143+
}
144+
145+
function verifyDocumentAccepted(doc, oldDoc, expectedChannels, expectedAccessAssignments) {
61146
syncFunction(doc, oldDoc);
62147

63148
verifyRequireAccess(expectedChannels);
64149

150+
if (expectedAccessAssignments) {
151+
verifyAccessAssignments(expectedAccessAssignments);
152+
}
153+
65154
expect(channel.callCount).to.equal(1);
66155

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

77-
function verifyDocumentCreated(doc, expectedChannels) {
78-
verifyDocumentAccepted(doc, undefined, expectedChannels || defaultWriteChannel);
166+
function verifyDocumentCreated(doc, expectedChannels, expectedAccessAssignments) {
167+
verifyDocumentAccepted(doc, undefined, expectedChannels || defaultWriteChannel, expectedAccessAssignments);
79168
}
80169

81-
function verifyDocumentReplaced(doc, oldDoc, expectedChannels) {
82-
verifyDocumentAccepted(doc, oldDoc, expectedChannels || defaultWriteChannel);
170+
function verifyDocumentReplaced(doc, oldDoc, expectedChannels, expectedAccessAssignments) {
171+
verifyDocumentAccepted(doc, oldDoc, expectedChannels || defaultWriteChannel, expectedAccessAssignments);
83172
}
84173

85-
function verifyDocumentDeleted(oldDoc, expectedChannels) {
86-
verifyDocumentAccepted({ _id: oldDoc._id, _deleted: true }, oldDoc, expectedChannels || defaultWriteChannel);
174+
function verifyDocumentDeleted(oldDoc, expectedChannels, expectedAccessAssignments) {
175+
verifyDocumentAccepted({ _id: oldDoc._id, _deleted: true }, oldDoc, expectedChannels || defaultWriteChannel, expectedAccessAssignments);
87176
}
88177

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

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

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

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

samples/sample-sync-doc-definitions.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ function() {
126126

127127
// Only service users can create new notifications
128128
return {
129-
view: [ toSyncChannel(businessId, 'VIEW_NOTIFICATIONS'), serviceChannel ],
129+
view: [ toSyncChannel(businessId, 'VIEW_NOTIFICATIONS'), doc._id + '-VIEW', serviceChannel ],
130130
add: serviceChannel,
131131
replace: [ toSyncChannel(businessId, 'CHANGE_NOTIFICATIONS'), serviceChannel ],
132132
remove: [ toSyncChannel(businessId, 'REMOVE_NOTIFICATIONS'), serviceChannel ]
@@ -135,6 +135,17 @@ function() {
135135
typeFilter: function(doc, oldDoc) {
136136
return createBusinessEntityRegex('notification\\.[A-Za-z0-9_-]+$').test(doc._id);
137137
},
138+
accessAssignments: [
139+
{
140+
users: function(doc, oldDoc) {
141+
return doc.users;
142+
},
143+
roles: function(doc, oldDoc) {
144+
return doc.groups;
145+
},
146+
channels: [ doc._id + '-VIEW' ]
147+
}
148+
],
138149
propertyValidators: {
139150
sender: {
140151
// Which Kashoo app/service generated the notification
@@ -143,6 +154,24 @@ function() {
143154
mustNotBeEmpty: true,
144155
immutable: true
145156
},
157+
users: {
158+
type: 'array',
159+
immutable: true,
160+
arrayElementsValidator: {
161+
type: 'string',
162+
required: true,
163+
mustNotBeEmpty: true
164+
}
165+
},
166+
groups: {
167+
type: 'array',
168+
immutable: true,
169+
arrayElementsValidator: {
170+
type: 'string',
171+
required: true,
172+
mustNotBeEmpty: true
173+
}
174+
},
146175
type: {
147176
// The type of notification. Corresponds to an entry in the business' notificationsConfig.notificationTypes property.
148177
type: 'string',

0 commit comments

Comments
 (0)