Commit 6497299c authored by Aron Carroll's avatar Aron Carroll

Merge pull request #1810 from hypothesis/permissions-service

Introduce Permissions Service.
parents af0e036b d050aa0d
......@@ -34,9 +34,6 @@ class Auth
# Set the user from the token.
plugins.Auth.withToken (token) =>
_checkingToken = false
annotator.addPlugin 'Permissions',
user: token.userId
userAuthorize: @permits
@user = token.userId
$rootScope.$apply()
......@@ -48,10 +45,6 @@ class Auth
plugins.Auth?.destroy()
delete plugins.Auth
plugins.Permissions?.setUser(null)
plugins.Permissions?.destroy()
delete plugins.Permissions
@user = null
_checkingToken = false
......@@ -66,39 +59,5 @@ class Auth
identity.watch {onlogin, onlogout, onready}
###*
# @ngdoc method
# @name auth#permits
#
# @param {String} action action to authorize (read|update|delete|admin)
# @param {Object} annotation to permit action on it or not
# @param {String} user the userId
#
# User authorization function used by (not solely) the Permissions plugin
###
permits: (action, annotation, user) ->
if annotation.permissions
tokens = annotation.permissions[action] || []
if tokens.length == 0
# Empty or missing tokens array: only admin can perform action.
return false
for token in tokens
if user == token
return true
if token == 'group:__world__'
return true
# No tokens matched: action should not be performed.
return false
# Coarse-grained authorization
else if annotation.user
return user and user == annotation.user
# No authorization info on annotation: free-for-all!
true
angular.module('h')
.service('auth', Auth)
......@@ -2,13 +2,13 @@ class AppController
this.$inject = [
'$location', '$route', '$scope', '$timeout',
'annotator', 'auth', 'documentHelpers', 'drafts', 'flash', 'identity',
'streamer', 'streamfilter'
'permissions', 'streamer', 'streamfilter'
]
constructor: (
$location, $route, $scope, $timeout,
annotator, auth, documentHelpers, drafts, flash, identity,
streamer, streamfilter,
permissions, streamer, streamfilter,
) ->
{plugins, host, providers} = annotator
......@@ -77,7 +77,7 @@ class AppController
cull = (acc, annotation) ->
if view is 'single-player' and annotation.user != user
acc.drop.push annotation
else if auth.permits 'read', annotation, user
else if permissions.permits('read', annotation, user)
acc.keep.push annotation
else
acc.drop.push annotation
......
......@@ -38,9 +38,11 @@ validate = (value) ->
###
AnnotationController = [
'$scope', '$timeout',
'annotator', 'auth', 'drafts', 'flash', 'documentHelpers', 'timeHelpers'
'annotator', 'auth', 'drafts', 'flash', 'documentHelpers', 'permissions',
'timeHelpers'
($scope, $timeout,
annotator, auth, drafts, flash, documentHelpers, timeHelpers
annotator, auth, drafts, flash, documentHelpers, permissions,
timeHelpers
) ->
@annotation = {}
@action = 'view'
......@@ -80,7 +82,7 @@ AnnotationController = [
# @returns {boolean} True if the annotation is private to the current user.
###
this.isPrivate = ->
model.user and angular.equals(model.permissions?.read or [], [model.user])
permissions.isPrivate model.permissions, model.user
###*
# @ngdoc method
......@@ -92,7 +94,7 @@ AnnotationController = [
###
this.authorize = (action) ->
return false unless model?
annotator.plugins.Permissions?.authorize action, model
permissions.permits action, model, auth.user
###*
# @ngdoc method
......@@ -183,15 +185,10 @@ AnnotationController = [
annotator.publish 'beforeAnnotationCreated', reply
if auth.user?
reply.permissions.update = [auth.user]
reply.permissions.delete = [auth.user]
reply.permissions.admin = [auth.user]
# If replying to a public annotation make the response public.
if 'group:__world__' in (model.permissions.read or [])
reply.permissions.read = ['group:__world__']
if permissions.isPublic model.permissions
reply.permissions = permissions.public()
else
reply.permissions.read = [auth.user]
reply.permissions = permissions.private()
###*
# @ngdoc method
......@@ -278,11 +275,8 @@ AnnotationController = [
# Save highlights once logged in.
if highlight and this.isHighlight()
if model.user
model.permissions.read = [model.user]
model.permissions.update = [model.user]
model.permissions.delete = [model.user]
model.permissions.admin = [model.user]
if auth.user
model.permissions = permissions.private()
annotator.publish 'annotationCreated', model
highlight = false # skip this on future updates
else
......
privacy = ['$window', ($window) ->
privacy = ['$window', 'permissions', ($window, permissions) ->
VISIBILITY_KEY ='hypothesis.visibility'
VISIBILITY_PUBLIC = 'public'
VISIBILITY_PRIVATE = 'private'
......@@ -37,10 +37,10 @@ privacy = ['$window', ($window) ->
link: (scope, elem, attrs, controller) ->
return unless controller?
controller.$formatters.push (permissions) ->
return unless permissions?
controller.$formatters.push (selectedPermissions) ->
return unless selectedPermissions?
if 'group:__world__' in (permissions.read or [])
if permissions.isPublic(selectedPermissions)
getLevel(VISIBILITY_PUBLIC)
else
getLevel(VISIBILITY_PRIVATE)
......@@ -48,21 +48,20 @@ privacy = ['$window', ($window) ->
controller.$parsers.push (privacy) ->
return unless privacy?
permissions = controller.$modelValue
if isPublic(privacy.name)
permissions.read = ['group:__world__']
newPermissions = permissions.public()
else
permissions.read = [attrs.user]
newPermissions = permissions.private()
permissions.update = [attrs.user]
permissions.delete = [attrs.user]
permissions.admin = [attrs.user]
# Cannot change the $modelValue into a new object
# Just update its properties
for key,val of newPermissions
controller.$modelValue[key] = val
permissions
controller.$modelValue
controller.$render = ->
unless controller.$modelValue.read.length
unless controller.$modelValue.read?.length
name = storage.getItem VISIBILITY_KEY
name ?= VISIBILITY_PUBLIC
level = getLevel(name)
......
###*
# @ngdoc service
# @name Permissions
#
# @description
# This service can set default permissions to annotations properly and
# offers some utility functions regarding those.
###
class Permissions
GROUP_WORLD = 'group:__world__'
EVERYONE = 'Everyone'
ALL_PERMISSIONS = 'ALL_PERMISSIONS'
this.$inject = ['auth']
constructor: (auth) ->
###*
# @ngdoc method
# @name permissions#private
#
# Sets permissions for a private annotation
# Typical use: annotation.permissions = permissions.private()
###
@private = ->
return {
read: [auth.user]
update: [auth.user]
delete: [auth.user]
admin: [auth.user]
}
###*
# @ngdoc method
# @name permissions#private
#
# Sets permissions for a public annotation
# Typical use: annotation.permissions = permissions.public()
###
@public = ->
return {
read: [GROUP_WORLD]
update: [auth.user]
delete: [auth.user]
admin: [auth.user]
}
###*
# @ngdoc method
# @name permissions#isPublic
#
# @param {Object} permissions
#
# This function determines whether the permissions allow public visibility
###
isPublic: (permissions) ->
GROUP_WORLD in (permissions?.read or [])
###*
# @ngdoc method
# @name permissions#isPrivate
#
# @param {Object} permissions
# @param {String} user
#
# @returns {boolean} True if the annotation is private to the user.
###
isPrivate: (permissions, user) ->
user and angular.equals(permissions?.read or [], [user])
# Creates access-level-control object list
_acl = (context) ->
acl = []
for action, roles of context.permissions or []
for role in roles
allow = true
if not role.indexOf('group:')
if role == GROUP_WORLD
principal = EVERYONE
else
# unhandled group
allow = false
principal = role
else
if not role.indexOf('acct:')
principal = role
else
allow = false
principal = role
acl.push
allow: allow
principal: principal
action: action
if acl.length
acl
else
return [
allow: true
principal: EVERYONE
action: ALL_PERMISSIONS
]
###*
# @ngdoc method
# @name permissions#permits
#
# @param {String} action action to authorize (read|update|delete|admin)
# @param {Object} context to permit action on it or not
# @param {String} user the userId
#
# User access-level-control function
###
permits: (action, context, user) ->
acls = _acl context
for acl in acls
if acl.principal not in [user, EVERYONE]
continue
if acl.action not in [action, ALL_PERMISSIONS]
continue
return acl.allow
false
angular.module('h')
.service('permissions', Permissions)
......@@ -28,7 +28,7 @@ renderFactory = ['$$rAF', ($$rAF) ->
class Hypothesis extends Annotator
events:
'beforeAnnotationCreated': 'digest'
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationCreated': 'digest'
'annotationDeleted': 'annotationDeleted'
'annotationUpdated': 'digest'
......@@ -47,13 +47,6 @@ class Hypothesis extends Annotator
tool: 'comment'
visibleHighlights: false
# Here as a noop just to make the Permissions plugin happy
# XXX: Change me when Annotator stops assuming things about viewers
editor:
addField: angular.noop
viewer:
addField: angular.noop
this.$inject = ['$document', '$window']
constructor: ( $document, $window ) ->
super ($document.find 'body')
......@@ -260,6 +253,11 @@ class Hypothesis extends Annotator
digest: ->
@element.scope().$evalAsync angular.noop
beforeAnnotationCreated: (annotation) ->
annotation.user = @element.injector().get('auth').user
annotation.permissions = {}
@digest()
annotationDeleted: (annotation) ->
scope = @element.scope()
if scope.selectedAnnotations?[annotation.id]
......@@ -323,8 +321,8 @@ class Hypothesis extends Annotator
# Sweet, nothing to do, just clean up previous filters
delete query.user
when "single-player"
if @plugins.Permissions?.user
query.user = @plugins.Permissions.user
if @user?
query.user = @element.injector().get('auth').user
else
delete query.user
......
// Generated by CoffeeScript 1.6.3
/*
** Annotator 1.2.6-dev-242f0df
** https://github.com/okfn/annotator/
**
** Copyright 2012 Aron Carroll, Rufus Pollock, and Nick Stenning.
** Dual licensed under the MIT and GPLv3 licenses.
** https://github.com/okfn/annotator/blob/master/LICENSE
**
** Built at: 2014-11-18 14:32:32Z
*/
/*
//
*/
// Generated by CoffeeScript 1.6.3
(function() {
var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
__hasProp = {}.hasOwnProperty,
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
Annotator.Plugin.Permissions = (function(_super) {
__extends(Permissions, _super);
Permissions.prototype.events = {
'beforeAnnotationCreated': 'addFieldsToAnnotation'
};
Permissions.prototype.options = {
showViewPermissionsCheckbox: true,
showEditPermissionsCheckbox: true,
userId: function(user) {
return user;
},
userString: function(user) {
return user;
},
userAuthorize: function(action, annotation, user) {
var token, tokens, _i, _len;
if (annotation.permissions) {
tokens = annotation.permissions[action] || [];
if (tokens.length === 0) {
return true;
}
for (_i = 0, _len = tokens.length; _i < _len; _i++) {
token = tokens[_i];
if (this.userId(user) === token) {
return true;
}
}
return false;
} else if (annotation.user) {
if (user) {
return this.userId(user) === this.userId(annotation.user);
} else {
return false;
}
}
return true;
},
user: '',
permissions: {
'read': [],
'update': [],
'delete': [],
'admin': []
}
};
function Permissions(element, options) {
this._setAuthFromToken = __bind(this._setAuthFromToken, this);
this.updateViewer = __bind(this.updateViewer, this);
this.updateAnnotationPermissions = __bind(this.updateAnnotationPermissions, this);
this.updatePermissionsField = __bind(this.updatePermissionsField, this);
this.addFieldsToAnnotation = __bind(this.addFieldsToAnnotation, this);
Permissions.__super__.constructor.apply(this, arguments);
if (this.options.user) {
this.setUser(this.options.user);
delete this.options.user;
}
}
Permissions.prototype.pluginInit = function() {
var createCallback, self,
_this = this;
if (!Annotator.supported()) {
return;
}
self = this;
createCallback = function(method, type) {
return function(field, annotation) {
return self[method].call(self, type, field, annotation);
};
};
if (!this.user && this.annotator.plugins.Auth) {
this.annotator.plugins.Auth.withToken(this._setAuthFromToken);
}
if (this.options.showViewPermissionsCheckbox === true) {
this.annotator.editor.addField({
type: 'checkbox',
label: Annotator._t('Allow anyone to <strong>view</strong> this annotation'),
load: createCallback('updatePermissionsField', 'read'),
submit: createCallback('updateAnnotationPermissions', 'read')
});
}
if (this.options.showEditPermissionsCheckbox === true) {
this.annotator.editor.addField({
type: 'checkbox',
label: Annotator._t('Allow anyone to <strong>edit</strong> this annotation'),
load: createCallback('updatePermissionsField', 'update'),
submit: createCallback('updateAnnotationPermissions', 'update')
});
}
this.annotator.viewer.addField({
load: this.updateViewer
});
if (this.annotator.plugins.Filter) {
return this.annotator.plugins.Filter.addFilter({
label: Annotator._t('User'),
property: 'user',
isFiltered: function(input, user) {
var keyword, _i, _len, _ref;
user = _this.options.userString(user);
if (!(input && user)) {
return false;
}
_ref = input.split(/\s*/);
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
keyword = _ref[_i];
if (user.indexOf(keyword) === -1) {
return false;
}
}
return true;
}
});
}
};
Permissions.prototype.setUser = function(user) {
return this.user = user;
};
Permissions.prototype.addFieldsToAnnotation = function(annotation) {
if (annotation) {
if (!annotation.permissions) {
annotation.permissions = $.extend(true, {}, this.options.permissions);
}
if (this.user) {
return annotation.user = this.user;
}
}
};
Permissions.prototype.authorize = function(action, annotation, user) {
if (user === void 0) {
user = this.user;
}
if (this.options.userAuthorize) {
return this.options.userAuthorize.call(this.options, action, annotation, user);
} else {
return true;
}
};
Permissions.prototype.updatePermissionsField = function(action, field, annotation) {
var input;
field = $(field).show();
input = field.find('input').removeAttr('disabled');
if (!this.authorize('admin', annotation)) {
field.hide();
}
if (this.authorize(action, annotation || {}, null)) {
return input.attr('checked', 'checked');
} else {
return input.removeAttr('checked');
}
};
Permissions.prototype.updateAnnotationPermissions = function(type, field, annotation) {
var dataKey;
if (!annotation.permissions) {
annotation.permissions = $.extend(true, {}, this.options.permissions);
}
dataKey = type + '-permissions';
if ($(field).find('input').is(':checked')) {
return annotation.permissions[type] = [];
} else {
return annotation.permissions[type] = [this.user];
}
};
Permissions.prototype.updateViewer = function(field, annotation, controls) {
var user, username;
field = $(field);
username = this.options.userString(annotation.user);
if (annotation.user && username && typeof username === 'string') {
user = Annotator.Util.escape(this.options.userString(annotation.user));
field.html(user).addClass('annotator-user');
} else {
field.remove();
}
if (controls) {
if (!this.authorize('update', annotation)) {
controls.hideEdit();
}
if (!this.authorize('delete', annotation)) {
return controls.hideDelete();
}
}
};
Permissions.prototype._setAuthFromToken = function(token) {
return this.setUser(token.userId);
};
return Permissions;
})(Annotator.Plugin);
}).call(this);
......@@ -37,7 +37,6 @@ module.exports = function(config) {
'h/static/scripts/vendor/annotator.js',
'h/static/scripts/vendor/annotator.auth.js',
'h/static/scripts/vendor/annotator.document.js',
'h/static/scripts/vendor/annotator.permissions.js',
'h/static/scripts/vendor/annotator.store.js',
'h/static/scripts/plugin/bridge.js',
'h/static/scripts/plugin/discovery.js',
......
......@@ -56,24 +56,20 @@ describe 'h', ->
onready()
assert.isNull(auth.user)
it 'sets the Permissions plugin and sets auth.user at login', ->
it 'sets auth.user at login', ->
{onlogin} = fakeIdentity.watch.args[0][0]
onlogin('test-assertion')
fakeToken = { userId: 'acct:hey@joe'}
userSetter = fakeAnnotator.plugins.Auth.withToken.args[0][0]
userSetter(fakeToken)
assert.equal(auth.user, 'acct:hey@joe')
secondPlugin = fakeAnnotator.addPlugin.args[1]
assert.equal(secondPlugin[0], 'Permissions')
it 'destroys the plugins at logout and sets auth.user to null', ->
it 'destroys the plugin at logout and sets auth.user to null', ->
{onlogout} = fakeIdentity.watch.args[0][0]
auth.user = 'acct:hey@joe'
authPlugin = fakeAnnotator.plugins.Auth
permissionsPlugin = fakeAnnotator.plugins.Permissions
onlogout()
assert.called(authPlugin.destroy)
assert.called(permissionsPlugin.destroy)
assert.equal(auth.user, null)
......@@ -16,14 +16,22 @@ describe 'h.directives.privacy', ->
beforeEach module('h.templates')
describe 'memory fallback', ->
fakeAuth = null
fakeWindow = null
sandbox = null
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeAuth = {
user: 'acct:angry.joe@texas.com'
}
fakeWindow = {
localStorage: undefined
}
$provide.value 'auth', fakeAuth
$provide.value '$window', fakeWindow
return
......@@ -55,6 +63,22 @@ describe 'h.directives.privacy', ->
assert.equal readPermissions, 'group:__world__'
describe 'has localStorage', ->
sandbox = null
fakeAuth = null
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeAuth = {
user: 'acct:angry.joe@texas.com'
}
$provide.value 'auth', fakeAuth
return
afterEach ->
sandbox.restore()
beforeEach inject (_$compile_, _$rootScope_, _$injector_, _$window_) ->
$compile = _$compile_
$scope = _$rootScope_.$new()
......@@ -129,32 +153,30 @@ describe 'h.directives.privacy', ->
beforeEach ->
$scope.permissions = {read: []}
it 'fills the permissions fields with the given user name', ->
it 'fills the permissions fields with the auth.user name', ->
store.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE
$element = $compile('<privacy ng-model="permissions" user="acct:user@example.com">')($scope)
$element = $compile('<privacy ng-model="permissions">')($scope)
$scope.$digest()
user = "acct:user@example.com"
readPermissions = $scope.permissions.read[0]
updatePermissions = $scope.permissions.update[0]
deletePermissions = $scope.permissions.delete[0]
adminPermissions = $scope.permissions.admin[0]
assert.equal readPermissions, user
assert.equal updatePermissions, user
assert.equal deletePermissions, user
assert.equal adminPermissions, user
assert.equal readPermissions, fakeAuth.user
assert.equal updatePermissions, fakeAuth.user
assert.equal deletePermissions, fakeAuth.user
assert.equal adminPermissions, fakeAuth.user
it 'puts group_world into the read permissions for public visibility', ->
store.setItem VISIBILITY_KEY, VISIBILITY_PUBLIC
$element = $compile('<privacy ng-model="permissions" user="acct:user@example.com">')($scope)
$element = $compile('<privacy ng-model="permissions">')($scope)
$scope.$digest()
user = "acct:user@example.com"
readPermissions = $scope.permissions.read[0]
updatePermissions = $scope.permissions.update[0]
deletePermissions = $scope.permissions.delete[0]
adminPermissions = $scope.permissions.admin[0]
assert.equal readPermissions, 'group:__world__'
assert.equal updatePermissions, user
assert.equal deletePermissions, user
assert.equal adminPermissions, user
assert.equal updatePermissions, fakeAuth.user
assert.equal deletePermissions, fakeAuth.user
assert.equal adminPermissions, fakeAuth.user
assert = chai.assert
sinon.assert.expose assert, prefix: null
describe 'h', ->
sandbox = null
fakeAuth = null
beforeEach module('h')
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeAuth = {
user: 'acct:flash@gordon'
}
$provide.value 'auth', fakeAuth
return
afterEach ->
sandbox.restore()
describe 'permissions service', ->
permissions = null
beforeEach inject (_permissions_) ->
permissions = _permissions_
it 'private call fills all permissions with auth.user', ->
perms = permissions.private()
assert.equal(perms.read[0], 'acct:flash@gordon')
assert.equal(perms.update[0], 'acct:flash@gordon')
assert.equal(perms.delete[0], 'acct:flash@gordon')
assert.equal(perms.admin[0], 'acct:flash@gordon')
it 'public call fills the read property with group:__world__', ->
perms = permissions.public()
assert.equal(perms.read[0], 'group:__world__')
assert.equal(perms.update[0], 'acct:flash@gordon')
assert.equal(perms.delete[0], 'acct:flash@gordon')
assert.equal(perms.admin[0], 'acct:flash@gordon')
describe 'isPublic', ->
it 'isPublic() true if the read permission has group:__world__ in it', ->
permission = {
read: ['group:__world__', 'acct:angry@birds.com']
}
assert.isTrue(permissions.isPublic(permission))
it 'isPublic() false otherwise', ->
permission = {
read: ['acct:angry@birds.com']
}
assert.isFalse(permissions.isPublic(permission))
permission.read = []
assert.isFalse(permissions.isPublic(permission))
permission.read = ['one', 'two', 'three']
assert.isFalse(permissions.isPublic(permission))
describe 'isPrivate', ->
it 'returns true if the given user is in the permissions', ->
user = 'acct:angry@birds.com'
permission = {read: [user]}
assert.isTrue(permissions.isPrivate(permission, user))
it 'returns false if another user is in the permissions', ->
users = ['acct:angry@birds.com', 'acct:angry@joe.com']
permission = {read: users}
assert.isFalse(permissions.isPrivate(permission, 'acct:angry@birds.com'))
it 'returns false if different user in the permissions', ->
user = 'acct:angry@joe.com'
permission = {read: ['acct:angry@birds.com']}
assert.isFalse(permissions.isPrivate(permission, user))
describe 'permits', ->
it 'returns true when annotation has no permissions', ->
annotation = {}
assert.isTrue(permissions.permits(null, annotation, null))
it 'returns false for unknown action', ->
annotation = {permissions: permissions.private()}
action = 'Hadouken-ing'
assert.isFalse(permissions.permits(action, annotation, null))
it 'returns true if user different, but permissions has group:__world__', ->
annotation = {permissions: permissions.public()}
annotation.permissions.read.push 'acct:darthsidious@deathstar.emp'
user = 'acct:darthvader@deathstar.emp'
assert.isTrue(permissions.permits('read', annotation, user))
it 'returns true if user is in permissions[action] list', ->
annotation = {permissions: permissions.private()}
user = 'acct:rogerrabbit@toonland'
annotation.permissions.read.push user
assert.isTrue(permissions.permits('read', annotation, user))
it 'returns false if the user name is missing from the list', ->
annotation = {permissions: permissions.private()}
user = 'acct:rogerrabbit@toonland'
assert.isFalse(permissions.permits('read', annotation, user))
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment