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.
This commit is contained in:
Joseph Schorr 2017-02-17 12:01:41 -05:00
parent d718829f5d
commit f5a854c189
5 changed files with 131 additions and 13 deletions

View file

@ -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)

View file

@ -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

View file

@ -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/<orgname>/team/<teamname>')
@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',

View file

@ -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'),

View file

@ -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)