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, TagManifest, AccessToken, OAuthAccessToken, BlobUpload,
RepositoryNotification, OAuthAuthorizationCode, RepositoryNotification, OAuthAuthorizationCode,
RepositoryActionCount, TagManifestLabel, Tag, RepositoryActionCount, TagManifestLabel, Tag,
ManifestLabel, BlobUploading} | beta_classes ManifestLabel, BlobUploading, TeamSync} | beta_classes
delete_instance_filtered(self, User, delete_nullable, skip_transitive_deletes) delete_instance_filtered(self, User, delete_nullable, skip_transitive_deletes)
@ -526,6 +526,15 @@ class LoginService(BaseModel):
name = CharField(unique=True, index=True) 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): class FederatedLogin(BaseModel):
user = QuayUserField(allows_robots=True, index=True) user = QuayUserField(allows_robots=True, index=True)
service = ForeignKeyField(LoginService) 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, from data.model import (DataModelException, InvalidTeamException, UserAlreadyInTeam,
InvalidTeamMemberException, user, _basequery) InvalidTeamMemberException, _basequery)
from data.text import prefix_search from data.text import prefix_search
from util.validation import validate_username from util.validation import validate_username
from peewee import fn, JOIN_LEFT_OUTER
from util.morecollections import AttrDict from util.morecollections import AttrDict
@ -369,3 +373,24 @@ def confirm_team_invite(code, user_obj):
team = found.team team = found.team
inviter = found.inviter inviter = found.inviter
return (team, 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. """ """ Create, list and manage an organization's teams. """
from functools import wraps
from flask import request from flask import request
import features import features
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, from app import avatar, authentication
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 auth.permissions import AdministerOrganizationPermission, ViewTeamPermission from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from data import model 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 util.useremails import send_org_invite_email
from app import avatar from util.names import parse_robot_username
def permission_view(permission): def permission_view(permission):
return { return {
@ -24,7 +27,6 @@ def permission_view(permission):
'role': permission.role.name 'role': permission.role.name
} }
def try_accept_invite(code, user): def try_accept_invite(code, user):
(team, inviter) = model.team.confirm_team_invite(code, user) (team, inviter) = model.team.confirm_team_invite(code, user)
@ -40,7 +42,6 @@ def try_accept_invite(code, user):
return team return team
def handle_addinvite_team(inviter, team, user=None, email=None): def handle_addinvite_team(inviter, team, user=None, email=None):
requires_invite = features.MAILING and features.REQUIRE_TEAM_INVITE requires_invite = features.MAILING and features.REQUIRE_TEAM_INVITE
invite = model.team.add_or_invite_to_team(inviter, team, user, email, invite = model.team.add_or_invite_to_team(inviter, team, user, email,
@ -82,7 +83,6 @@ def member_view(member, invited=False):
'invited': invited, 'invited': invited,
} }
def invite_view(invite): def invite_view(invite):
if invite.user: if invite.user:
return member_view(invite.user, invited=True) return member_view(invite.user, invited=True)
@ -94,6 +94,26 @@ def invite_view(invite):
'invited': True '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>') @resource('/v1/organization/<orgname>/team/<teamname>')
@path_param('orgname', 'The name of the organization') @path_param('orgname', 'The name of the organization')
@ -214,6 +234,14 @@ class TeamMemberList(ApiResource):
'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,
}
return data return data
raise Unauthorized() raise Unauthorized()
@ -228,6 +256,7 @@ class TeamMember(ApiResource):
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('updateOrganizationTeamMember') @nickname('updateOrganizationTeamMember')
@disallow_for_synced_team(except_robots=True)
def put(self, orgname, teamname, membername): def put(self, orgname, teamname, membername):
""" Adds or invites a member to an existing team. """ """ Adds or invites a member to an existing team. """
permission = AdministerOrganizationPermission(orgname) permission = AdministerOrganizationPermission(orgname)
@ -265,6 +294,7 @@ class TeamMember(ApiResource):
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('deleteOrganizationTeamMember') @nickname('deleteOrganizationTeamMember')
@disallow_for_synced_team(except_robots=True)
def delete(self, orgname, teamname, membername): def delete(self, orgname, teamname, membername):
""" Delete a member of a team. If the user is merely invited to join """ Delete a member of a team. If the user is merely invited to join
the team, then the invite is removed instead. the team, then the invite is removed instead.
@ -308,6 +338,7 @@ class InviteTeamMember(ApiResource):
""" Resource for inviting a team member via email address. """ """ Resource for inviting a team member via email address. """
@require_scope(scopes.ORG_ADMIN) @require_scope(scopes.ORG_ADMIN)
@nickname('inviteTeamMemberEmail') @nickname('inviteTeamMemberEmail')
@disallow_for_synced_team()
def put(self, orgname, teamname, email): def put(self, orgname, teamname, email):
""" Invites an email address to an existing team. """ """ Invites an email address to an existing team. """
permission = AdministerOrganizationPermission(orgname) permission = AdministerOrganizationPermission(orgname)
@ -407,7 +438,7 @@ class TeamMemberInvite(ApiResource):
@nickname('declineOrganizationTeamInvite') @nickname('declineOrganizationTeamInvite')
@require_user_admin @require_user_admin
def delete(self, code): 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()) (team, inviter) = model.team.delete_team_invite(code, user_obj=get_authenticated_user())
model.notification.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', 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(creatorbot, creators)
model.team.add_user_to_team(creatoruser, 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, [], __generate_repository(with_storage, new_user_1, 'superwide', None, False, [],
[(10, [], 'latest2'), [(10, [], 'latest2'),
(2, [], 'latest3'), (2, [], 'latest3'),

View file

@ -7,6 +7,7 @@ import time
import re import re
import json as py_json import json as py_json
from mock import patch
from StringIO import StringIO from StringIO import StringIO
from calendar import timegm from calendar import timegm
from contextlib import contextmanager from contextlib import contextmanager
@ -77,6 +78,7 @@ from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, Su
SuperUserCreateInitialSuperUser) SuperUserCreateInitialSuperUser)
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
from test.test_ssl_util import generate_test_cert from test.test_ssl_util import generate_test_cert
from util.morecollections import AttrDict
try: try:
@ -1592,6 +1594,54 @@ class TestUpdateOrganizationTeamMember(ApiTestCase):
self.assertNotEqual(membername, member['name']) 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): class TestAcceptTeamMemberInvite(ApiTestCase):
def test_accept(self): def test_accept(self):
self.login(ADMIN_ACCESS_USER) self.login(ADMIN_ACCESS_USER)