From f5a854c1898289e311db3ecabefe399612028ff7 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 17 Feb 2017 12:01:41 -0500 Subject: [PATCH] Add TeamSync database and API support Teams can now have a TeamSync entry in the database, indicating how they are synced via an external group. If found, then the user membership of the team cannot be changed via the API. --- data/database.py | 11 +++++++++- data/model/team.py | 31 +++++++++++++++++++++++--- endpoints/api/team.py | 49 +++++++++++++++++++++++++++++++++-------- initdb.py | 3 +++ test/test_api_usage.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/data/database.py b/data/database.py index 6d7b7e3b2..37b89a7af 100644 --- a/data/database.py +++ b/data/database.py @@ -451,7 +451,7 @@ class User(BaseModel): TagManifest, AccessToken, OAuthAccessToken, BlobUpload, RepositoryNotification, OAuthAuthorizationCode, RepositoryActionCount, TagManifestLabel, Tag, - ManifestLabel, BlobUploading} | beta_classes + ManifestLabel, BlobUploading, TeamSync} | beta_classes delete_instance_filtered(self, User, delete_nullable, skip_transitive_deletes) @@ -526,6 +526,15 @@ class LoginService(BaseModel): name = CharField(unique=True, index=True) +class TeamSync(BaseModel): + team = ForeignKeyField(Team) + + transaction_id = CharField() + last_updated = DateTimeField(default=datetime.now, index=True) + service = ForeignKeyField(LoginService) + config = JSONField() + + class FederatedLogin(BaseModel): user = QuayUserField(allows_robots=True, index=True) service = ForeignKeyField(LoginService) diff --git a/data/model/team.py b/data/model/team.py index 753d00e2f..1d176ebdf 100644 --- a/data/model/team.py +++ b/data/model/team.py @@ -1,9 +1,13 @@ -from data.database import Team, TeamMember, TeamRole, User, TeamMemberInvite, RepositoryPermission +import json + +from peewee import fn + +from data.database import (Team, TeamMember, TeamRole, User, TeamMemberInvite, RepositoryPermission, + TeamSync, LoginService) from data.model import (DataModelException, InvalidTeamException, UserAlreadyInTeam, - InvalidTeamMemberException, user, _basequery) + InvalidTeamMemberException, _basequery) from data.text import prefix_search from util.validation import validate_username -from peewee import fn, JOIN_LEFT_OUTER from util.morecollections import AttrDict @@ -369,3 +373,24 @@ def confirm_team_invite(code, user_obj): team = found.team inviter = found.inviter return (team, inviter) + +def set_team_syncing(team, login_service_name, config): + login_service = LoginService.get(name=login_service_name) + TeamSync.create(team=team, transaction_id='', service=login_service, config=json.dumps(config)) + +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. + """ + query = (TeamSync + .select(TeamSync, LoginService) + .join(Team) + .join(User) + .switch(TeamSync) + .join(LoginService) + .where(Team.name == teamname, User.organization == True, User.username == orgname)) + + try: + return query.get() + except TeamSync.DoesNotExist: + return None diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 81b779b58..fada006f2 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -1,19 +1,22 @@ """ Create, list and manage an organization's teams. """ +from functools import wraps + from flask import request import features -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) -from endpoints.exception import Unauthorized, NotFound +from app import avatar, authentication from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission 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) +from endpoints.exception import Unauthorized, NotFound, InvalidRequest from util.useremails import send_org_invite_email -from app import avatar +from util.names import parse_robot_username def permission_view(permission): return { @@ -24,7 +27,6 @@ def permission_view(permission): 'role': permission.role.name } - def try_accept_invite(code, user): (team, inviter) = model.team.confirm_team_invite(code, user) @@ -40,7 +42,6 @@ def try_accept_invite(code, user): return team - def handle_addinvite_team(inviter, team, user=None, email=None): requires_invite = features.MAILING and features.REQUIRE_TEAM_INVITE invite = model.team.add_or_invite_to_team(inviter, team, user, email, @@ -82,7 +83,6 @@ def member_view(member, invited=False): 'invited': invited, } - def invite_view(invite): if invite.user: return member_view(invite.user, invited=True) @@ -94,6 +94,26 @@ def invite_view(invite): 'invited': True } +def disallow_for_synced_team(except_robots=False): + """ Disallows the decorated operation for a team that is marked as being synced from an internal + auth provider such as LDAP. If except_robots is True, then the operation is allowed if the + member specified on the operation is a robot account. + """ + def inner(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + # Team syncing can only be enabled if we have a federated service. + if authentication.federated_service: + orgname = kwargs['orgname'] + teamname = kwargs['teamname'] + if model.team.get_team_sync_information(orgname, teamname): + if not except_robots or not parse_robot_username(kwargs.get('membername', '')): + raise InvalidRequest('Cannot call this method on an auth-synced team') + + return func(self, *args, **kwargs) + return wrapper + return inner + @resource('/v1/organization//team/') @path_param('orgname', 'The name of the organization') @@ -214,6 +234,14 @@ class TeamMemberList(ApiResource): '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, + } + return data raise Unauthorized() @@ -228,6 +256,7 @@ class TeamMember(ApiResource): @require_scope(scopes.ORG_ADMIN) @nickname('updateOrganizationTeamMember') + @disallow_for_synced_team(except_robots=True) def put(self, orgname, teamname, membername): """ Adds or invites a member to an existing team. """ permission = AdministerOrganizationPermission(orgname) @@ -265,6 +294,7 @@ class TeamMember(ApiResource): @require_scope(scopes.ORG_ADMIN) @nickname('deleteOrganizationTeamMember') + @disallow_for_synced_team(except_robots=True) def delete(self, orgname, teamname, membername): """ Delete a member of a team. If the user is merely invited to join the team, then the invite is removed instead. @@ -308,6 +338,7 @@ class InviteTeamMember(ApiResource): """ Resource for inviting a team member via email address. """ @require_scope(scopes.ORG_ADMIN) @nickname('inviteTeamMemberEmail') + @disallow_for_synced_team() def put(self, orgname, teamname, email): """ Invites an email address to an existing team. """ permission = AdministerOrganizationPermission(orgname) @@ -407,7 +438,7 @@ class TeamMemberInvite(ApiResource): @nickname('declineOrganizationTeamInvite') @require_user_admin def delete(self, code): - """ Delete an existing member of a team. """ + """ Delete an existing invitation to join a team. """ (team, inviter) = model.team.delete_team_invite(code, user_obj=get_authenticated_user()) model.notification.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', diff --git a/initdb.py b/initdb.py index 03bb9131c..f8ce513ab 100644 --- a/initdb.py +++ b/initdb.py @@ -700,6 +700,9 @@ def populate_database(minimal=False, with_storage=False): model.team.add_user_to_team(creatorbot, creators) model.team.add_user_to_team(creatoruser, creators) + synced_team = model.team.create_team('synced', org, 'member', 'Some synced team.') + model.team.set_team_syncing(synced_team, 'ldap', {'group_dn': 'cn=Test-Group,ou=Users'}) + __generate_repository(with_storage, new_user_1, 'superwide', None, False, [], [(10, [], 'latest2'), (2, [], 'latest3'), diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 2f2548df5..52dabbaaa 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -7,6 +7,7 @@ import time import re import json as py_json +from mock import patch from StringIO import StringIO from calendar import timegm from contextlib import contextmanager @@ -77,6 +78,7 @@ from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, Su SuperUserCreateInitialSuperUser) from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel from test.test_ssl_util import generate_test_cert +from util.morecollections import AttrDict try: @@ -1592,6 +1594,54 @@ class TestUpdateOrganizationTeamMember(ApiTestCase): self.assertNotEqual(membername, member['name']) + def test_updatemembers_syncedteam(self): + self.login(ADMIN_ACCESS_USER) + + with patch('endpoints.api.team.authentication', AttrDict({'federated_service': 'foobar'})): + # Add the user to a non-synced team, which should succeed. + self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=READ_ACCESS_USER)) + + # Remove the user from the non-synced team, which should succeed. + self.deleteEmptyResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=READ_ACCESS_USER)) + + # Attempt to add the user to a synced team, which should fail. + self.putResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='synced', + membername=READ_ACCESS_USER), + expected_code=400) + + # Attempt to remove the user from the synced team, which should fail. + self.deleteResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='synced', + membername=READ_ACCESS_USER), + expected_code=400) + + # Add a robot to the synced team, which should succeed. + self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='synced', + membername=ORGANIZATION + '+coolrobot')) + + # Remove the robot from the non-synced team, which should succeed. + self.deleteEmptyResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='synced', + membername=ORGANIZATION + '+coolrobot')) + + # Invite a team member to a non-synced team, which should succeed. + self.putJsonResponse(InviteTeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + email='someguy+new@devtable.com')) + + # Attempt to invite a team member to a synced team, which should fail. + self.putResponse(InviteTeamMember, + params=dict(orgname=ORGANIZATION, teamname='synced', + email='someguy+new@devtable.com'), + expected_code=400) + + class TestAcceptTeamMemberInvite(ApiTestCase): def test_accept(self): self.login(ADMIN_ACCESS_USER)