based authorization Leverages alanning:roles package to associate a user to a role. Uses alanning:roles optional "group" parameter to limit the role's scope to either the global level or room level. The global level is applicable to users that can perform administrative functions. The room level is applicable to users that can perform room specific administrative functions (like a moderator). A role can have zero or more permissions. Permissions and their association to roles are defined by this package Authorization checks are based on whether or not the user has a role or permission. The roles, permissions, and their association are statically defined at this time. Eventually, there should be an API to dynamically create a role and associate it to static permission(s). Old 'isAdmin' and '.admin is true' checks have been replaced with corresponding hasPermission authorization checks. Additionally, code that automatically assigned admin privileges are updated to assign 'admin' role instead. channel/direct message/private group code checks authorization to edit properties (e.g. title) and edit/delete messages (regardless of the system level allow edit/delete settings). - user with 'admin' role are authorized to do anything - room creator is assigned 'moderator' role that can edit the room and edit/delete messages - members can only edit/delete their own messages IF system wide settings permit them to. v19 migration will - add 'admin' role to users with admin:true property - add 'moderator' role scoped to room for room creators - add 'user' role to all users. There are known issues unrelated to the changes made - If a user with edit/delete message room permissions logs out then a user without edit/delete message room permissions logs in, then they will see edit/delete icons. The server will deny execution - edit/delete icons are not reactive Thus if the system level allow edit/delete message setting is toggled, the icons will not reflect it. The server will deny execution.pull/818/head
parent
4fab550ad6
commit
c2e6e0fa2c
@ -1,14 +1,20 @@ |
||||
<template name="adminRoomInfo"> |
||||
<div> |
||||
<h3><a href="{{route}}"><i class="icon-{{type}}"></i> {{name}}</a></h3> |
||||
</div> |
||||
<div> |
||||
<h3>{{_ "Users"}}:</h3> |
||||
{{#each usernames}} |
||||
{{.}}<br /> |
||||
{{/each}} |
||||
</div> |
||||
<nav> |
||||
<button class='button delete red'><span><i class='icon-trash'></i> {{_ "Delete"}}</span></button> |
||||
</nav> |
||||
{{#unless hasPermission 'view-room-administration'}} |
||||
<p>You are not authorized to view this page.</p> |
||||
{{else}} |
||||
<div> |
||||
<h3><a href="{{route}}"><i class="icon-{{type}}"></i> {{name}}</a></h3> |
||||
</div> |
||||
<div> |
||||
<h3>{{_ "Users"}}:</h3> |
||||
{{#each usernames}} |
||||
{{.}}<br /> |
||||
{{/each}} |
||||
</div> |
||||
{{#if canDeleteRoom}} |
||||
<nav> |
||||
<button class='button delete red'><span><i class='icon-trash'></i> {{_ "Delete"}}</span></button> |
||||
</nav> |
||||
{{/if}} |
||||
{{/unless}} |
||||
</template> |
||||
@ -1,5 +1,9 @@ |
||||
<template name="adminUserChannels"> |
||||
<div class="user-info-channel"> |
||||
<h3><a href="{{route}}"><i class="icon-{{type}}"></i> {{name}}</a></h3> |
||||
</div> |
||||
{{#unless hasPermission 'view-full-other-user-info'}} |
||||
<p>You are not authorized to view this page.</p> |
||||
{{else}} |
||||
<div class="user-info-channel"> |
||||
<h3><a href="{{route}}"><i class="icon-{{type}}"></i> {{name}}</a></h3> |
||||
</div> |
||||
{{/unless}} |
||||
</template> |
||||
@ -1,19 +1,23 @@ |
||||
<template name="adminUserEdit"> |
||||
<div class="about clearfix"> |
||||
<form class="edit-form"> |
||||
<h3>{{name}}</h3> |
||||
<div class="input-line"> |
||||
<label for="name">{{_ "Name"}}</label> |
||||
<input type="text" id="name" autocomplete="off" value="{{name}}"> |
||||
</div> |
||||
<div class="input-line"> |
||||
<label for="username">{{_ "Username"}}</label> |
||||
<input type="text" id="username" autocomplete="off" value="{{username}}"> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
<nav> |
||||
<button class='button button-block cancel secondary'><span>{{_ "Cancel"}}</span></button> |
||||
<button class='button button-block blue save'><span>{{_ "Save"}}</span></button> |
||||
</nav> |
||||
{{#unless hasPermission 'edit-other-user-info'}} |
||||
<p>You are not authorized to view this page.</p> |
||||
{{else}} |
||||
<div class="about clearfix"> |
||||
<form class="edit-form"> |
||||
<h3>{{name}}</h3> |
||||
<div class="input-line"> |
||||
<label for="name">{{_ "Name"}}</label> |
||||
<input type="text" id="name" autocomplete="off" value="{{name}}"> |
||||
</div> |
||||
<div class="input-line"> |
||||
<label for="username">{{_ "Username"}}</label> |
||||
<input type="text" id="username" autocomplete="off" value="{{username}}"> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
<nav> |
||||
<button class='button button-block cancel secondary'><span>{{_ "Cancel"}}</span></button> |
||||
<button class='button button-block blue save'><span>{{_ "Save"}}</span></button> |
||||
</nav> |
||||
{{/unless}} |
||||
</template> |
||||
@ -1,19 +1,25 @@ |
||||
<template name="adminUserInfo"> |
||||
{{#if isAdmin}} |
||||
{{> userInfo user=.}} |
||||
<nav> |
||||
<button class='button lightblue edit-user button-block'><span><i class='icon-edit'></i> {{_ "Edit"}}</span></button> |
||||
{{#if admin}} |
||||
{{> userInfo user=.}} |
||||
<nav> |
||||
{{#if hasPermission 'edit-other-user-info'}} |
||||
<button class='button lightblue edit-user button-block'><span><i class='icon-edit'></i> {{_ "Edit"}}</span></button> |
||||
{{/if}} |
||||
{{#if hasPermission 'assign-admin-role'}} |
||||
{{#if hasAdminRole}} |
||||
<button class='button lightblue remove-admin button-block'><span><i class='icon-shield'></i> {{_ "Remove_Admin"}}</span></button> |
||||
{{else}} |
||||
<button class='button lightblue make-admin button-block'><span><i class='icon-shield'></i> {{_ "Make_Admin"}}</span></button> |
||||
{{/if}} |
||||
{{/if}} |
||||
{{#if hasPermission 'edit-other-user-active-status'}} |
||||
{{#if active}} |
||||
<button class='button deactivate button-block'><span><i class='icon-block'></i> {{_ "Deactivate"}}</span></button> |
||||
{{else}} |
||||
<button class='button activate button-block'><span><i class='icon-ok-circled'></i> {{_ "Activate"}}</span></button> |
||||
{{/if}} |
||||
<button class='button delete red button-block'><span><i class='icon-trash'></i> {{_ "Delete"}}</span></button> |
||||
</nav> |
||||
{{/if}} |
||||
{{/if}} |
||||
{{#if hasPermission 'delete-user'}} |
||||
<button class='button delete red button-block'><span><i class='icon-trash'></i> {{_ "Delete"}}</span></button> |
||||
{{/if}} |
||||
</nav> |
||||
</template> |
||||
@ -0,0 +1,41 @@ |
||||
Supports role or permission based authorization, and defines the association between them. |
||||
|
||||
A user is associated with role(s), and a role is associated with permission(s). This package depends on alanning:roles for the role/user association, while the role/permission association is handled internally. Thus, the underlying alanning:roles has no concept of a permission or the association between a role and permission. |
||||
|
||||
Authorization checks can be done based on a role or permission. However, permission based checks are preferred because they loosely associate an action with a role. For example: |
||||
|
||||
``` |
||||
# permission based check |
||||
if hasPermission(userId, 'edit-message') ... |
||||
# action is loosely associated to role via permission. Thus action can be revoked |
||||
# at runtime by removing the permission for user's role instead of modifying the action code. |
||||
|
||||
# role based check |
||||
if hasRole(userId, ['admin','site-moderator','moderator']) |
||||
# action is statically associated with the role |
||||
# action code has to be modified to add/remove role authorization |
||||
|
||||
``` |
||||
|
||||
Usage: |
||||
``` |
||||
# assign user to moderator role. Permissions scoped globally |
||||
# user can moderate (e.g. edit channel name, delete private group message) for all rooms |
||||
RocketChat.authz.addUsersToRoles(userId, 'moderator') |
||||
|
||||
# assign user to moderator role. Permissions scoped to the specified room |
||||
# user can moderate (e.g. edit channel name, delete private group message) for only one room specified by the roomId |
||||
RocketChat.authz.addUsersToRoles(userId, 'moderator', roomId ) |
||||
|
||||
# check if user can modify message for any room |
||||
RocketChat.authz.hasPermission(userId, 'edit-message') |
||||
|
||||
# check if user can modify message for the specified room. Also returns true if user |
||||
# has 'edit-message' at global scope. |
||||
RocketChat.authz.hasPermission(userId, 'edit-message', roomId) |
||||
``` |
||||
|
||||
Notes: |
||||
1. Roles are statically defined. UI needs to be implemented to dynamically assign permission(s) to a Role |
||||
2. 'admin', 'moderator', 'user' role identifiers should NOT be changed (unless you update the associated code) because they are referenced when creating users and creating rooms. |
||||
3. edit, delete message permissions are at either the global or room scope. i.e. role with edit-message with GLOBAL scope can edit ANY message regardless of the room type. However, role with edit-message with room scope can only edit messages for the room. The global scope is associated with the admin role while the "room-scoped" permission is assigned to the room "moderator" (room creator). If we want a middle ground that allows for edit-message for only channel/group/direct, then we need to create individual edit-c-message, edit-p-message, edit-d-message permissions. |
||||
@ -0,0 +1,40 @@ |
||||
atLeastOne = (toFind, toSearch) -> |
||||
console.log 'toFind: ', toFind if window.rocketDebug |
||||
console.log 'toSearch: ', toSearch if window.rocketDebug |
||||
return not _.isEmpty(_.intersection(toFind, toSearch)) |
||||
|
||||
all = (toFind, toSearch) -> |
||||
toFind = _.uniq(toFind) |
||||
toSearch = _.uniq(toSearch) |
||||
return _.isEmpty( _.difference( toFind, toSearch)) |
||||
|
||||
Template.registerHelper 'hasPermission', (permission, scope) -> |
||||
unless _.isString( scope ) |
||||
scope = Roles.GLOBAL_GROUP |
||||
return hasPermission( permission, scope, atLeastOne) |
||||
|
||||
RocketChat.authz.hasAllPermission = (permissions, scope=Roles.GLOBAL_GROUP) -> |
||||
return hasPermission( permissions, scope, all ) |
||||
|
||||
RocketChat.authz.hasAtLeastOnePermission = (permissions, scope=Roles.GLOBAL_GROUP) -> |
||||
return hasPermission(permissions, scope, atLeastOne) |
||||
|
||||
hasPermission = (permissions, scope=Roles.GLOBAL_GROUP, strategy) -> |
||||
userId = Meteor.userId() |
||||
|
||||
unless userId |
||||
return false |
||||
|
||||
unless RocketChat.authz.subscription.ready() |
||||
return false |
||||
|
||||
unless _.isArray(permissions) |
||||
permissions = [permissions] |
||||
|
||||
roleNames = Roles.getRolesForUser(userId, scope) |
||||
|
||||
userPermissions = [] |
||||
for roleName in roleNames |
||||
userPermissions = userPermissions.concat(_.pluck(ChatPermissions.find({roles : roleName }).fetch(), '_id')) |
||||
|
||||
return strategy( permissions, userPermissions) |
||||
@ -0,0 +1,6 @@ |
||||
RocketChat.authz.hasRole = (userId, roleName, scope=Roles.GLOBAL_GROUP) -> |
||||
unless Meteor.userId() |
||||
return false |
||||
|
||||
# per alanning:roles, returns true if user is in ANY roles |
||||
return Roles.userIsInRole(userId, [roleName], scope) |
||||
@ -0,0 +1,2 @@ |
||||
Meteor.startup -> |
||||
RocketChat.authz.subscription = Meteor.subscribe 'permissions' |
||||
@ -0,0 +1 @@ |
||||
@ChatPermissions = new Meteor.Collection 'rocketchat_permissions' |
||||
@ -0,0 +1 @@ |
||||
RocketChat.authz = {} |
||||
@ -0,0 +1,38 @@ |
||||
Package.describe({ |
||||
name: 'rocketchat:authorization', |
||||
version: '0.0.1', |
||||
summary: 'Role based authorization of actions', |
||||
git: '', |
||||
documentation: 'README.md' |
||||
}); |
||||
|
||||
Package.onUse(function(api) { |
||||
api.versionsFrom('1.0'); |
||||
api.use([ |
||||
'coffeescript', |
||||
'rocketchat:lib@0.0.1', |
||||
'alanning:roles@1.2.12' |
||||
]); |
||||
|
||||
api.use('templating', 'client'); |
||||
|
||||
api.addFiles('lib/permissions.coffee', ['server', 'client']); |
||||
api.addFiles('lib/rocketchat.coffee', ['server','client']); |
||||
api.addFiles('client/startup.coffee', ['client']); |
||||
api.addFiles('client/hasPermission.coffee', ['client']); |
||||
api.addFiles('client/hasRole.coffee', ['client']); |
||||
|
||||
|
||||
api.addFiles('server/functions/addUsersToRoles.coffee', ['server']); |
||||
api.addFiles('server/functions/getPermissionsForRole.coffee', ['server']); |
||||
api.addFiles('server/functions/getRoles.coffee', ['server']); |
||||
api.addFiles('server/functions/getRolesForUser.coffee', ['server']); |
||||
api.addFiles('server/functions/getUsersInRole.coffee', ['server']); |
||||
api.addFiles('server/functions/hasPermission.coffee', ['server']); |
||||
api.addFiles('server/functions/hasRole.coffee', ['server']); |
||||
api.addFiles('server/functions/removeUsersFromRoles.coffee', ['server']); |
||||
api.addFiles('server/functions/methods.coffee', ['server']); |
||||
|
||||
api.addFiles('server/publication.coffee', ['server']); |
||||
api.addFiles('server/startup.coffee', ['server']); |
||||
}); |
||||
@ -0,0 +1,26 @@ |
||||
RocketChat.authz.addUsersToRoles = (userIds, roleNames, scope ) -> |
||||
console.log '[methods] addUserToRoles -> '.green, 'arguments:', arguments |
||||
if not userIds or not roleNames |
||||
return false |
||||
|
||||
unless _.isArray(userIds) |
||||
userIds = [userIds] |
||||
|
||||
users = Meteor.users.find({_id: {$in : userIds}}).fetch() |
||||
unless userIds.length is users.length |
||||
throw new Meteor.Error 'invalid-user' |
||||
|
||||
unless _.isArray(roleNames) |
||||
roleNames = [roleNames] |
||||
|
||||
existingRoleNames = _.pluck(RocketChat.authz.getRoles().fetch(), 'name') |
||||
invalidRoleNames = _.difference( roleNames, existingRoleNames) |
||||
unless _.isEmpty(invalidRoleNames) |
||||
throw new Meteor.Error 'invalid-role' |
||||
|
||||
unless _.isString(scope) |
||||
scope = Roles.GLOBAL_GROUP |
||||
|
||||
Roles.addUsersToRoles( userIds, roleNames, scope) |
||||
|
||||
return true |
||||
@ -0,0 +1,9 @@ |
||||
RocketChat.authz.getPermissionsForRole = (roleName) -> |
||||
unless roleName |
||||
throw new Meteor.Error 'invalid-role' |
||||
|
||||
roleNames = _.pluck(RocketChat.authz.getRoles().fetch(), 'name') |
||||
unless roleName in roleNames |
||||
throw new Meteor.Error 'invalid-role' |
||||
|
||||
return _.pluck(ChatPermissions.find({roles : roleName }).fetch(), '_id') |
||||
@ -0,0 +1,2 @@ |
||||
RocketChat.authz.getRoles = -> |
||||
return Roles.getAllRoles() |
||||
@ -0,0 +1,7 @@ |
||||
RocketChat.authz.getRolesForUser = (userId, scope) -> |
||||
console.log '[methods] getRolesForUser -> '.green, 'arguments:', arguments |
||||
# returns roles for the given scope as well as the global scope |
||||
unless scope |
||||
scope = Roles.GLOBAL_GROUP |
||||
|
||||
return Roles.getRolesForUser(userId, scope) |
||||
@ -0,0 +1,6 @@ |
||||
RocketChat.authz.getUsersInRole = (roleName, scope) -> |
||||
# alanning:roles doc says this is an expensive operation |
||||
unless _.isString(scope) |
||||
scope = Roles.GLOBAL_GROUP |
||||
|
||||
return Roles.getUsersInRole(roleName, scope) |
||||
@ -0,0 +1,12 @@ |
||||
RocketChat.authz.hasPermission = (userId, permissionId, scope) -> |
||||
console.log '[methods] hasPermission -> '.green, 'arguments:', arguments |
||||
|
||||
# get user's roles |
||||
roles = RocketChat.authz.getRolesForUser(userId, scope) |
||||
|
||||
# get permissions for user's roles |
||||
permissions = [] |
||||
for role in roles |
||||
permissions = permissions.concat( RocketChat.authz.getPermissionsForRole( role )) |
||||
# may contain duplicate, but doesn't matter |
||||
return permissionId in permissions |
||||
@ -0,0 +1,4 @@ |
||||
RocketChat.authz.hasRole = (userId, roleName, scope) -> |
||||
console.log '[methods] hasRoles -> '.green, 'arguments:', arguments |
||||
# per alanning:roles, returns true if user is in ANY roles |
||||
return Roles.userIsInRole(userId, [roleName], scope) |
||||
@ -0,0 +1,26 @@ |
||||
RocketChat.authz.removeUsersFromRoles = (userIds, roleNames, scope ) -> |
||||
console.log '[methods] removeUsersFromRoles -> '.green, 'arguments:', arguments |
||||
if not userIds or not roleNames |
||||
return false |
||||
|
||||
unless _.isArray(userIds) |
||||
userIds = [userIds] |
||||
|
||||
users = Meteor.users.find({_id: {$in : userIds}}).fetch() |
||||
unless userIds.length is users.length |
||||
throw new Meteor.Error 'invalid-user' |
||||
|
||||
unless _.isArray(roleNames) |
||||
roleNames = [roleNames] |
||||
|
||||
existingRoleNames = _.pluck(RocketChat.authz.getRoles().fetch(), 'name') |
||||
invalidRoleNames = _.difference( roleNames, existingRoleNames) |
||||
unless _.isEmpty(invalidRoleNames) |
||||
throw new Meteor.Error 'invalid-role' |
||||
|
||||
unless _.isString(scope) |
||||
scope = Roles.GLOBAL_GROUP |
||||
|
||||
Roles.removeUsersFromRoles( userIds, roleNames, scope) |
||||
|
||||
return true |
||||
@ -0,0 +1,3 @@ |
||||
Meteor.publish 'permissions', -> |
||||
console.log '[publish] permissions'.green |
||||
return ChatPermissions.find {} |
||||
@ -0,0 +1,87 @@ |
||||
Meteor.startup -> |
||||
|
||||
# Note: |
||||
# 1.if we need to create a role that can only edit channel message, but not edit group message |
||||
# then we can define edit-<type>-message instead of edit-message |
||||
# 2. admin, moderator, and user roles should not be deleted as they are referened in the code. |
||||
permissions = [ |
||||
|
||||
{ _id: 'view-statistics', |
||||
roles : ['admin', 'temp-role']} |
||||
|
||||
{ _id: 'view-privileged-setting', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'edit-privileged-setting', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'view-room-administration', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'view-user-administration', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'view-full-other-user-info', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'edit-other-user-info', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'assign-admin-role', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'edit-other-user-active-status', |
||||
roles : ['admin', 'site-moderator']} |
||||
|
||||
{ _id: 'delete-user', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'view-other-user-channels', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'add-oath-service', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'run-migration', |
||||
roles : ['admin']} |
||||
|
||||
{ _id: 'create-c', |
||||
roles : ['admin', 'site-moderator', 'user']} |
||||
|
||||
{ _id: 'delete-c', |
||||
roles : ['admin', 'site-moderator']} |
||||
|
||||
{ _id: 'edit-room', |
||||
roles : ['admin', 'site-moderator', 'moderator']} |
||||
|
||||
{ _id: 'edit-message', |
||||
roles : ['admin', 'site-moderator', 'moderator']} |
||||
|
||||
{ _id: 'delete-message', |
||||
roles : ['admin', 'site-moderator', 'moderator']} |
||||
|
||||
{ _id: 'ban-user', |
||||
roles : ['admin', 'site-moderator', 'moderator']} |
||||
|
||||
{ _id: 'create-p', |
||||
roles : ['admin', 'site-moderator', 'user']} |
||||
|
||||
{ _id: 'delete-p', |
||||
roles : ['admin', 'site-moderator']} |
||||
|
||||
{ _id: 'delete-d', |
||||
roles : ['admin', 'site-moderator']} |
||||
|
||||
] |
||||
|
||||
#alanning:roles |
||||
roles = _.pluck(Roles.getAllRoles().fetch(), 'name'); |
||||
|
||||
for permission in permissions |
||||
ChatPermissions.upsert( permission._id, {$setOnInsert : permission }) |
||||
for role in permission.roles |
||||
unless role in roles |
||||
Roles.createRole role |
||||
roles.push(role) |
||||
|
||||
|
||||
@ -0,0 +1,28 @@ |
||||
Meteor.startup -> |
||||
Migrations.add |
||||
version: 19 |
||||
up: -> |
||||
### |
||||
# Migrate existing admin users to Role based admin functionality |
||||
# 'admin' role applies to global scope |
||||
### |
||||
admins = Meteor.users.find({ admin: true }, { fields: { _id: 1, username:1 } }).fetch() |
||||
RocketChat.authz.addUsersToRoles( _.pluck(admins, '_id'), ['admin']) |
||||
Meteor.users.update({}, { $unset :{admin:''}}, {multi:true}) |
||||
usernames = _.pluck( admins, 'username').join(', ') |
||||
console.log "Migrate #{usernames} from admin field to 'admin' role".green |
||||
|
||||
# Add 'user' role to all users |
||||
users = Meteor.users.find().fetch() |
||||
RocketChat.authz.addUsersToRoles( _.pluck(users, '_id'), ['user']) |
||||
usernames = _.pluck( users, 'username').join(', ') |
||||
console.log "Add #{usernames} to 'user' role".green |
||||
|
||||
# Add 'moderator' role to channel/group creators |
||||
rooms = ChatRoom.find({t: {$in : ['c','p']}}).fetch() |
||||
_.each( rooms, (room) -> |
||||
creator = room?.u?._id |
||||
if creator |
||||
RocketChat.authz.addUsersToRoles( creator, ['moderator'], room._id) |
||||
console.log "Add #{room.u.username} to 'moderator' role".green |
||||
) |
||||
Loading…
Reference in new issue