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:
parent
a17b637032
commit
8ea3977140
9 changed files with 298 additions and 27 deletions
|
@ -375,9 +375,15 @@ def confirm_team_invite(code, user_obj):
|
|||
return (team, inviter)
|
||||
|
||||
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)
|
||||
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):
|
||||
""" Returns the team syncing information for the team with the given name under the organization
|
||||
with the given name or None if none.
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
""" Create, list and manage an organization's teams. """
|
||||
|
||||
import json
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import request
|
||||
|
@ -7,13 +9,16 @@ from flask import request
|
|||
import features
|
||||
|
||||
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 import scopes
|
||||
from data import model
|
||||
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
||||
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 util.useremails import send_org_invite_email
|
||||
from util.names import parse_robot_username
|
||||
|
@ -200,6 +205,57 @@ class OrganizationTeam(ApiResource):
|
|||
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')
|
||||
@path_param('orgname', 'The name of the organization')
|
||||
@path_param('teamname', 'The name of the team')
|
||||
|
@ -231,16 +287,28 @@ class TeamMemberList(ApiResource):
|
|||
data = {
|
||||
'name': teamname,
|
||||
'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 sync_info is not None:
|
||||
data['synced'] = {
|
||||
'last_updated': format_date(sync_info.last_updated),
|
||||
'service': sync_info.service.name,
|
||||
'config': sync_info.config,
|
||||
}
|
||||
if authentication.federated_service:
|
||||
if SuperUserPermission().can():
|
||||
data['can_sync'] = {
|
||||
'service': authentication.federated_service,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
|
|
@ -2,15 +2,12 @@ import datetime
|
|||
import json
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from data import model
|
||||
from endpoints.api import api
|
||||
|
||||
CSRF_TOKEN_KEY = '_csrf_token'
|
||||
CSRF_TOKEN = '123csrfforme'
|
||||
|
||||
|
||||
@contextmanager
|
||||
def client_with_identity(auth_username, client):
|
||||
with client.session_transaction() as sess:
|
||||
if auth_username and auth_username is not None:
|
||||
|
|
|
@ -1,14 +1,26 @@
|
|||
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.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
|
||||
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'}
|
||||
BUILD_PARAMS = {'build_uuid': 'test-1234'}
|
||||
|
||||
@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, 'freshuser', 403),
|
||||
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'reader', 403),
|
||||
|
|
35
endpoints/api/test/test_team.py
Normal file
35
endpoints/api/test/test_team.py
Normal 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
|
|
@ -3,6 +3,18 @@
|
|||
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 {
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
|
@ -14,10 +26,10 @@
|
|||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.team-view .team-view-header {
|
||||
.team-view .team-view-header, .team-view .team-sync-header {
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.team-view .team-view-header button i.fa {
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
<div class="team-view-add-element" focusable-popover-content>
|
||||
<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)"
|
||||
email-selected="inviteEmail(email)"
|
||||
current-entity="selectedMember"
|
||||
auto-clear="true"
|
||||
allowed-entities="['user', 'robot']"
|
||||
allowed-entities="allowedEntities"
|
||||
pull-right="true"
|
||||
allow-emails="allowEmail"
|
||||
allow-emails="allowEmail && syncInfo"
|
||||
email-message="Press enter to invite the entered e-mail address to this team"
|
||||
ng-show="!addingMember"></div>
|
||||
<div ng-show="addingMember">
|
||||
<div class="cor-loader-inline"></div> Inviting team member
|
||||
</div>
|
||||
<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="!allowEmail">Search by registry username or robot account name</span>
|
||||
<span ng-if="!syncInfo">
|
||||
<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>
|
||||
|
|
|
@ -14,12 +14,14 @@
|
|||
var teamname = $routeParams.teamname;
|
||||
var orgname = $routeParams.orgname;
|
||||
|
||||
$scope.context = {};
|
||||
$scope.orgname = orgname;
|
||||
$scope.teamname = teamname;
|
||||
$scope.addingMember = false;
|
||||
$scope.memberMap = null;
|
||||
$scope.allowEmail = Features.MAILING;
|
||||
$scope.feedback = null;
|
||||
$scope.allowedEntities = ['user', 'robot'];
|
||||
|
||||
$rootScope.title = 'Loading...';
|
||||
|
||||
|
@ -146,6 +148,39 @@
|
|||
}, 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.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() {
|
||||
$scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) {
|
||||
$scope.organization = org;
|
||||
|
@ -187,6 +264,9 @@
|
|||
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
|
||||
$scope.members = resp.members;
|
||||
$scope.canEditMembers = resp.can_edit;
|
||||
$scope.canSync = resp.can_sync;
|
||||
$scope.syncInfo = resp.synced;
|
||||
$scope.allowedEntities = resp.synced ? ['robot'] : ['user', 'robot'];
|
||||
|
||||
$('.info-icon').popover({
|
||||
'trigger': 'hover',
|
||||
|
|
|
@ -18,6 +18,39 @@
|
|||
<div class="co-main-content-panel">
|
||||
<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 -->
|
||||
<div class="section-header">Team Description</div>
|
||||
<div class="team-view-header">
|
||||
|
@ -33,12 +66,15 @@
|
|||
<div ng-include="'/static/directives/team-view-add.html'" style="max-width: 500px;"></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-primary-msg">This team has no members.</div>
|
||||
<div class="empty-secondary-msg">
|
||||
Click the "Add Team Member" button above to add or invite team members.
|
||||
<div class="empty-secondary-msg" ng-if="!syncInfo">
|
||||
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>
|
||||
|
||||
|
@ -46,7 +82,7 @@
|
|||
<!-- Team Members -->
|
||||
<tr class="co-table-header-row"
|
||||
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 class="indented-row"
|
||||
|
@ -56,7 +92,7 @@
|
|||
show-avatar="true" avatar-size="24"></span>
|
||||
</td>
|
||||
<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)">
|
||||
<i class="fa fa-times"></i> Remove {{ member.name }}
|
||||
</span>
|
||||
|
@ -122,6 +158,25 @@
|
|||
</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 -->
|
||||
<div class="modal fade" id="cannotChangeTeamModal">
|
||||
<div class="modal-dialog">
|
||||
|
|
Reference in a new issue