diff --git a/data/model/team.py b/data/model/team.py index 1d176ebdf..52a58b842 100644 --- a/data/model/team.py +++ b/data/model/team.py @@ -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. diff --git a/endpoints/api/team.py b/endpoints/api/team.py index fada006f2..80c2ad5ef 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -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//team//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//team//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 diff --git a/endpoints/api/test/shared.py b/endpoints/api/test/shared.py index 5b9c2f090..140f6b37e 100644 --- a/endpoints/api/test/shared.py +++ b/endpoints/api/test/shared.py @@ -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: diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index 7d094f44c..dafd9fd0c 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -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), diff --git a/endpoints/api/test/test_team.py b/endpoints/api/test/test_team.py new file mode 100644 index 000000000..1f04ed108 --- /dev/null +++ b/endpoints/api/test/test_team.py @@ -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 diff --git a/static/css/pages/team-view.css b/static/css/pages/team-view.css index 86cb7b6de..dad2fccab 100644 --- a/static/css/pages/team-view.css +++ b/static/css/pages/team-view.css @@ -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 { diff --git a/static/directives/team-view-add.html b/static/directives/team-view-add.html index 88c747d2f..91aa154bb 100644 --- a/static/directives/team-view-add.html +++ b/static/directives/team-view-add.html @@ -1,20 +1,26 @@
Inviting team member
- Search by registry username, robot account name or enter an email address to invite - Search by registry username or robot account name + + Search by registry username, robot account name or enter an email address to invite + Search by registry username or robot account name + + + Search by robot account name. Users must be added in {{ syncInfo.service }}. +
diff --git a/static/js/pages/team-view.js b/static/js/pages/team-view.js index 0215ab080..91135163c 100644 --- a/static/js/pages/team-view.js +++ b/static/js/pages/team-view.js @@ -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', diff --git a/static/partials/team-view.html b/static/partials/team-view.html index 5be2d58c5..c3694ba7a 100644 --- a/static/partials/team-view.html +++ b/static/partials/team-view.html @@ -18,6 +18,39 @@
+
+
Directory Synchronization
+

Directory synchronization allows this team's user membership to be backed by a group in {{ getServiceName(canSync.service) }}.

+ +
+ + +
+
+ This team is synchronized with a group in {{ getServiceName(syncInfo.service) }} and its user membership is therefore read-only. +
+ +
+
Directory Synchronization
+ + + + + + + + + +
Bound to group: +
+ {{ syncInfo.config.group_dn }} +
+
Last Updated: at {{ syncInfo.last_updated | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}
+ + +
+
+
Team Description
@@ -33,12 +66,15 @@
-
Team Members
+
Team Members
This team has no members.
-
- 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. +
+
+ 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.
@@ -46,7 +82,7 @@ - Team Members + Team Members (defined in {{ getServiceName(syncInfo.service) }}) - + Remove {{ member.name }} @@ -122,6 +158,25 @@
+ +
+
Please note that once team syncing is enabled, the team's user membership from within will be read-only.
+
+
+
+ Enter the distinguished name of the group, relative to {{ enableSyncingInfo.service_info.base_dn }}: + +
+
+
+
+ +