Add real subject ACL support and tests.

This commit is contained in:
Ian Gulliver
2014-05-19 20:52:57 +03:00
parent c361d41fcf
commit f27fc7977b
4 changed files with 220 additions and 33 deletions

30
api.py
View File

@@ -61,11 +61,18 @@ def SendMessage(google_user, client, args):
message, client.parent_key(), sender_message_id, key) message, client.parent_key(), sender_message_id, key)
except models.DuplicateMessage: except models.DuplicateMessage:
logging.exception('Duplicate message: %s', sender_message_id) logging.exception('Duplicate message: %s', sender_message_id)
# We still return success since we assume that the message was already return {
# delivered. If it's really a client ID generation bug, we just swallowed 'result': 'duplicate_message',
# a message. }
except models.AccessDenied:
logging.exception('SendMessage access denied')
return {
'result': 'access_denied',
}
return {} return {
'result': 'ok',
}
def Subscribe(google_user, client, args): def Subscribe(google_user, client, args):
@@ -74,13 +81,22 @@ def Subscribe(google_user, client, args):
last_id = args.get('last_id', None) last_id = args.get('last_id', None)
keys = args.get('keys', []) keys = args.get('keys', [])
ret = { try:
'events': models.Subscription.FindOrCreate(subject, client, messages, last_id), ret = {
} 'result': 'ok',
'events': models.Subscription.FindOrCreate(subject, client, messages, last_id),
}
except models.AccessDenied:
logging.exception('Subscribe access denied')
return {
'result': 'access_denied',
}
for key in keys: for key in keys:
message = subject.GetKey(key) message = subject.GetKey(key)
if message: if message:
ret['events'].append(message.ToEvent()) ret['events'].append(message.ToEvent())
return ret return ret

View File

@@ -34,6 +34,10 @@ class DuplicateMessage(Exception):
pass pass
class AccessDenied(Exception):
pass
class Profile(db.Model): class Profile(db.Model):
google_user = db.UserProperty() google_user = db.UserProperty()
@@ -99,11 +103,29 @@ class Subject(db.Model):
next_message_id = db.IntegerProperty(required=True, default=1) next_message_id = db.IntegerProperty(required=True, default=1)
@classmethod @classmethod
def FindOrCreate(cls, name): def FindOrCreate(cls, subject):
subjects = cls.all().filter('name =', name).fetch(1) if 'readable_only_by' in subject:
readable_only_by = Profile.get(subject['readable_only_by'])
else:
readable_only_by = None
if 'writable_only_by' in subject:
writable_only_by = Profile.get(subject['writable_only_by'])
else:
writable_only_by = None
subjects = (
cls.all()
.filter('name =', subject['name'])
.filter('readable_only_by =', readable_only_by)
.filter('writable_only_by =', writable_only_by)
.fetch(1))
if subjects: if subjects:
return subjects[0] return subjects[0]
subject = cls(name=name) subject = cls(
name=subject['name'],
readable_only_by=readable_only_by,
writable_only_by=writable_only_by)
subject.put() subject.put()
return subject return subject
@@ -179,11 +201,27 @@ class Subject(db.Model):
for subscription in Subscription.all().ancestor(subject)]) for subscription in Subscription.all().ancestor(subject)])
def SendMessage(self, message, sender, sender_message_id, key=None): def SendMessage(self, message, sender, sender_message_id, key=None):
writable_only_by = Subject.writable_only_by.get_value_for_datastore(self)
if (writable_only_by and
writable_only_by != sender):
raise AccessDenied
obj, subscriptions = self.PutMessage(message, sender, sender_message_id, key) obj, subscriptions = self.PutMessage(message, sender, sender_message_id, key)
event = obj.ToEvent() event = obj.ToEvent()
for subscription in subscriptions: for subscription in subscriptions:
Client.SendByKey(subscription, event) Client.SendByKey(subscription, event)
def ToDict(self):
ret = {
'name': self.name,
}
readable_only_by = Subject.readable_only_by.get_value_for_datastore(self)
if readable_only_by:
ret['readable_only_by'] = readable_only_by
writable_only_by = Subject.writable_only_by.get_value_for_datastore(self)
if writable_only_by:
ret['writable_only_by'] = writable_only_by
return ret
class Subscription(db.Model): class Subscription(db.Model):
# parent=Subject # parent=Subject
@@ -193,6 +231,12 @@ class Subscription(db.Model):
@classmethod @classmethod
@db.transactional() @db.transactional()
def FindOrCreate(cls, subject, client, messages=0, last_id=None): def FindOrCreate(cls, subject, client, messages=0, last_id=None):
readable_only_by = (
Subject.readable_only_by.get_value_for_datastore(subject))
if (readable_only_by and
readable_only_by != client.parent_key()):
raise AccessDenied
subscriptions = ( subscriptions = (
cls.all(keys_only=True) cls.all(keys_only=True)
.ancestor(subject) .ancestor(subject)
@@ -235,9 +279,7 @@ class Message(db.Model):
'event_type': 'message', 'event_type': 'message',
'id': self.id_, 'id': self.id_,
'sender': str(Message.sender.get_value_for_datastore(self)), 'sender': str(Message.sender.get_value_for_datastore(self)),
'subject': { 'subject': self.parent().ToDict(),
'name': self.parent().name,
},
'created': self.created, 'created': self.created,
'message': self.message, 'message': self.message,
} }

View File

@@ -56,7 +56,8 @@ var Cosmopolite = function(callbacks, urlPrefix, namespace) {
// message's client_message_id, which is intentionally not exposed via // message's client_message_id, which is intentionally not exposed via
// the sendMessage API // the sendMessage API
this.sendRPC_( this.sendRPC_(
'sendMessage', message, this.onMessageSent_.bind(this, message, null)); 'sendMessage', message,
this.onMessageSent_.bind(this, message, null, null));
}.bind(this)); }.bind(this));
} else { } else {
localStorage[this.messageQueueKey_] = JSON.stringify([]); localStorage[this.messageQueueKey_] = JSON.stringify([]);
@@ -126,15 +127,17 @@ Cosmopolite.prototype.shutdown = function() {
* *
* Start receiving messages sent to this subject via the onMessage callback. * Start receiving messages sent to this subject via the onMessage callback.
* *
* @param {!string} subject Subject name * @param {!*} subject Subject name or object
* @param {number=} messages Number of recent messages to request; 0 for none, -1 for all * @param {number=} messages Number of recent messages to request; 0 for none, -1 for all
* @param {number=} last_id ID of last message received; fetch all messages since * @param {number=} last_id ID of last message received; fetch all messages since
* @param {Array.<string>=} keys Key names to ensure we receive at least 1 message defining * @param {Array.<string>=} keys Key names to ensure we receive at least 1 message defining
*/ */
Cosmopolite.prototype.subscribe = function(subject, messages, last_id, keys) { Cosmopolite.prototype.subscribe = function(subject, messages, last_id, keys) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
if (!(subject in this.subscriptions_)) { var canonicalSubject = this.canonicalSubject_(subject);
this.subscriptions_[subject] = { var subjectString = JSON.stringify(canonicalSubject);
if (!(subjectString in this.subscriptions_)) {
this.subscriptions_[subjectString] = {
'messages': [], 'messages': [],
'keys': {}, 'keys': {},
'state': this.SubscriptionState.PENDING, 'state': this.SubscriptionState.PENDING,
@@ -142,7 +145,7 @@ Cosmopolite.prototype.subscribe = function(subject, messages, last_id, keys) {
} }
var args = { var args = {
'subject': subject, 'subject': canonicalSubject,
}; };
if (messages) { if (messages) {
args['messages'] = messages; args['messages'] = messages;
@@ -154,13 +157,18 @@ Cosmopolite.prototype.subscribe = function(subject, messages, last_id, keys) {
args['keys'] = keys; args['keys'] = keys;
} }
this.sendRPC_('subscribe', args, function() { this.sendRPC_('subscribe', args, function(response) {
// unsubscribe may have been called since we sent the RPC. That's racy // unsubscribe may have been called since we sent the RPC. That's racy
// without waiting for the promise, but do our best // without waiting for the promise, but do our best
if (subject in this.subscriptions_) { if (subjectString in this.subscriptions_) {
this.subscriptions_[subject].state = this.SubscriptionState.ACTIVE; this.subscriptions_[subjectString].state = this.SubscriptionState.ACTIVE;
}
var result = response['result'];
if (result == 'ok') {
resolve();
} else {
reject();
} }
resolve();
}.bind(this)); }.bind(this));
}.bind(this)); }.bind(this));
}; };
@@ -175,9 +183,11 @@ Cosmopolite.prototype.subscribe = function(subject, messages, last_id, keys) {
*/ */
Cosmopolite.prototype.unsubscribe = function(subject) { Cosmopolite.prototype.unsubscribe = function(subject) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
delete this.subscriptions_[subject]; var canonicalSubject = this.canonicalSubject_(subject);
var subjectString = JSON.stringify(canonicalSubject);
delete this.subscriptions_[subjectString];
var args = { var args = {
'subject': subject, 'subject': canonicalSubject,
} }
this.sendRPC_('unsubscribe', args, resolve); this.sendRPC_('unsubscribe', args, resolve);
}.bind(this)); }.bind(this));
@@ -193,7 +203,7 @@ Cosmopolite.prototype.unsubscribe = function(subject) {
Cosmopolite.prototype.sendMessage = function(subject, message, key) { Cosmopolite.prototype.sendMessage = function(subject, message, key) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
var args = { var args = {
'subject': subject, 'subject': this.canonicalSubject_(subject),
'message': JSON.stringify(message), 'message': JSON.stringify(message),
'sender_message_id': this.uuid_(), 'sender_message_id': this.uuid_(),
}; };
@@ -207,7 +217,8 @@ Cosmopolite.prototype.sendMessage = function(subject, message, key) {
localStorage[this.messageQueueKey_] = JSON.stringify(messageQueue); localStorage[this.messageQueueKey_] = JSON.stringify(messageQueue);
this.sendRPC_( this.sendRPC_(
'sendMessage', args, this.onMessageSent_.bind(this, args, resolve)); 'sendMessage', args,
this.onMessageSent_.bind(this, args, resolve, reject));
}.bind(this)); }.bind(this));
}; };
@@ -218,7 +229,9 @@ Cosmopolite.prototype.sendMessage = function(subject, message, key) {
* @const * @const
*/ */
Cosmopolite.prototype.getMessages = function(subject) { Cosmopolite.prototype.getMessages = function(subject) {
return this.subscriptions_[subject].messages; var canonicalSubject = this.canonicalSubject_(subject);
var subjectString = JSON.stringify(canonicalSubject);
return this.subscriptions_[subjectString].messages;
}; };
/** /**
@@ -229,7 +242,9 @@ Cosmopolite.prototype.getMessages = function(subject) {
* @const * @const
*/ */
Cosmopolite.prototype.getKeyMessage = function(subject, key) { Cosmopolite.prototype.getKeyMessage = function(subject, key) {
return this.subscriptions_[subject].keys[key]; var canonicalSubject = this.canonicalSubject_(subject);
var subjectString = JSON.stringify(canonicalSubject);
return this.subscriptions_[subjectString].keys[key];
}; };
/** /**
@@ -269,6 +284,31 @@ Cosmopolite.prototype.uuid_ = function() {
}); });
}; };
/**
* Canonicalize a subject name or object
*
* @param {!*} subject A simple or complex representation of a subject
* @return {Object} A canonicalized object for RPCs
*/
Cosmopolite.prototype.canonicalSubject_ = function(subject) {
if (typeof(subject) == 'number') {
subject = subject.toString();
}
if (typeof(subject) == 'string') {
subject = {
'name': subject,
}
}
if (subject['readable_only_by'] === null) {
delete subject['readable_only_by'];
};
if (subject['writable_only_by'] === null) {
delete subject['writable_only_by'];
};
return subject;
};
/** /**
* Callback when a script loads. * Callback when a script loads.
*/ */
@@ -332,16 +372,26 @@ Cosmopolite.prototype.registerMessageHandlers_ = function() {
* *
* @param {Object} message Message details. * @param {Object} message Message details.
* @param {function()=} resolve Promise resolution callback. * @param {function()=} resolve Promise resolution callback.
* @param {function()=} reject Promise rejection callback.
* @param {Object=} response Server RPC response.
*/ */
Cosmopolite.prototype.onMessageSent_ = function(message, resolve) { Cosmopolite.prototype.onMessageSent_ = function(
message, resolve, reject, response) {
// No message left behind. // No message left behind.
var messageQueue = JSON.parse(localStorage[this.messageQueueKey_]); var messageQueue = JSON.parse(localStorage[this.messageQueueKey_]);
messageQueue = messageQueue.filter(function(queuedMessage) { messageQueue = messageQueue.filter(function(queuedMessage) {
return message['sender_message_id'] != queuedMessage['sender_message_id']; return message['sender_message_id'] != queuedMessage['sender_message_id'];
}); });
localStorage[this.messageQueueKey_] = JSON.stringify(messageQueue); localStorage[this.messageQueueKey_] = JSON.stringify(messageQueue);
if (resolve) { var result = response['result'];
resolve(); if (result == 'ok' || result == 'duplicate_message') {
if (resolve) {
resolve();
}
} else {
if (reject) {
reject();
}
} }
}; };
@@ -500,6 +550,7 @@ Cosmopolite.prototype.resubscribe_ = function() {
var rpcs = []; var rpcs = [];
for (var subject in this.subscriptions_) { for (var subject in this.subscriptions_) {
var subscription = this.subscriptions_[subject]; var subscription = this.subscriptions_[subject];
var canonicalSubject = JSON.parse(subject);
if (subscription.state != this.SubscriptionState.ACTIVE) { if (subscription.state != this.SubscriptionState.ACTIVE) {
continue; continue;
} }
@@ -510,7 +561,7 @@ Cosmopolite.prototype.resubscribe_ = function() {
rpcs.push({ rpcs.push({
'command': 'subscribe', 'command': 'subscribe',
'arguments': { 'arguments': {
'subject': subject, 'subject': canonicalSubject,
'last_id': last_id, 'last_id': last_id,
} }
}); });
@@ -657,7 +708,8 @@ Cosmopolite.prototype.onLogout_ = function(e) {
* @param {!Object} e Event object * @param {!Object} e Event object
*/ */
Cosmopolite.prototype.onMessage_ = function(e) { Cosmopolite.prototype.onMessage_ = function(e) {
var subscription = this.subscriptions_[e['subject']['name']]; var subjectString = JSON.stringify(e['subject']);
var subscription = this.subscriptions_[subjectString];
if (!subscription) { if (!subscription) {
console.log( console.log(
this.loggingPrefix_(), this.loggingPrefix_(),

View File

@@ -374,6 +374,83 @@ asyncTest('Reconnect channel', function() {
}); });
}); });
asyncTest('subscribe ACL', function() {
expect(2);
var subject = randstring();
logout(function() {
var tempCallbacks = {
'onLogout': function() {
var tempProfile = tempCosmo.profile();
tempCosmo.shutdown();
var callbacks = {
'onLogout': function() {
cosmo.subscribe({
'name': subject,
'readable_only_by': cosmo.profile(),
}).then(function() {
ok(true, 'correct ACL succeeds');
cosmo.subscribe({
'name': subject,
'readable_only_by': tempProfile,
}).then(null, function() {
ok(true, 'bad ACL fails');
cosmo.shutdown();
start();
});
});
},
};
var cosmo = new Cosmopolite(callbacks, null, randstring());
},
};
var tempCosmo = new Cosmopolite(tempCallbacks, null, randstring());
});
});
asyncTest('sendMessage ACL', function() {
expect(2);
var subject = randstring();
var message = randstring();
logout(function() {
var tempCallbacks = {
'onLogout': function() {
var tempProfile = tempCosmo.profile();
tempCosmo.shutdown();
var callbacks = {
'onLogout': function() {
cosmo.sendMessage({
'name': subject,
'writable_only_by': cosmo.profile(),
}, message).then(function() {
ok(true, 'correct ACL succeeds');
cosmo.sendMessage({
'name': subject,
'writable_only_by': tempProfile,
}, message).then(null, function() {
ok(true, 'bad ACL fails');
cosmo.shutdown();
start();
});
});
},
};
var cosmo = new Cosmopolite(callbacks, null, randstring());
},
};
var tempCosmo = new Cosmopolite(tempCallbacks, null, randstring());
});
});
module('dev_appserver only'); module('dev_appserver only');