Merge pull request #2387 from coreos-inc/team-sync
Team synchronization support in Quay Enterprise
This commit is contained in:
commit
1bfca871ec
34 changed files with 1576 additions and 94 deletions
|
@ -6,7 +6,7 @@ from flask import request
|
|||
|
||||
import features
|
||||
|
||||
from app import billing as stripe, avatar, all_queues
|
||||
from app import billing as stripe, avatar, all_queues, authentication
|
||||
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
||||
related_user_resource, internal_only, require_user_admin, log_action,
|
||||
show_if, path_param, require_scope, require_fresh_login)
|
||||
|
@ -33,6 +33,8 @@ def team_view(orgname, team):
|
|||
|
||||
'repo_count': team.repo_count,
|
||||
'member_count': team.member_count,
|
||||
|
||||
'is_synced': team.is_synced,
|
||||
}
|
||||
|
||||
|
||||
|
@ -157,7 +159,8 @@ class Organization(ApiResource):
|
|||
|
||||
teams = None
|
||||
if OrganizationMemberPermission(orgname).can():
|
||||
teams = model.team.get_teams_within_org(org)
|
||||
has_syncing = features.TEAM_SYNCING and bool(authentication.federated_service)
|
||||
teams = model.team.get_teams_within_org(org, has_syncing)
|
||||
|
||||
return org_view(org, teams)
|
||||
|
||||
|
|
|
@ -1,19 +1,27 @@
|
|||
""" Create, list and manage an organization's teams. """
|
||||
|
||||
import json
|
||||
|
||||
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 auth.permissions import AdministerOrganizationPermission, ViewTeamPermission
|
||||
from app import avatar, authentication
|
||||
from auth.permissions import (AdministerOrganizationPermission, ViewTeamPermission,
|
||||
SuperUserPermission)
|
||||
|
||||
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,
|
||||
verify_not_prod, require_fresh_login)
|
||||
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 +32,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 +47,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 +88,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 +99,30 @@ 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 features.TEAM_SYNCING and 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
|
||||
|
||||
|
||||
disallow_nonrobots_for_synced_team = disallow_for_synced_team(except_robots=True)
|
||||
disallow_all_for_synced_team = disallow_for_synced_team(except_robots=False)
|
||||
|
||||
|
||||
@resource('/v1/organization/<orgname>/team/<teamname>')
|
||||
@path_param('orgname', 'The name of the organization')
|
||||
|
@ -180,6 +209,58 @@ class OrganizationTeam(ApiResource):
|
|||
raise Unauthorized()
|
||||
|
||||
|
||||
@resource('/v1/organization/<orgname>/team/<teamname>/syncing')
|
||||
@path_param('orgname', 'The name of the organization')
|
||||
@path_param('teamname', 'The name of the team')
|
||||
@show_if(features.TEAM_SYNCING)
|
||||
class OrganizationTeamSyncing(ApiResource):
|
||||
""" Resource for managing syncing of a team by a backing group. """
|
||||
@require_scope(scopes.ORG_ADMIN)
|
||||
@require_scope(scopes.SUPERUSER)
|
||||
@nickname('enableOrganizationTeamSync')
|
||||
@verify_not_prod
|
||||
@require_fresh_login
|
||||
def post(self, orgname, teamname):
|
||||
# User must be both the org admin AND a superuser.
|
||||
if SuperUserPermission().can() and AdministerOrganizationPermission(orgname).can():
|
||||
try:
|
||||
team = model.team.get_organization_team(orgname, teamname)
|
||||
except model.InvalidTeamException:
|
||||
raise NotFound()
|
||||
|
||||
config = request.get_json()
|
||||
|
||||
# Ensure that the specified config points to a valid group.
|
||||
status, err = authentication.check_group_lookup_args(config)
|
||||
if not status:
|
||||
raise InvalidRequest('Could not sync to group: %s' % err)
|
||||
|
||||
# Set the team's syncing config.
|
||||
model.team.set_team_syncing(team, authentication.federated_service, config)
|
||||
|
||||
return team_view(orgname, team)
|
||||
|
||||
raise Unauthorized()
|
||||
|
||||
@require_scope(scopes.ORG_ADMIN)
|
||||
@require_scope(scopes.SUPERUSER)
|
||||
@nickname('disableOrganizationTeamSync')
|
||||
@verify_not_prod
|
||||
@require_fresh_login
|
||||
def delete(self, orgname, teamname):
|
||||
# User must be both the org admin AND a superuser.
|
||||
if SuperUserPermission().can() and AdministerOrganizationPermission(orgname).can():
|
||||
try:
|
||||
team = model.team.get_organization_team(orgname, teamname)
|
||||
except model.InvalidTeamException:
|
||||
raise NotFound()
|
||||
|
||||
model.team.remove_team_syncing(orgname, teamname)
|
||||
return team_view(orgname, team)
|
||||
|
||||
raise Unauthorized()
|
||||
|
||||
|
||||
@resource('/v1/organization/<orgname>/team/<teamname>/members')
|
||||
@path_param('orgname', 'The name of the organization')
|
||||
@path_param('teamname', 'The name of the team')
|
||||
|
@ -211,9 +292,29 @@ class TeamMemberList(ApiResource):
|
|||
data = {
|
||||
'name': teamname,
|
||||
'members': [member_view(m) for m in members] + [invite_view(i) for i in invites],
|
||||
'can_edit': edit_permission.can()
|
||||
'can_edit': edit_permission.can(),
|
||||
}
|
||||
|
||||
if features.TEAM_SYNCING and authentication.federated_service:
|
||||
if SuperUserPermission().can() and AdministerOrganizationPermission(orgname).can():
|
||||
data['can_sync'] = {
|
||||
'service': authentication.federated_service,
|
||||
}
|
||||
|
||||
data['can_sync'].update(authentication.service_metadata())
|
||||
|
||||
sync_info = model.team.get_team_sync_information(orgname, teamname)
|
||||
if sync_info is not None:
|
||||
data['synced'] = {
|
||||
'service': sync_info.service.name,
|
||||
}
|
||||
|
||||
if SuperUserPermission().can():
|
||||
data['synced'].update({
|
||||
'last_updated': format_date(sync_info.last_updated),
|
||||
'config': json.loads(sync_info.config),
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
raise Unauthorized()
|
||||
|
@ -228,6 +329,7 @@ class TeamMember(ApiResource):
|
|||
|
||||
@require_scope(scopes.ORG_ADMIN)
|
||||
@nickname('updateOrganizationTeamMember')
|
||||
@disallow_nonrobots_for_synced_team
|
||||
def put(self, orgname, teamname, membername):
|
||||
""" Adds or invites a member to an existing team. """
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
|
@ -265,6 +367,7 @@ class TeamMember(ApiResource):
|
|||
|
||||
@require_scope(scopes.ORG_ADMIN)
|
||||
@nickname('deleteOrganizationTeamMember')
|
||||
@disallow_nonrobots_for_synced_team
|
||||
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 +411,7 @@ class InviteTeamMember(ApiResource):
|
|||
""" Resource for inviting a team member via email address. """
|
||||
@require_scope(scopes.ORG_ADMIN)
|
||||
@nickname('inviteTeamMemberEmail')
|
||||
@disallow_all_for_synced_team
|
||||
def put(self, orgname, teamname, email):
|
||||
""" Invites an email address to an existing team. """
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
|
@ -407,7 +511,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',
|
||||
|
|
|
@ -2,7 +2,6 @@ import datetime
|
|||
import json
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from data import model
|
||||
from endpoints.api import api
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import pytest
|
||||
|
||||
from endpoints.api import api
|
||||
from endpoints.api.team import OrganizationTeamSyncing
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
|
||||
from endpoints.api.superuser import SuperUserRepositoryBuildStatus
|
||||
|
@ -9,6 +11,16 @@ TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
|
|||
BUILD_PARAMS = {'build_uuid': 'test-1234'}
|
||||
|
||||
@pytest.mark.parametrize('resource,method,params,body,identity,expected', [
|
||||
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403),
|
||||
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'freshuser', 403),
|
||||
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'reader', 403),
|
||||
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'devtable', 400),
|
||||
|
||||
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, None, 403),
|
||||
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'freshuser', 403),
|
||||
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'reader', 403),
|
||||
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'devtable', 200),
|
||||
|
||||
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, None, 401),
|
||||
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
|
||||
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'reader', 403),
|
||||
|
|
87
endpoints/api/test/test_team.py
Normal file
87
endpoints/api/test/test_team.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
import json
|
||||
|
||||
from mock import patch
|
||||
|
||||
from data import model
|
||||
from endpoints.api import api
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.team import OrganizationTeamSyncing, TeamMemberList
|
||||
from endpoints.api.organization import Organization
|
||||
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||
from test.test_ldap import mock_ldap
|
||||
|
||||
SYNCED_TEAM_PARAMS = {'orgname': 'sellnsmall', 'teamname': 'synced'}
|
||||
UNSYNCED_TEAM_PARAMS = {'orgname': 'sellnsmall', 'teamname': 'owners'}
|
||||
|
||||
def test_team_syncing(client):
|
||||
with mock_ldap() as ldap:
|
||||
with patch('endpoints.api.team.authentication', ldap):
|
||||
with client_with_identity('devtable', client) as cl:
|
||||
config = {
|
||||
'group_dn': 'cn=AwesomeFolk',
|
||||
}
|
||||
|
||||
conduct_api_call(cl, OrganizationTeamSyncing, 'POST', UNSYNCED_TEAM_PARAMS, config)
|
||||
|
||||
# Ensure the team is now synced.
|
||||
sync_info = model.team.get_team_sync_information(UNSYNCED_TEAM_PARAMS['orgname'],
|
||||
UNSYNCED_TEAM_PARAMS['teamname'])
|
||||
assert sync_info is not None
|
||||
assert json.loads(sync_info.config) == config
|
||||
|
||||
# Remove the syncing.
|
||||
conduct_api_call(cl, OrganizationTeamSyncing, 'DELETE', UNSYNCED_TEAM_PARAMS, None)
|
||||
|
||||
# Ensure the team is no longer synced.
|
||||
sync_info = model.team.get_team_sync_information(UNSYNCED_TEAM_PARAMS['orgname'],
|
||||
UNSYNCED_TEAM_PARAMS['teamname'])
|
||||
assert sync_info is None
|
||||
|
||||
|
||||
def test_team_member_sync_info(client):
|
||||
with mock_ldap() as ldap:
|
||||
with patch('endpoints.api.team.authentication', ldap):
|
||||
# Check for an unsynced team, with superuser.
|
||||
with client_with_identity('devtable', client) as cl:
|
||||
resp = conduct_api_call(cl, TeamMemberList, 'GET', UNSYNCED_TEAM_PARAMS)
|
||||
assert 'can_sync' in resp.json
|
||||
assert resp.json['can_sync']['service'] == 'ldap'
|
||||
|
||||
assert 'synced' not in resp.json
|
||||
|
||||
# Check for an unsynced team, with non-superuser.
|
||||
with client_with_identity('randomuser', client) as cl:
|
||||
resp = conduct_api_call(cl, TeamMemberList, 'GET', UNSYNCED_TEAM_PARAMS)
|
||||
assert 'can_sync' not in resp.json
|
||||
assert 'synced' not in resp.json
|
||||
|
||||
# Check for a synced team, with superuser.
|
||||
with client_with_identity('devtable', client) as cl:
|
||||
resp = conduct_api_call(cl, TeamMemberList, 'GET', SYNCED_TEAM_PARAMS)
|
||||
assert 'can_sync' in resp.json
|
||||
assert resp.json['can_sync']['service'] == 'ldap'
|
||||
|
||||
assert 'synced' in resp.json
|
||||
assert 'last_updated' in resp.json['synced']
|
||||
assert 'group_dn' in resp.json['synced']['config']
|
||||
|
||||
# Check for a synced team, with non-superuser.
|
||||
with client_with_identity('randomuser', client) as cl:
|
||||
resp = conduct_api_call(cl, TeamMemberList, 'GET', SYNCED_TEAM_PARAMS)
|
||||
assert 'can_sync' not in resp.json
|
||||
|
||||
assert 'synced' in resp.json
|
||||
assert 'last_updated' not in resp.json['synced']
|
||||
assert 'config' not in resp.json['synced']
|
||||
|
||||
|
||||
def test_organization_teams_sync_bool(client):
|
||||
with mock_ldap() as ldap:
|
||||
with patch('endpoints.api.organization.authentication', ldap):
|
||||
# Ensure synced teams are marked as such in the organization teams list.
|
||||
with client_with_identity('devtable', client) as cl:
|
||||
resp = conduct_api_call(cl, Organization, 'GET', {'orgname': 'sellnsmall'})
|
||||
|
||||
assert not resp.json['teams']['owners']['is_synced']
|
||||
|
||||
assert resp.json['teams']['synced']['is_synced']
|
|
@ -1,11 +1,9 @@
|
|||
import pytest
|
||||
|
||||
from endpoints.oauth.login import _conduct_oauth_login
|
||||
|
||||
from oauth.services.github import GithubOAuthService
|
||||
|
||||
from data import model, database
|
||||
from data.users import get_users_handler, DatabaseUsers
|
||||
from endpoints.oauth.login import _conduct_oauth_login
|
||||
from oauth.services.github import GithubOAuthService
|
||||
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||
from test.test_ldap import mock_ldap
|
||||
|
||||
|
|
Reference in a new issue