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:
parent
d718829f5d
commit
f5a854c189
5 changed files with 131 additions and 13 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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)
|
||||
|
|
Reference in a new issue