Add ability to enable, disable and view team syncing in UI and API

Also extracts out some common testing infrastructure to make testing APIs easier now using pytest
This commit is contained in:
Joseph Schorr 2017-02-17 18:20:23 -05:00
parent a17b637032
commit 8ea3977140
9 changed files with 298 additions and 27 deletions

View file

@ -375,9 +375,15 @@ def confirm_team_invite(code, user_obj):
return (team, inviter) return (team, inviter)
def set_team_syncing(team, login_service_name, config): def set_team_syncing(team, login_service_name, config):
""" Sets the given team to sync to the given service using the given config. """
login_service = LoginService.get(name=login_service_name) login_service = LoginService.get(name=login_service_name)
TeamSync.create(team=team, transaction_id='', service=login_service, config=json.dumps(config)) TeamSync.create(team=team, transaction_id='', service=login_service, config=json.dumps(config))
def remove_team_syncing(orgname, teamname):
existing = get_team_sync_information(orgname, teamname)
if existing:
existing.delete_instance()
def get_team_sync_information(orgname, teamname): def get_team_sync_information(orgname, teamname):
""" Returns the team syncing information for the team with the given name under the organization """ Returns the team syncing information for the team with the given name under the organization
with the given name or None if none. with the given name or None if none.

View file

@ -1,5 +1,7 @@
""" Create, list and manage an organization's teams. """ """ Create, list and manage an organization's teams. """
import json
from functools import wraps from functools import wraps
from flask import request from flask import request
@ -7,13 +9,16 @@ from flask import request
import features import features
from app import avatar, authentication from app import avatar, authentication
from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission from auth.permissions import (AdministerOrganizationPermission, ViewTeamPermission,
SuperUserPermission)
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from data import model from data import model
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
log_action, internal_only, require_scope, path_param, query_param, log_action, internal_only, require_scope, path_param, query_param,
truthy_bool, parse_args, require_user_admin, show_if, format_date) truthy_bool, parse_args, require_user_admin, show_if, format_date,
verify_not_prod, require_fresh_login)
from endpoints.exception import Unauthorized, NotFound, InvalidRequest from endpoints.exception import Unauthorized, NotFound, InvalidRequest
from util.useremails import send_org_invite_email from util.useremails import send_org_invite_email
from util.names import parse_robot_username from util.names import parse_robot_username
@ -200,6 +205,57 @@ class OrganizationTeam(ApiResource):
raise Unauthorized() raise Unauthorized()
@resource('/v1/organization/<orgname>/team/<teamname>/syncing')
@path_param('orgname', 'The name of the organization')
@path_param('teamname', 'The name of the team')
class OrganizationTeamSyncing(ApiResource):
""" Resource for managing syncing of a team by a backing group. """
@require_scope(scopes.ORG_ADMIN)
@require_scope(scopes.SUPERUSER)
@nickname('enableOrganizationTeamSync')
@verify_not_prod
@require_fresh_login
def post(self, orgname, teamname):
# User must be both the org admin AND a superuser.
if SuperUserPermission().can() and AdministerOrganizationPermission(orgname).can():
try:
team = model.team.get_organization_team(orgname, teamname)
except model.InvalidTeamException:
raise NotFound()
config = request.get_json()
# Ensure that the specified config points to a valid group.
status, err = authentication.check_group_lookup_args(config)
if not status:
raise InvalidRequest('Could not sync to group: %s' % err)
# Set the team's syncing config.
model.team.set_team_syncing(team, authentication.federated_service, config)
return team_view(orgname, team)
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@require_scope(scopes.SUPERUSER)
@nickname('disableOrganizationTeamSync')
@verify_not_prod
@require_fresh_login
def delete(self, orgname, teamname):
# User must be both the org admin AND a superuser.
if SuperUserPermission().can() and AdministerOrganizationPermission(orgname).can():
try:
team = model.team.get_organization_team(orgname, teamname)
except model.InvalidTeamException:
raise NotFound()
model.team.remove_team_syncing(orgname, teamname)
return team_view(orgname, team)
raise Unauthorized()
@resource('/v1/organization/<orgname>/team/<teamname>/members') @resource('/v1/organization/<orgname>/team/<teamname>/members')
@path_param('orgname', 'The name of the organization') @path_param('orgname', 'The name of the organization')
@path_param('teamname', 'The name of the team') @path_param('teamname', 'The name of the team')
@ -231,16 +287,28 @@ class TeamMemberList(ApiResource):
data = { data = {
'name': teamname, 'name': teamname,
'members': [member_view(m) for m in members] + [invite_view(i) for i in invites], 'members': [member_view(m) for m in members] + [invite_view(i) for i in invites],
'can_edit': edit_permission.can() 'can_edit': edit_permission.can(),
} }
sync_info = model.team.get_team_sync_information(orgname, teamname) if authentication.federated_service:
if sync_info is not None: if SuperUserPermission().can():
data['synced'] = { data['can_sync'] = {
'last_updated': format_date(sync_info.last_updated), 'service': authentication.federated_service,
'service': sync_info.service.name, }
'config': sync_info.config,
} data['can_sync'].update(authentication.service_metadata())
sync_info = model.team.get_team_sync_information(orgname, teamname)
if sync_info is not None:
data['synced'] = {
'service': sync_info.service.name,
}
if SuperUserPermission().can():
data['synced'].update({
'last_updated': format_date(sync_info.last_updated),
'config': json.loads(sync_info.config),
})
return data return data

View file

@ -2,15 +2,12 @@ import datetime
import json import json
from contextlib import contextmanager from contextlib import contextmanager
from data import model from data import model
from endpoints.api import api from endpoints.api import api
CSRF_TOKEN_KEY = '_csrf_token' CSRF_TOKEN_KEY = '_csrf_token'
CSRF_TOKEN = '123csrfforme' CSRF_TOKEN = '123csrfforme'
@contextmanager
def client_with_identity(auth_username, client): def client_with_identity(auth_username, client):
with client.session_transaction() as sess: with client.session_transaction() as sess:
if auth_username and auth_username is not None: if auth_username and auth_username is not None:

View file

@ -1,14 +1,26 @@
import pytest import pytest
from endpoints.api import api
from endpoints.api.team import OrganizationTeamSyncing
from endpoints.api.test.shared import client_with_identity, conduct_api_call from endpoints.api.test.shared import client_with_identity, conduct_api_call
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
from endpoints.api.superuser import SuperUserRepositoryBuildStatus from endpoints.api.superuser import SuperUserRepositoryBuildStatus
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'} TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
BUILD_PARAMS = {'build_uuid': 'test-1234'} BUILD_PARAMS = {'build_uuid': 'test-1234'}
@pytest.mark.parametrize('resource,method,params,body,identity,expected', [ @pytest.mark.parametrize('resource,method,params,body,identity,expected', [
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403),
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'freshuser', 403),
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'reader', 403),
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'devtable', 400),
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, None, 403),
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'freshuser', 403),
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'reader', 403),
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'devtable', 200),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, None, 401), (SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, None, 401),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'freshuser', 403), (SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'reader', 403), (SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'reader', 403),

View file

@ -0,0 +1,35 @@
import json
from mock import patch
from data import model
from endpoints.api import api
from endpoints.api.test.shared import client_with_identity, conduct_api_call
from endpoints.api.team import OrganizationTeamSyncing
from test.test_ldap import mock_ldap
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
def test_team_syncing(client):
with mock_ldap() as ldap:
with patch('endpoints.api.team.authentication', ldap):
cl = client_with_identity('devtable', client)
config = {
'group_dn': 'cn=AwesomeFolk',
}
conduct_api_call(cl, OrganizationTeamSyncing, 'POST', TEAM_PARAMS, config)
# Ensure the team is now synced.
sync_info = model.team.get_team_sync_information(TEAM_PARAMS['orgname'],
TEAM_PARAMS['teamname'])
assert sync_info is not None
assert json.loads(sync_info.config) == config
# Remove the syncing.
conduct_api_call(cl, OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, None)
# Ensure the team is no longer synced.
sync_info = model.team.get_team_sync_information(TEAM_PARAMS['orgname'],
TEAM_PARAMS['teamname'])
assert sync_info is None

View file

@ -3,6 +3,18 @@
position: relative; position: relative;
} }
.team-view .team-sync-table {
margin-bottom: 20px;
}
.team-view .team-sync-table td {
padding: 6px;
}
.team-view .team-sync-table td:first-child {
font-weight: bold;
}
.team-view .team-title { .team-view .team-title {
vertical-align: middle; vertical-align: middle;
margin-right: 10px; margin-right: 10px;
@ -14,10 +26,10 @@
margin-left: 6px; margin-left: 6px;
} }
.team-view .team-view-header { .team-view .team-view-header, .team-view .team-sync-header {
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
margin-bottom: 10px; margin-bottom: 20px;
padding-bottom: 10px; padding-bottom: 20px;
} }
.team-view .team-view-header button i.fa { .team-view .team-view-header button i.fa {

View file

@ -1,20 +1,26 @@
<div class="team-view-add-element" focusable-popover-content> <div class="team-view-add-element" focusable-popover-content>
<div class="entity-search" <div class="entity-search"
namespace="orgname" placeholder="allowEmail ? 'Add a registered user, robot or email address to the team' : 'Add a registered user or robot to the team'" namespace="orgname"
placeholder="getAddPlaceholder(allowEmail, syncInfo)"
entity-selected="addNewMember(entity)" entity-selected="addNewMember(entity)"
email-selected="inviteEmail(email)" email-selected="inviteEmail(email)"
current-entity="selectedMember" current-entity="selectedMember"
auto-clear="true" auto-clear="true"
allowed-entities="['user', 'robot']" allowed-entities="allowedEntities"
pull-right="true" pull-right="true"
allow-emails="allowEmail" allow-emails="allowEmail && syncInfo"
email-message="Press enter to invite the entered e-mail address to this team" email-message="Press enter to invite the entered e-mail address to this team"
ng-show="!addingMember"></div> ng-show="!addingMember"></div>
<div ng-show="addingMember"> <div ng-show="addingMember">
<div class="cor-loader-inline"></div> Inviting team member <div class="cor-loader-inline"></div> Inviting team member
</div> </div>
<div class="help-text" ng-show="!addingMember"> <div class="help-text" ng-show="!addingMember">
<span ng-if="allowEmail">Search by registry username, robot account name or enter an email address to invite</span> <span ng-if="!syncInfo">
<span ng-if="!allowEmail">Search by registry username or robot account name</span> <span ng-if="allowEmail">Search by registry username, robot account name or enter an email address to invite</span>
<span ng-if="!allowEmail">Search by registry username or robot account name</span>
</span>
<span ng-if="syncInfo">
Search by robot account name. Users must be added in {{ syncInfo.service }}.
</span>
</div> </div>
</div> </div>

View file

@ -14,12 +14,14 @@
var teamname = $routeParams.teamname; var teamname = $routeParams.teamname;
var orgname = $routeParams.orgname; var orgname = $routeParams.orgname;
$scope.context = {};
$scope.orgname = orgname; $scope.orgname = orgname;
$scope.teamname = teamname; $scope.teamname = teamname;
$scope.addingMember = false; $scope.addingMember = false;
$scope.memberMap = null; $scope.memberMap = null;
$scope.allowEmail = Features.MAILING; $scope.allowEmail = Features.MAILING;
$scope.feedback = null; $scope.feedback = null;
$scope.allowedEntities = ['user', 'robot'];
$rootScope.title = 'Loading...'; $rootScope.title = 'Loading...';
@ -146,6 +148,39 @@
}, ApiService.errorDisplay('Cannot remove team member')); }, ApiService.errorDisplay('Cannot remove team member'));
}; };
$scope.getServiceName = function(service) {
switch (service) {
case 'ldap':
return 'LDAP';
case 'keystone':
return 'Keystone Auth';
case 'jwtauthn':
return 'External JWT Auth';
default:
return synced.service;
}
};
$scope.getAddPlaceholder = function(email, synced) {
var kinds = [];
if (!synced) {
kinds.push('registered user');
}
kinds.push('robot');
if (email && !synced) {
kinds.push('email address');
}
kind_string = kinds.join(', ')
return 'Add a ' + kind_string + ' to the team';
};
$scope.updateForDescription = function(content) { $scope.updateForDescription = function(content) {
$scope.organization.teams[teamname].description = content; $scope.organization.teams[teamname].description = content;
@ -166,6 +201,48 @@
}); });
}; };
$scope.showEnableSyncing = function() {
$scope.enableSyncingInfo = {
'service_info': $scope.canSync,
'config': {}
};
};
$scope.showDisableSyncing = function() {
msg = 'Are you sure you want to disable group syncing on this team? ' +
'The team will once again become editable.';
bootbox.confirm(msg, function(result) {
if (result) {
$scope.disableSyncing();
}
});
};
$scope.disableSyncing = function() {
var params = {
'orgname': orgname,
'teamname': teamname
};
var errorHandler = ApiService.errorDisplay('Could not disable team syncing');
ApiService.disableOrganizationTeamSync(null, params).then(function(resp) {
loadMembers();
}, errorHandler);
};
$scope.enableSyncing = function(config, callback) {
var params = {
'orgname': orgname,
'teamname': teamname
};
var errorHandler = ApiService.errorDisplay('Cannot enable team syncing', callback);
ApiService.enableOrganizationTeamSync(config, params).then(function(resp) {
loadMembers();
callback(true);
}, errorHandler);
};
var loadOrganization = function() { var loadOrganization = function() {
$scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) {
$scope.organization = org; $scope.organization = org;
@ -187,6 +264,9 @@
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) { $scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
$scope.members = resp.members; $scope.members = resp.members;
$scope.canEditMembers = resp.can_edit; $scope.canEditMembers = resp.can_edit;
$scope.canSync = resp.can_sync;
$scope.syncInfo = resp.synced;
$scope.allowedEntities = resp.synced ? ['robot'] : ['user', 'robot'];
$('.info-icon').popover({ $('.info-icon').popover({
'trigger': 'hover', 'trigger': 'hover',

View file

@ -18,6 +18,39 @@
<div class="co-main-content-panel"> <div class="co-main-content-panel">
<div class="feedback-bar" feedback="feedback"></div> <div class="feedback-bar" feedback="feedback"></div>
<div class="team-sync-header" ng-if="canSync && !syncInfo">
<div class="section-header">Directory Synchronization</div>
<p>Directory synchronization allows this team's user membership to be backed by a group in {{ getServiceName(canSync.service) }}.</p>
<button class="btn btn-primary" ng-click="showEnableSyncing()">Enable Directory Synchronization</button>
</div>
<!-- Sync Header -->
<div ng-if="syncInfo">
<div class="co-alert co-alert-info">
This team is synchronized with a group in <strong>{{ getServiceName(syncInfo.service) }}</strong> and its user membership is therefore <strong>read-only</strong>.
</div>
<div class="team-sync-header" ng-if="syncInfo.last_updated">
<div class="section-header">Directory Synchronization</div>
<table class="team-sync-table">
<tr>
<td>Bound to group:</td>
<td>
<div ng-if="syncInfo.service == 'ldap'">
<code>{{ syncInfo.config.group_dn }}</code>
</div>
</td>
</tr>
<tr>
<td>Last Updated:</td>
<td><span am-time-ago="syncInfo.last_updated"></span> at {{ syncInfo.last_updated | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}</td>
</tr>
</table>
<button class="btn btn-default" ng-click="showDisableSyncing()">Remove Synchronization</button>
</div>
</div>
<!-- Description --> <!-- Description -->
<div class="section-header">Team Description</div> <div class="section-header">Team Description</div>
<div class="team-view-header"> <div class="team-view-header">
@ -33,12 +66,15 @@
<div ng-include="'/static/directives/team-view-add.html'" style="max-width: 500px;"></div> <div ng-include="'/static/directives/team-view-add.html'" style="max-width: 500px;"></div>
</div> </div>
</div> </div>
<div class="section-header">Team Members</div> <div class="section-header" style="margin-bottom: 55px;">Team Members</div>
<div class="empty" ng-if="!members.length"> <div class="empty" ng-if="!members.length">
<div class="empty-primary-msg">This team has no members.</div> <div class="empty-primary-msg">This team has no members.</div>
<div class="empty-secondary-msg"> <div class="empty-secondary-msg" ng-if="!syncInfo">
Click the "Add Team Member" button above to add or invite team members. Enter a user or robot above to add or invite to the team.
</div>
<div class="empty-secondary-msg" ng-if="syncInfo">
This team is synchronized with an external group defined in {{ getServiceName(syncInfo.service) }}. To add a user to this team, add them in the backing group. To add a robot account to this team, enter them above.
</div> </div>
</div> </div>
@ -46,7 +82,7 @@
<!-- Team Members --> <!-- Team Members -->
<tr class="co-table-header-row" <tr class="co-table-header-row"
ng-if="(members | filter: filterFunction(false, false)).length"> ng-if="(members | filter: filterFunction(false, false)).length">
<td colspan="3"><i class="fa fa-user"></i> Team Members</td> <td colspan="3"><i class="fa fa-user"></i> Team Members <span ng-if="syncInfo">(defined in {{ getServiceName(syncInfo.service) }})</span></td>
</tr> </tr>
<tr class="indented-row" <tr class="indented-row"
@ -56,7 +92,7 @@
show-avatar="true" avatar-size="24"></span> show-avatar="true" avatar-size="24"></span>
</td> </td>
<td class="options-col"> <td class="options-col">
<span class="cor-options-menu" ng-if="canEditMembers"> <span class="cor-options-menu" ng-if="canEditMembers && !syncInfo">
<span class="cor-option" option-click="removeMember(member.name)"> <span class="cor-option" option-click="removeMember(member.name)">
<i class="fa fa-times"></i> Remove {{ member.name }} <i class="fa fa-times"></i> Remove {{ member.name }}
</span> </span>
@ -122,6 +158,25 @@
</div> </div>
</div> </div>
<!-- Directory binding dialog -->
<div class="cor-confirm-dialog"
dialog-context="enableSyncingInfo"
dialog-action="enableSyncing(info.config, callback)"
dialog-title="Enable Directory Syncing"
dialog-action-title="Enable Group Sync"
dialog-form="context.syncform">
<div class="co-alert co-alert-warning">Please note that once team syncing is enabled, the team's user membership from within <span class="registry-name"></span> will be read-only.</div>
<form name="context.syncform" class="co-single-field-dialog">
<div ng-switch on="enableSyncingInfo.service_info.service">
<div ng-switch-when="ldap">
Enter the distinguished name of the group, relative to <code>{{ enableSyncingInfo.service_info.base_dn }}</code>:
<input type="text" class="form-control" placeholder="Group DN" ng-model="enableSyncingInfo.config.group_dn" required>
</div>
</div>
</form>
</div>
<!-- Modal message dialog --> <!-- Modal message dialog -->
<div class="modal fade" id="cannotChangeTeamModal"> <div class="modal fade" id="cannotChangeTeamModal">
<div class="modal-dialog"> <div class="modal-dialog">