Merge pull request #2387 from coreos-inc/team-sync

Team synchronization support in Quay Enterprise
This commit is contained in:
josephschorr 2017-04-03 18:26:29 -04:00 committed by GitHub
commit 1bfca871ec
34 changed files with 1576 additions and 94 deletions

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import datetime
import json
from contextlib import contextmanager
from data import model
from endpoints.api import api

View file

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

View 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']

View file

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