diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index c449bd843..ae43a86fb 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -262,6 +262,55 @@ class OrgPrivateRepositories(ApiResource): raise Unauthorized() +@resource('/v1/organization//collaborators') +@path_param('orgname', 'The name of the organization') +class OrganizationCollaboratorList(ApiResource): + """ Resource for listing outside collaborators of an organization. + + Collaborators are users that do not belong to any team in the + organiztion, but who have direct permissions on one or more + repositories belonging to the organization. + """ + + @require_scope(scopes.ORG_ADMIN) + @nickname('getOrganizationCollaborators') + def get(self, orgname): + """ List outside collaborators of the specified organization. """ + permission = AdministerOrganizationPermission(orgname) + if not permission.can(): + raise Unauthorized() + + try: + org = model.organization.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + all_perms = model.permission.list_organization_member_permissions(org) + membership = model.team.list_organization_members_by_teams(org) + + org_members = set(m.user.username for m in membership) + + collaborators = {} + for perm in all_perms: + username = perm.user.username + + # Only interested in non-member permissions. + if username in org_members: + continue + + if username not in collaborators: + collaborators[username] = { + 'kind': 'user', + 'name': username, + 'avatar': avatar.get_data_for_user(perm.user), + 'repositories': [], + } + + collaborators[username]['repositories'].append(perm.repository.name) + + return {'collaborators': collaborators.values()} + + @resource('/v1/organization//members') @path_param('orgname', 'The name of the organization') class OrganizationMemberList(ApiResource): diff --git a/endpoints/api/test/test_organization.py b/endpoints/api/test/test_organization.py index 9a6525113..4341e1125 100644 --- a/endpoints/api/test/test_organization.py +++ b/endpoints/api/test/test_organization.py @@ -3,10 +3,12 @@ import pytest from data import model from endpoints.api import api from endpoints.api.test.shared import conduct_api_call -from endpoints.api.organization import Organization +from endpoints.api.organization import (Organization, + OrganizationCollaboratorList) from endpoints.test.shared import client_with_identity from test.fixtures import * + @pytest.mark.parametrize('expiration, expected_code', [ (0, 200), (100, 400), @@ -17,3 +19,20 @@ def test_change_tag_expiration(expiration, expected_code, client): conduct_api_call(cl, Organization, 'PUT', {'orgname': 'buynlarge'}, body={'tag_expiration_s': expiration}, expected_code=expected_code) + + +def test_get_organization_collaborators(client): + params = {'orgname': 'buynlarge'} + + with client_with_identity('devtable', client) as cl: + resp = conduct_api_call(cl, OrganizationCollaboratorList, 'GET', params) + + collaborator_names = [c['name'] for c in resp.json['collaborators']] + assert 'outsideorg' in collaborator_names + assert 'devtable' not in collaborator_names + assert 'reader' not in collaborator_names + + for collaborator in resp.json['collaborators']: + if collaborator['name'] == 'outsideorg': + assert 'orgrepo' in collaborator['repositories'] + assert 'anotherorgrepo' not in collaborator['repositories'] diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index 863a78f2e..95d9b3acd 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -4,6 +4,7 @@ import pytest from flask_principal import AnonymousIdentity from endpoints.api import api +from endpoints.api.organization import OrganizationCollaboratorList from endpoints.api.repositorynotification import RepositoryNotification from endpoints.api.permission import RepositoryUserTransitivePermission from endpoints.api.team import OrganizationTeamSyncing @@ -19,6 +20,7 @@ from endpoints.test.shared import client_with_identity, toggle_feature from test.fixtures import * +ORG_PARAMS = {'orgname': 'buynlarge'} TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'} BUILD_PARAMS = {'build_uuid': 'test-1234'} REPO_PARAMS = {'repository': 'devtable/someapp'} @@ -48,6 +50,11 @@ TRIGGER_PARAMS = {'repository': 'devtable/simple', 'trigger_uuid': 'someuuid'} (AppToken, 'DELETE', TOKEN_PARAMS, {}, 'reader', 404), (AppToken, 'DELETE', TOKEN_PARAMS, {}, 'devtable', 404), + (OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, None, 401), + (OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, 'freshuser', 403), + (OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, 'reader', 403), + (OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, 'devtable', 200), + (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403), (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'freshuser', 403), (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'reader', 403),