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
|
@ -432,3 +432,8 @@ class DefaultConfig(object):
|
||||||
|
|
||||||
# Maximum size allowed for layers in the registry.
|
# Maximum size allowed for layers in the registry.
|
||||||
MAXIMUM_LAYER_SIZE = '20G'
|
MAXIMUM_LAYER_SIZE = '20G'
|
||||||
|
|
||||||
|
# Feature Flag: Whether team syncing from the backing auth is enabled.
|
||||||
|
FEATURE_TEAM_SYNCING = False
|
||||||
|
TEAM_RESYNC_STALE_TIME = '30m'
|
||||||
|
TEAM_SYNC_WORKER_FREQUENCY = 60 # seconds
|
||||||
|
|
|
@ -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, unique=True)
|
||||||
|
|
||||||
|
transaction_id = CharField()
|
||||||
|
last_updated = DateTimeField(null=True, 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)
|
||||||
|
|
39
data/migrations/versions/be8d1c402ce0_add_teamsync_table.py
Normal file
39
data/migrations/versions/be8d1c402ce0_add_teamsync_table.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
"""Add TeamSync table
|
||||||
|
|
||||||
|
Revision ID: be8d1c402ce0
|
||||||
|
Revises: a6c463dfb9fe
|
||||||
|
Create Date: 2017-02-23 13:34:52.356812
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'be8d1c402ce0'
|
||||||
|
down_revision = 'a6c463dfb9fe'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from util.migrate import UTF8LongText
|
||||||
|
|
||||||
|
def upgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('teamsync',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('transaction_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('last_updated', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('service_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('config', UTF8LongText(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['service_id'], ['loginservice.id'], name=op.f('fk_teamsync_service_id_loginservice')),
|
||||||
|
sa.ForeignKeyConstraint(['team_id'], ['team.id'], name=op.f('fk_teamsync_team_id_team')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_teamsync'))
|
||||||
|
)
|
||||||
|
op.create_index('teamsync_last_updated', 'teamsync', ['last_updated'], unique=False)
|
||||||
|
op.create_index('teamsync_service_id', 'teamsync', ['service_id'], unique=False)
|
||||||
|
op.create_index('teamsync_team_id', 'teamsync', ['team_id'], unique=True)
|
||||||
|
### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(tables):
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('teamsync')
|
||||||
|
### end Alembic commands ###
|
|
@ -1,9 +1,15 @@
|
||||||
from data.database import Team, TeamMember, TeamRole, User, TeamMemberInvite, RepositoryPermission
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from peewee import fn
|
||||||
|
|
||||||
|
from data.database import (Team, TeamMember, TeamRole, User, TeamMemberInvite, RepositoryPermission,
|
||||||
|
TeamSync, LoginService, FederatedLogin, db_random_func, db_transaction)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -188,7 +194,7 @@ def get_matching_teams(team_prefix, organization):
|
||||||
return query.limit(10)
|
return query.limit(10)
|
||||||
|
|
||||||
|
|
||||||
def get_teams_within_org(organization):
|
def get_teams_within_org(organization, has_external_auth=False):
|
||||||
""" Returns a AttrDict of team info (id, name, description), its role under the org,
|
""" Returns a AttrDict of team info (id, name, description), its role under the org,
|
||||||
the number of repositories on which it has permission, and the number of members.
|
the number of repositories on which it has permission, and the number of members.
|
||||||
"""
|
"""
|
||||||
|
@ -205,6 +211,8 @@ def get_teams_within_org(organization):
|
||||||
|
|
||||||
'repo_count': 0,
|
'repo_count': 0,
|
||||||
'member_count': 0,
|
'member_count': 0,
|
||||||
|
|
||||||
|
'is_synced': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
teams = {team.id: _team_view(team) for team in query}
|
teams = {team.id: _team_view(team) for team in query}
|
||||||
|
@ -232,6 +240,12 @@ def get_teams_within_org(organization):
|
||||||
for member_tuple in members_tuples:
|
for member_tuple in members_tuples:
|
||||||
teams[member_tuple[0]]['member_count'] = member_tuple[1]
|
teams[member_tuple[0]]['member_count'] = member_tuple[1]
|
||||||
|
|
||||||
|
# Add syncing information.
|
||||||
|
if has_external_auth:
|
||||||
|
sync_query = TeamSync.select(TeamSync.team).where(TeamSync.team << teams.keys())
|
||||||
|
for team_sync in sync_query:
|
||||||
|
teams[team_sync.team_id]['is_synced'] = True
|
||||||
|
|
||||||
return [AttrDict(team_info) for team_info in teams.values()]
|
return [AttrDict(team_info) for team_info in teams.values()]
|
||||||
|
|
||||||
|
|
||||||
|
@ -369,3 +383,121 @@ 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 get_federated_team_member_mapping(team, login_service_name):
|
||||||
|
""" Returns a dict of all federated IDs for all team members in the team whose users are
|
||||||
|
bound to the login service within the given name. The dictionary is from federated service
|
||||||
|
identifier (username) to their Quay User table ID.
|
||||||
|
"""
|
||||||
|
login_service = LoginService.get(name=login_service_name)
|
||||||
|
|
||||||
|
query = (FederatedLogin
|
||||||
|
.select(FederatedLogin.service_ident, User.id)
|
||||||
|
.join(User)
|
||||||
|
.join(TeamMember)
|
||||||
|
.join(Team)
|
||||||
|
.where(Team.id == team, User.robot == False, FederatedLogin.service == login_service))
|
||||||
|
return dict(query.tuples())
|
||||||
|
|
||||||
|
|
||||||
|
def list_team_users(team):
|
||||||
|
""" Returns an iterator of all the *users* found in a team. Does not include robots. """
|
||||||
|
return (User
|
||||||
|
.select()
|
||||||
|
.join(TeamMember)
|
||||||
|
.join(Team)
|
||||||
|
.where(Team.id == team, User.robot == False))
|
||||||
|
|
||||||
|
|
||||||
|
def list_team_robots(team):
|
||||||
|
""" Returns an iterator of all the *robots* found in a team. Does not include users. """
|
||||||
|
return (User
|
||||||
|
.select()
|
||||||
|
.join(TeamMember)
|
||||||
|
.join(Team)
|
||||||
|
.where(Team.id == team, User.robot == True))
|
||||||
|
|
||||||
|
|
||||||
|
def set_team_syncing(team, login_service_name, config):
|
||||||
|
""" Sets the given team to sync to the given service using the given config. """
|
||||||
|
login_service = LoginService.get(name=login_service_name)
|
||||||
|
return TeamSync.create(team=team, transaction_id='', service=login_service,
|
||||||
|
config=json.dumps(config))
|
||||||
|
|
||||||
|
|
||||||
|
def remove_team_syncing(orgname, teamname):
|
||||||
|
""" Removes syncing on the team matching the given organization name and team name. """
|
||||||
|
existing = get_team_sync_information(orgname, teamname)
|
||||||
|
if existing:
|
||||||
|
existing.delete_instance()
|
||||||
|
|
||||||
|
|
||||||
|
def get_stale_team(stale_timespan):
|
||||||
|
""" Returns a team that is setup to sync to an external group, and who has not been synced in
|
||||||
|
now - stale_timespan. Returns None if none found.
|
||||||
|
"""
|
||||||
|
stale_at = datetime.now() - stale_timespan
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidates = (TeamSync
|
||||||
|
.select(TeamSync.id)
|
||||||
|
.where((TeamSync.last_updated <= stale_at) | (TeamSync.last_updated >> None))
|
||||||
|
.limit(500)
|
||||||
|
.alias('candidates'))
|
||||||
|
|
||||||
|
found = (TeamSync
|
||||||
|
.select(candidates.c.id)
|
||||||
|
.from_(candidates)
|
||||||
|
.order_by(db_random_func())
|
||||||
|
.get())
|
||||||
|
|
||||||
|
if found is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
return TeamSync.select(TeamSync, Team).join(Team).where(TeamSync.id == found.id).get()
|
||||||
|
except TeamSync.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def update_sync_status(team_sync_info):
|
||||||
|
""" Attempts to update the transaction ID and last updated time on a TeamSync object. If the
|
||||||
|
transaction ID on the entry in the DB does not match that found on the object, this method
|
||||||
|
returns False, which indicates another caller updated it first.
|
||||||
|
"""
|
||||||
|
new_transaction_id = str(uuid.uuid4())
|
||||||
|
query = (TeamSync
|
||||||
|
.update(transaction_id=new_transaction_id, last_updated=datetime.now())
|
||||||
|
.where(TeamSync.id == team_sync_info.id,
|
||||||
|
TeamSync.transaction_id == team_sync_info.transaction_id))
|
||||||
|
return query.execute() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def delete_members_not_present(team, member_id_set):
|
||||||
|
""" Deletes all members of the given team that are not found in the member ID set. """
|
||||||
|
with db_transaction():
|
||||||
|
user_ids = set([u.id for u in list_team_users(team)])
|
||||||
|
to_delete = list(user_ids - member_id_set)
|
||||||
|
if to_delete:
|
||||||
|
query = TeamMember.delete().where(TeamMember.team == team, TeamMember.user << to_delete)
|
||||||
|
return query.execute()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
|
@ -175,6 +175,12 @@ class UserAuthentication(object):
|
||||||
"""
|
"""
|
||||||
return self.state.link_user(username_or_email)
|
return self.state.link_user(username_or_email)
|
||||||
|
|
||||||
|
def get_and_link_federated_user_info(self, user_info):
|
||||||
|
""" Returns a tuple containing the database user record linked to the given UserInformation
|
||||||
|
pair and any error that occurred when trying to link the user.
|
||||||
|
"""
|
||||||
|
return self.state.get_and_link_federated_user_info(user_info)
|
||||||
|
|
||||||
def confirm_existing_user(self, username, password):
|
def confirm_existing_user(self, username, password):
|
||||||
""" Verifies that the given password matches to the given DB username. Unlike
|
""" Verifies that the given password matches to the given DB username. Unlike
|
||||||
verify_credentials, this call first translates the DB user via the FederatedLogin table
|
verify_credentials, this call first translates the DB user via the FederatedLogin table
|
||||||
|
@ -186,6 +192,28 @@ class UserAuthentication(object):
|
||||||
""" Verifies that the given username and password credentials are valid. """
|
""" Verifies that the given username and password credentials are valid. """
|
||||||
return self.state.verify_credentials(username_or_email, password)
|
return self.state.verify_credentials(username_or_email, password)
|
||||||
|
|
||||||
|
def check_group_lookup_args(self, group_lookup_args):
|
||||||
|
""" Verifies that the given group lookup args point to a valid group. Returns a tuple consisting
|
||||||
|
of a boolean status and an error message (if any).
|
||||||
|
"""
|
||||||
|
return self.state.check_group_lookup_args(group_lookup_args)
|
||||||
|
|
||||||
|
def service_metadata(self):
|
||||||
|
""" Returns a dictionary of extra metadata to present to *superusers* about this auth engine.
|
||||||
|
For example, LDAP returns the base DN so we can display to the user during sync setup.
|
||||||
|
"""
|
||||||
|
return self.state.service_metadata()
|
||||||
|
|
||||||
|
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||||
|
""" Returns a tuple of an iterator over all the members of the group matching the given lookup
|
||||||
|
args dictionary, or the error that occurred if the initial call failed or is unsupported.
|
||||||
|
The format of the lookup args dictionary is specific to the implementation.
|
||||||
|
Each result in the iterator is a tuple of (UserInformation, error_message), and only
|
||||||
|
one will be not-None.
|
||||||
|
"""
|
||||||
|
return self.state.iterate_group_members(group_lookup_args, page_size=page_size,
|
||||||
|
disable_pagination=disable_pagination)
|
||||||
|
|
||||||
def verify_and_link_user(self, username_or_email, password, basic_auth=False):
|
def verify_and_link_user(self, username_or_email, password, basic_auth=False):
|
||||||
""" Verifies that the given username and password credentials are valid and, if so,
|
""" Verifies that the given username and password credentials are valid and, if so,
|
||||||
creates or links the database user to the federated identity. """
|
creates or links the database user to the federated identity. """
|
||||||
|
|
|
@ -24,7 +24,22 @@ class DatabaseUsers(object):
|
||||||
""" Never used since all users being added are already, by definition, in the database. """
|
""" Never used since all users being added are already, by definition, in the database. """
|
||||||
return (None, 'Unsupported for this authentication system')
|
return (None, 'Unsupported for this authentication system')
|
||||||
|
|
||||||
|
def get_and_link_federated_user_info(self, user_info):
|
||||||
|
""" Never used since all users being added are already, by definition, in the database. """
|
||||||
|
return (None, 'Unsupported for this authentication system')
|
||||||
|
|
||||||
def query_users(self, query, limit):
|
def query_users(self, query, limit):
|
||||||
""" No need to implement, as we already query for users directly in the database. """
|
""" No need to implement, as we already query for users directly in the database. """
|
||||||
return (None, '', '')
|
return (None, '', '')
|
||||||
|
|
||||||
|
def check_group_lookup_args(self, group_lookup_args):
|
||||||
|
""" Never used since all groups, by definition, are in the database. """
|
||||||
|
return (False, 'Not supported')
|
||||||
|
|
||||||
|
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||||
|
""" Never used since all groups, by definition, are in the database. """
|
||||||
|
return (None, 'Not supported')
|
||||||
|
|
||||||
|
def service_metadata(self):
|
||||||
|
""" Never used since database has no metadata """
|
||||||
|
return {}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import ldap
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from ldap.controls import SimplePagedResultsControl
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from data.users.federated import FederatedUsers, UserInformation
|
from data.users.federated import FederatedUsers, UserInformation
|
||||||
from util.itertoolrecipes import take
|
from util.itertoolrecipes import take
|
||||||
|
@ -10,6 +12,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DEFAULT_NETWORK_TIMEOUT = 10.0 # seconds
|
_DEFAULT_NETWORK_TIMEOUT = 10.0 # seconds
|
||||||
_DEFAULT_TIMEOUT = 10.0 # seconds
|
_DEFAULT_TIMEOUT = 10.0 # seconds
|
||||||
|
_DEFAULT_PAGE_SIZE = 1000
|
||||||
|
|
||||||
|
|
||||||
class LDAPConnectionBuilder(object):
|
class LDAPConnectionBuilder(object):
|
||||||
|
@ -63,7 +66,7 @@ class LDAPUsers(FederatedUsers):
|
||||||
|
|
||||||
def __init__(self, ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr,
|
def __init__(self, ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr,
|
||||||
allow_tls_fallback=False, secondary_user_rdns=None, requires_email=True,
|
allow_tls_fallback=False, secondary_user_rdns=None, requires_email=True,
|
||||||
timeout=None, network_timeout=None):
|
timeout=None, network_timeout=None, force_no_pagination=False):
|
||||||
super(LDAPUsers, self).__init__('ldap', requires_email)
|
super(LDAPUsers, self).__init__('ldap', requires_email)
|
||||||
|
|
||||||
self._ldap = LDAPConnectionBuilder(ldap_uri, admin_dn, admin_passwd, allow_tls_fallback,
|
self._ldap = LDAPConnectionBuilder(ldap_uri, admin_dn, admin_passwd, allow_tls_fallback,
|
||||||
|
@ -73,6 +76,7 @@ class LDAPUsers(FederatedUsers):
|
||||||
self._email_attr = email_attr
|
self._email_attr = email_attr
|
||||||
self._allow_tls_fallback = allow_tls_fallback
|
self._allow_tls_fallback = allow_tls_fallback
|
||||||
self._requires_email = requires_email
|
self._requires_email = requires_email
|
||||||
|
self._force_no_pagination = force_no_pagination
|
||||||
|
|
||||||
# Note: user_rdn is a list of RDN pieces (for historical reasons), and secondary_user_rds
|
# Note: user_rdn is a list of RDN pieces (for historical reasons), and secondary_user_rds
|
||||||
# is a list of RDN strings.
|
# is a list of RDN strings.
|
||||||
|
@ -84,6 +88,7 @@ class LDAPUsers(FederatedUsers):
|
||||||
|
|
||||||
# Create the set of full DN paths.
|
# Create the set of full DN paths.
|
||||||
self._user_dns = [get_full_rdn(relative_dn) for relative_dn in relative_user_dns]
|
self._user_dns = [get_full_rdn(relative_dn) for relative_dn in relative_user_dns]
|
||||||
|
self._base_dn = ','.join(base_dn)
|
||||||
|
|
||||||
def _get_ldap_referral_dn(self, referral_exception):
|
def _get_ldap_referral_dn(self, referral_exception):
|
||||||
logger.debug('Got referral: %s', referral_exception.args[0])
|
logger.debug('Got referral: %s', referral_exception.args[0])
|
||||||
|
@ -174,7 +179,7 @@ class LDAPUsers(FederatedUsers):
|
||||||
with_mail = [result for result in with_dns if result.attrs.get(self._email_attr)]
|
with_mail = [result for result in with_dns if result.attrs.get(self._email_attr)]
|
||||||
return (with_mail[0] if with_mail else with_dns[0], None)
|
return (with_mail[0] if with_mail else with_dns[0], None)
|
||||||
|
|
||||||
def _credential_for_user(self, response):
|
def _build_user_information(self, response):
|
||||||
if not response.get(self._uid_attr):
|
if not response.get(self._uid_attr):
|
||||||
return (None, 'Missing uid field "%s" in user record' % self._uid_attr)
|
return (None, 'Missing uid field "%s" in user record' % self._uid_attr)
|
||||||
|
|
||||||
|
@ -194,7 +199,7 @@ class LDAPUsers(FederatedUsers):
|
||||||
|
|
||||||
logger.debug('Found user for LDAP username or email %s', username_or_email)
|
logger.debug('Found user for LDAP username or email %s', username_or_email)
|
||||||
_, found_response = found_user
|
_, found_response = found_user
|
||||||
return self._credential_for_user(found_response)
|
return self._build_user_information(found_response)
|
||||||
|
|
||||||
def query_users(self, query, limit=20):
|
def query_users(self, query, limit=20):
|
||||||
""" Queries LDAP for matching users. """
|
""" Queries LDAP for matching users. """
|
||||||
|
@ -208,7 +213,7 @@ class LDAPUsers(FederatedUsers):
|
||||||
|
|
||||||
final_results = []
|
final_results = []
|
||||||
for result in results[0:limit]:
|
for result in results[0:limit]:
|
||||||
credentials, err_msg = self._credential_for_user(result.attrs)
|
credentials, err_msg = self._build_user_information(result.attrs)
|
||||||
if err_msg is not None:
|
if err_msg is not None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -253,4 +258,87 @@ class LDAPUsers(FederatedUsers):
|
||||||
logger.debug('Invalid LDAP credentials')
|
logger.debug('Invalid LDAP credentials')
|
||||||
return (None, 'Invalid password')
|
return (None, 'Invalid password')
|
||||||
|
|
||||||
return self._credential_for_user(found_response)
|
return self._build_user_information(found_response)
|
||||||
|
|
||||||
|
def service_metadata(self):
|
||||||
|
return {
|
||||||
|
'base_dn': self._base_dn,
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_group_lookup_args(self, group_lookup_args, disable_pagination=False):
|
||||||
|
if not group_lookup_args.get('group_dn'):
|
||||||
|
return (False, 'Missing group_dn')
|
||||||
|
|
||||||
|
(it, err) = self.iterate_group_members(group_lookup_args, page_size=1,
|
||||||
|
disable_pagination=disable_pagination)
|
||||||
|
if err is not None:
|
||||||
|
return (False, err)
|
||||||
|
|
||||||
|
if not list(it):
|
||||||
|
return (False, 'Group does not exist or is empty')
|
||||||
|
|
||||||
|
return (True, None)
|
||||||
|
|
||||||
|
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||||
|
try:
|
||||||
|
with self._ldap.get_connection():
|
||||||
|
pass
|
||||||
|
except ldap.INVALID_CREDENTIALS:
|
||||||
|
return (None, 'LDAP Admin dn or password is invalid')
|
||||||
|
|
||||||
|
group_dn = group_lookup_args['group_dn']
|
||||||
|
page_size = page_size or _DEFAULT_PAGE_SIZE
|
||||||
|
return (self._iterate_members(group_dn, page_size, disable_pagination), None)
|
||||||
|
|
||||||
|
def _iterate_members(self, group_dn, page_size, disable_pagination):
|
||||||
|
has_pagination = not(self._force_no_pagination or disable_pagination)
|
||||||
|
with self._ldap.get_connection() as conn:
|
||||||
|
lc = ldap.controls.libldap.SimplePagedResultsControl(criticality=True, size=page_size,
|
||||||
|
cookie='')
|
||||||
|
|
||||||
|
search_flt = '(memberOf=%s,%s)' % (group_dn, self._base_dn)
|
||||||
|
attributes = [self._uid_attr, self._email_attr]
|
||||||
|
|
||||||
|
for user_search_dn in self._user_dns:
|
||||||
|
# Conduct the initial search for users that are a member of the group.
|
||||||
|
if has_pagination:
|
||||||
|
msgid = conn.search_ext(user_search_dn, ldap.SCOPE_SUBTREE, search_flt, serverctrls=[lc],
|
||||||
|
attrlist=attributes)
|
||||||
|
else:
|
||||||
|
msgid = conn.search(user_search_dn, ldap.SCOPE_SUBTREE, search_flt, attrlist=attributes)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if has_pagination:
|
||||||
|
_, rdata, _, serverctrls = conn.result3(msgid)
|
||||||
|
else:
|
||||||
|
_, rdata = conn.result(msgid)
|
||||||
|
|
||||||
|
# Yield any users found.
|
||||||
|
for userdata in rdata:
|
||||||
|
yield self._build_user_information(userdata[1])
|
||||||
|
|
||||||
|
# If pagination is disabled, nothing more to do.
|
||||||
|
if not has_pagination:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Filter down the controls with which the server responded, looking for the paging
|
||||||
|
# control type. If not found, then the server does not support pagination and we already
|
||||||
|
# got all of the results.
|
||||||
|
pctrls = [control for control in serverctrls
|
||||||
|
if control.controlType == ldap.controls.SimplePagedResultsControl.controlType]
|
||||||
|
|
||||||
|
if pctrls:
|
||||||
|
# Server supports pagination. Update the cookie so the next search finds the next page,
|
||||||
|
# then conduct the next search.
|
||||||
|
cookie = lc.cookie = pctrls[0].cookie
|
||||||
|
if cookie:
|
||||||
|
msgid = conn.search_ext(user_search_dn, ldap.SCOPE_SUBTREE, search_flt,
|
||||||
|
serverctrls=[lc], attrlist=attributes)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# No additional results.
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Pagination is not supported.
|
||||||
|
logger.debug('Pagination is not supported for this LDAP server')
|
||||||
|
break
|
||||||
|
|
|
@ -37,7 +37,63 @@ class FederatedUsers(object):
|
||||||
""" If implemented, get_user must be implemented as well. """
|
""" If implemented, get_user must be implemented as well. """
|
||||||
return (None, 'Not supported')
|
return (None, 'Not supported')
|
||||||
|
|
||||||
def _get_federated_user(self, username, email):
|
def link_user(self, username_or_email):
|
||||||
|
(user_info, err_msg) = self.get_user(username_or_email)
|
||||||
|
if user_info is None:
|
||||||
|
return (None, err_msg)
|
||||||
|
|
||||||
|
return self.get_and_link_federated_user_info(user_info)
|
||||||
|
|
||||||
|
def get_and_link_federated_user_info(self, user_info):
|
||||||
|
return self._get_and_link_federated_user_info(user_info.username, user_info.email)
|
||||||
|
|
||||||
|
def verify_and_link_user(self, username_or_email, password):
|
||||||
|
""" Verifies the given credentials and, if valid, creates/links a database user to the
|
||||||
|
associated federated service.
|
||||||
|
"""
|
||||||
|
(credentials, err_msg) = self.verify_credentials(username_or_email, password)
|
||||||
|
if credentials is None:
|
||||||
|
return (None, err_msg)
|
||||||
|
|
||||||
|
return self._get_and_link_federated_user_info(credentials.username, credentials.email)
|
||||||
|
|
||||||
|
def confirm_existing_user(self, username, password):
|
||||||
|
""" Confirms that the given *database* username and service password are valid for the linked
|
||||||
|
service. This method is used when the federated service's username is not known.
|
||||||
|
"""
|
||||||
|
db_user = model.user.get_user(username)
|
||||||
|
if not db_user:
|
||||||
|
return (None, 'Invalid user')
|
||||||
|
|
||||||
|
federated_login = model.user.lookup_federated_login(db_user, self._federated_service)
|
||||||
|
if not federated_login:
|
||||||
|
return (None, 'Invalid user')
|
||||||
|
|
||||||
|
(credentials, err_msg) = self.verify_credentials(federated_login.service_ident, password)
|
||||||
|
if credentials is None:
|
||||||
|
return (None, err_msg)
|
||||||
|
|
||||||
|
return (db_user, None)
|
||||||
|
|
||||||
|
def service_metadata(self):
|
||||||
|
""" Returns a dictionary of extra metadata to present to *superusers* about this auth engine.
|
||||||
|
For example, LDAP returns the base DN so we can display to the user during sync setup.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def check_group_lookup_args(self, group_lookup_args):
|
||||||
|
""" Verifies that the given group lookup args point to a valid group. Returns a tuple consisting
|
||||||
|
of a boolean status and an error message (if any).
|
||||||
|
"""
|
||||||
|
return (False, 'Not supported')
|
||||||
|
|
||||||
|
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||||
|
""" Returns an iterator over all the members of the group matching the given lookup args
|
||||||
|
dictionary. The format of the lookup args dictionary is specific to the implementation.
|
||||||
|
"""
|
||||||
|
return (None, 'Not supported')
|
||||||
|
|
||||||
|
def _get_and_link_federated_user_info(self, username, email):
|
||||||
db_user = model.user.verify_federated_login(self._federated_service, username)
|
db_user = model.user.verify_federated_login(self._federated_service, username)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
# We must create the user in our db
|
# We must create the user in our db
|
||||||
|
@ -58,43 +114,8 @@ class FederatedUsers(object):
|
||||||
prompts=prompts)
|
prompts=prompts)
|
||||||
else:
|
else:
|
||||||
# Update the db attributes from the federated service.
|
# Update the db attributes from the federated service.
|
||||||
if email:
|
if email and db_user.email != email:
|
||||||
db_user.email = email
|
db_user.email = email
|
||||||
db_user.save()
|
db_user.save()
|
||||||
|
|
||||||
return (db_user, None)
|
return (db_user, None)
|
||||||
|
|
||||||
def link_user(self, username_or_email):
|
|
||||||
(credentials, err_msg) = self.get_user(username_or_email)
|
|
||||||
if credentials is None:
|
|
||||||
return (None, err_msg)
|
|
||||||
|
|
||||||
return self._get_federated_user(credentials.username, credentials.email)
|
|
||||||
|
|
||||||
def verify_and_link_user(self, username_or_email, password):
|
|
||||||
""" Verifies the given credentials and, if valid, creates/links a database user to the
|
|
||||||
associated federated service.
|
|
||||||
"""
|
|
||||||
(credentials, err_msg) = self.verify_credentials(username_or_email, password)
|
|
||||||
if credentials is None:
|
|
||||||
return (None, err_msg)
|
|
||||||
|
|
||||||
return self._get_federated_user(credentials.username, credentials.email)
|
|
||||||
|
|
||||||
def confirm_existing_user(self, username, password):
|
|
||||||
""" Confirms that the given *database* username and service password are valid for the linked
|
|
||||||
service. This method is used when the federated service's username is not known.
|
|
||||||
"""
|
|
||||||
db_user = model.user.get_user(username)
|
|
||||||
if not db_user:
|
|
||||||
return (None, 'Invalid user')
|
|
||||||
|
|
||||||
federated_login = model.user.lookup_federated_login(db_user, self._federated_service)
|
|
||||||
if not federated_login:
|
|
||||||
return (None, 'Invalid user')
|
|
||||||
|
|
||||||
(credentials, err_msg) = self.verify_credentials(federated_login.service_ident, password)
|
|
||||||
if credentials is None:
|
|
||||||
return (None, err_msg)
|
|
||||||
|
|
||||||
return (db_user, None)
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ from keystoneclient.v2_0 import client as kclient
|
||||||
from keystoneclient.v3 import client as kv3client
|
from keystoneclient.v3 import client as kv3client
|
||||||
from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure
|
from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure
|
||||||
from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized
|
from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized
|
||||||
|
from keystoneclient.exceptions import NotFound as KeystoneNotFound
|
||||||
from data.users.federated import FederatedUsers, UserInformation
|
from data.users.federated import FederatedUsers, UserInformation
|
||||||
from util.itertoolrecipes import take
|
from util.itertoolrecipes import take
|
||||||
|
|
||||||
|
@ -83,6 +84,11 @@ class KeystoneV3Users(FederatedUsers):
|
||||||
self.debug = os.environ.get('USERS_DEBUG') == '1'
|
self.debug = os.environ.get('USERS_DEBUG') == '1'
|
||||||
self.requires_email = requires_email
|
self.requires_email = requires_email
|
||||||
|
|
||||||
|
def _get_admin_client(self):
|
||||||
|
return kv3client.Client(username=self.admin_username, password=self.admin_password,
|
||||||
|
tenant_name=self.admin_tenant, auth_url=self.auth_url,
|
||||||
|
timeout=self.timeout, debug=self.debug)
|
||||||
|
|
||||||
def verify_credentials(self, username_or_email, password):
|
def verify_credentials(self, username_or_email, password):
|
||||||
try:
|
try:
|
||||||
keystone_client = kv3client.Client(username=username_or_email, password=password,
|
keystone_client = kv3client.Client(username=username_or_email, password=password,
|
||||||
|
@ -116,6 +122,46 @@ class KeystoneV3Users(FederatedUsers):
|
||||||
|
|
||||||
return (user, None)
|
return (user, None)
|
||||||
|
|
||||||
|
def check_group_lookup_args(self, group_lookup_args):
|
||||||
|
if not group_lookup_args.get('group_id'):
|
||||||
|
return (False, 'Missing group_id')
|
||||||
|
|
||||||
|
group_id = group_lookup_args['group_id']
|
||||||
|
return self._check_group(group_id)
|
||||||
|
|
||||||
|
def _check_group(self, group_id):
|
||||||
|
try:
|
||||||
|
return (bool(self._get_admin_client().groups.get(group_id)), None)
|
||||||
|
except KeystoneNotFound:
|
||||||
|
return (False, 'Group not found')
|
||||||
|
except KeystoneAuthorizationFailure as kaf:
|
||||||
|
logger.exception('Keystone auth failure for admin user for group lookup %s', group_id)
|
||||||
|
return (False, kaf.message or 'Invalid admin username or password')
|
||||||
|
except KeystoneUnauthorized as kut:
|
||||||
|
logger.exception('Keystone unauthorized for admin user for group lookup %s', group_id)
|
||||||
|
return (False, kut.message or 'Invalid admin username or password')
|
||||||
|
|
||||||
|
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||||
|
group_id = group_lookup_args['group_id']
|
||||||
|
|
||||||
|
(status, err) = self._check_group(group_id)
|
||||||
|
if not status:
|
||||||
|
return (None, err)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_info_iterator = self._get_admin_client().users.list(group=group_id)
|
||||||
|
def iterator():
|
||||||
|
for user in user_info_iterator:
|
||||||
|
yield (self._user_info(user), None)
|
||||||
|
|
||||||
|
return (iterator(), None)
|
||||||
|
except KeystoneAuthorizationFailure as kaf:
|
||||||
|
logger.exception('Keystone auth failure for admin user for group lookup %s', group_id)
|
||||||
|
return (False, kaf.message or 'Invalid admin username or password')
|
||||||
|
except KeystoneUnauthorized as kut:
|
||||||
|
logger.exception('Keystone unauthorized for admin user for group lookup %s', group_id)
|
||||||
|
return (False, kut.message or 'Invalid admin username or password')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _user_info(user):
|
def _user_info(user):
|
||||||
email = user.email if hasattr(user, 'email') else None
|
email = user.email if hasattr(user, 'email') else None
|
||||||
|
@ -126,10 +172,7 @@ class KeystoneV3Users(FederatedUsers):
|
||||||
return ([], self.federated_service, None)
|
return ([], self.federated_service, None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
keystone_client = kv3client.Client(username=self.admin_username, password=self.admin_password,
|
found_users = list(take(limit, self._get_admin_client().users.list(name=query)))
|
||||||
tenant_name=self.admin_tenant, auth_url=self.auth_url,
|
|
||||||
timeout=self.timeout, debug=self.debug)
|
|
||||||
found_users = list(take(limit, keystone_client.users.list(name=query)))
|
|
||||||
logger.debug('For Keystone query %s found users: %s', query, found_users)
|
logger.debug('For Keystone query %s found users: %s', query, found_users)
|
||||||
if not found_users:
|
if not found_users:
|
||||||
return ([], self.federated_service, None)
|
return ([], self.federated_service, None)
|
||||||
|
|
135
data/users/teamsync.py
Normal file
135
data/users/teamsync.py
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_TEAMS_PER_ITERATION = 500
|
||||||
|
|
||||||
|
|
||||||
|
def sync_teams_to_groups(authentication, stale_cutoff):
|
||||||
|
""" Performs team syncing by looking up any stale team(s) found, and performing the sync
|
||||||
|
operation on them.
|
||||||
|
"""
|
||||||
|
logger.debug('Looking up teams to sync to groups')
|
||||||
|
|
||||||
|
sync_team_tried = set()
|
||||||
|
while len(sync_team_tried) < MAX_TEAMS_PER_ITERATION:
|
||||||
|
# Find a stale team.
|
||||||
|
stale_team_sync = model.team.get_stale_team(stale_cutoff)
|
||||||
|
if not stale_team_sync:
|
||||||
|
logger.debug('No additional stale team found; sleeping')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Make sure we don't try to reprocess a team on this iteration.
|
||||||
|
if stale_team_sync.id in sync_team_tried:
|
||||||
|
break
|
||||||
|
|
||||||
|
sync_team_tried.add(stale_team_sync.id)
|
||||||
|
|
||||||
|
# Sync the team.
|
||||||
|
sync_successful = sync_team(authentication, stale_team_sync)
|
||||||
|
if not sync_successful:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def sync_team(authentication, stale_team_sync):
|
||||||
|
""" Performs synchronization of a team (as referenced by the TeamSync stale_team_sync).
|
||||||
|
Returns True on success and False otherwise.
|
||||||
|
"""
|
||||||
|
sync_config = json.loads(stale_team_sync.config)
|
||||||
|
logger.info('Syncing team `%s` under organization %s via %s (#%s)', stale_team_sync.team.name,
|
||||||
|
stale_team_sync.team.organization.username, sync_config, stale_team_sync.team_id,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
|
||||||
|
|
||||||
|
# Load all the existing members of the team in Quay that are bound to the auth service.
|
||||||
|
existing_users = model.team.get_federated_team_member_mapping(stale_team_sync.team,
|
||||||
|
authentication.federated_service)
|
||||||
|
|
||||||
|
logger.debug('Existing membership of %s for team `%s` under organization %s via %s (#%s)',
|
||||||
|
len(existing_users), stale_team_sync.team.name,
|
||||||
|
stale_team_sync.team.organization.username, sync_config, stale_team_sync.team_id,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
|
||||||
|
'existing_member_count': len(existing_users)})
|
||||||
|
|
||||||
|
# Load all the members of the team from the authenication system.
|
||||||
|
(member_iterator, err) = authentication.iterate_group_members(sync_config)
|
||||||
|
if err is not None:
|
||||||
|
logger.error('Got error when trying to iterate group members with config %s: %s',
|
||||||
|
sync_config, err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Collect all the members currently found in the group, adding them to the team as we go
|
||||||
|
# along.
|
||||||
|
group_membership = set()
|
||||||
|
for (member_info, err) in member_iterator:
|
||||||
|
if err is not None:
|
||||||
|
logger.error('Got error when trying to construct a member: %s', err)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If the member is already in the team, nothing more to do.
|
||||||
|
if member_info.username in existing_users:
|
||||||
|
logger.debug('Member %s already in team `%s` under organization %s via %s (#%s)',
|
||||||
|
member_info.username, stale_team_sync.team.name,
|
||||||
|
stale_team_sync.team.organization.username, sync_config,
|
||||||
|
stale_team_sync.team_id,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
|
||||||
|
'member': member_info.username})
|
||||||
|
|
||||||
|
group_membership.add(existing_users[member_info.username])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Retrieve the Quay user associated with the member info.
|
||||||
|
(quay_user, err) = authentication.get_and_link_federated_user_info(member_info)
|
||||||
|
if err is not None:
|
||||||
|
logger.error('Could not link external user %s to an internal user: %s',
|
||||||
|
member_info.username, err,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
|
||||||
|
'member': member_info.username, 'error': err})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add the user to the membership set.
|
||||||
|
group_membership.add(quay_user.id)
|
||||||
|
|
||||||
|
# Add the user to the team.
|
||||||
|
try:
|
||||||
|
logger.info('Adding member %s to team `%s` under organization %s via %s (#%s)',
|
||||||
|
quay_user.username, stale_team_sync.team.name,
|
||||||
|
stale_team_sync.team.organization.username, sync_config,
|
||||||
|
stale_team_sync.team_id,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config,
|
||||||
|
'member': quay_user.username})
|
||||||
|
|
||||||
|
model.team.add_user_to_team(quay_user, stale_team_sync.team)
|
||||||
|
except model.UserAlreadyInTeam:
|
||||||
|
# If the user is already present, nothing more to do for them.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Update the transaction and last_updated time of the team sync. Only if it matches
|
||||||
|
# the current value will we then perform the deletion step.
|
||||||
|
got_transaction_handle = model.team.update_sync_status(stale_team_sync)
|
||||||
|
if not got_transaction_handle:
|
||||||
|
# Another worker updated this team. Nothing more to do.
|
||||||
|
logger.debug('Another worker synced team `%s` under organization %s via %s (#%s)',
|
||||||
|
stale_team_sync.team.name,
|
||||||
|
stale_team_sync.team.organization.username, sync_config,
|
||||||
|
stale_team_sync.team_id,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Delete any team members not found in the backing auth system.
|
||||||
|
logger.debug('Deleting stale members for team `%s` under organization %s via %s (#%s)',
|
||||||
|
stale_team_sync.team.name, stale_team_sync.team.organization.username,
|
||||||
|
sync_config, stale_team_sync.team_id,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
|
||||||
|
|
||||||
|
deleted = model.team.delete_members_not_present(stale_team_sync.team, group_membership)
|
||||||
|
|
||||||
|
# Done!
|
||||||
|
logger.info('Finishing sync for team `%s` under organization %s via %s (#%s): %s deleted',
|
||||||
|
stale_team_sync.team.name, stale_team_sync.team.organization.username,
|
||||||
|
sync_config, stale_team_sync.team_id, deleted,
|
||||||
|
extra={'team': stale_team_sync.team_id, 'sync_config': sync_config})
|
||||||
|
return True
|
260
data/users/test/test_teamsync.py
Normal file
260
data/users/test/test_teamsync.py
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from data import model, database
|
||||||
|
from data.users.federated import FederatedUsers, UserInformation
|
||||||
|
from data.users.teamsync import sync_team, sync_teams_to_groups
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
from test.test_ldap import mock_ldap
|
||||||
|
from test.test_keystone_auth import fake_keystone
|
||||||
|
from util.names import parse_robot_username
|
||||||
|
|
||||||
|
_FAKE_AUTH = 'fake'
|
||||||
|
|
||||||
|
class FakeUsers(FederatedUsers):
|
||||||
|
def __init__(self, group_members):
|
||||||
|
super(FakeUsers, self).__init__(_FAKE_AUTH, False)
|
||||||
|
self.group_tuples = [(m, None) for m in group_members]
|
||||||
|
|
||||||
|
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||||
|
return (self.group_tuples, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('starting_membership,group_membership,expected_membership', [
|
||||||
|
# Empty team + single member in group => Single member in team.
|
||||||
|
([],
|
||||||
|
[
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
],
|
||||||
|
['someuser']),
|
||||||
|
|
||||||
|
# Team with a Quay user + empty group => empty team.
|
||||||
|
([('someuser', None)],
|
||||||
|
[],
|
||||||
|
[]),
|
||||||
|
|
||||||
|
# Team with an existing external user + user is in the group => no changes.
|
||||||
|
([
|
||||||
|
('someuser', 'someuser'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
],
|
||||||
|
['someuser']),
|
||||||
|
|
||||||
|
# Team with an existing external user (with a different Quay username) + user is in the group.
|
||||||
|
# => no changes
|
||||||
|
([
|
||||||
|
('anotherquayname', 'someuser'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
],
|
||||||
|
['someuser']),
|
||||||
|
|
||||||
|
# Team missing a few members that are in the group => members added.
|
||||||
|
([('someuser', 'someuser')],
|
||||||
|
[
|
||||||
|
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
UserInformation('thirduser', 'thirduser', 'thirduser@devtable.com'),
|
||||||
|
],
|
||||||
|
['anotheruser', 'someuser', 'thirduser']),
|
||||||
|
|
||||||
|
# Team has a few extra members no longer in the group => members removed.
|
||||||
|
([
|
||||||
|
('anotheruser', 'anotheruser'),
|
||||||
|
('someuser', 'someuser'),
|
||||||
|
('thirduser', 'thirduser'),
|
||||||
|
('nontestuser', None),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
UserInformation('thirduser', 'thirduser', 'thirduser@devtable.com'),
|
||||||
|
],
|
||||||
|
['thirduser']),
|
||||||
|
|
||||||
|
# Team has different membership than the group => members added and removed.
|
||||||
|
([
|
||||||
|
('anotheruser', 'anotheruser'),
|
||||||
|
('someuser', 'someuser'),
|
||||||
|
('nontestuser', None),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
|
||||||
|
UserInformation('missinguser', 'missinguser', 'missinguser@devtable.com'),
|
||||||
|
],
|
||||||
|
['anotheruser', 'missinguser']),
|
||||||
|
|
||||||
|
# Team has same membership but some robots => robots remain and no other changes.
|
||||||
|
([
|
||||||
|
('someuser', 'someuser'),
|
||||||
|
('buynlarge+anotherbot', None),
|
||||||
|
('buynlarge+somerobot', None),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
],
|
||||||
|
['someuser', 'buynlarge+somerobot', 'buynlarge+anotherbot']),
|
||||||
|
|
||||||
|
# Team has an extra member and some robots => member removed and robots remain.
|
||||||
|
([
|
||||||
|
('someuser', 'someuser'),
|
||||||
|
('buynlarge+anotherbot', None),
|
||||||
|
('buynlarge+somerobot', None),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
# No members.
|
||||||
|
],
|
||||||
|
['buynlarge+somerobot', 'buynlarge+anotherbot']),
|
||||||
|
|
||||||
|
# Team has a different member and some robots => member changed and robots remain.
|
||||||
|
([
|
||||||
|
('someuser', 'someuser'),
|
||||||
|
('buynlarge+anotherbot', None),
|
||||||
|
('buynlarge+somerobot', None),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
|
||||||
|
],
|
||||||
|
['anotheruser', 'buynlarge+somerobot', 'buynlarge+anotherbot']),
|
||||||
|
|
||||||
|
# Team with an existing external user (with a different Quay username) + user is in the group.
|
||||||
|
# => no changes and robots remain.
|
||||||
|
([
|
||||||
|
('anotherquayname', 'someuser'),
|
||||||
|
('buynlarge+anotherbot', None),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
],
|
||||||
|
['someuser', 'buynlarge+anotherbot']),
|
||||||
|
|
||||||
|
# Team which returns the same member twice, as pagination in some engines (like LDAP) is not
|
||||||
|
# stable.
|
||||||
|
([],
|
||||||
|
[
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
UserInformation('anotheruser', 'anotheruser', 'anotheruser@devtable.com'),
|
||||||
|
UserInformation('someuser', 'someuser', 'someuser@devtable.com'),
|
||||||
|
],
|
||||||
|
['anotheruser', 'someuser']),
|
||||||
|
])
|
||||||
|
def test_syncing(starting_membership, group_membership, expected_membership, app):
|
||||||
|
org = model.organization.get_organization('buynlarge')
|
||||||
|
|
||||||
|
# Necessary for the fake auth entries to be created in FederatedLogin.
|
||||||
|
database.LoginService.create(name=_FAKE_AUTH)
|
||||||
|
|
||||||
|
# Assert the team is empty, so we have a clean slate.
|
||||||
|
sync_team_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
assert len(list(model.team.list_team_users(sync_team_info.team))) == 0
|
||||||
|
|
||||||
|
# Add the existing starting members to the team.
|
||||||
|
for starting_member in starting_membership:
|
||||||
|
(quay_username, fakeauth_username) = starting_member
|
||||||
|
if '+' in quay_username:
|
||||||
|
# Add a robot.
|
||||||
|
(_, shortname) = parse_robot_username(quay_username)
|
||||||
|
robot, _ = model.user.create_robot(shortname, org)
|
||||||
|
model.team.add_user_to_team(robot, sync_team_info.team)
|
||||||
|
else:
|
||||||
|
email = quay_username + '@devtable.com'
|
||||||
|
|
||||||
|
if fakeauth_username is None:
|
||||||
|
quay_user = model.user.create_user_noverify(quay_username, email)
|
||||||
|
else:
|
||||||
|
quay_user = model.user.create_federated_user(quay_username, email, _FAKE_AUTH,
|
||||||
|
fakeauth_username, False)
|
||||||
|
|
||||||
|
model.team.add_user_to_team(quay_user, sync_team_info.team)
|
||||||
|
|
||||||
|
# Call syncing on the team.
|
||||||
|
fake_auth = FakeUsers(group_membership)
|
||||||
|
assert sync_team(fake_auth, sync_team_info)
|
||||||
|
|
||||||
|
# Ensure the last updated time and transaction_id's have changed.
|
||||||
|
updated_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
assert updated_sync_info.last_updated is not None
|
||||||
|
assert updated_sync_info.transaction_id != sync_team_info.transaction_id
|
||||||
|
|
||||||
|
users_expected = set([name for name in expected_membership if '+' not in name])
|
||||||
|
robots_expected = set([name for name in expected_membership if '+' in name])
|
||||||
|
assert len(users_expected) + len(robots_expected) == len(expected_membership)
|
||||||
|
|
||||||
|
# Check that the team's users match those expected.
|
||||||
|
service_user_map = model.team.get_federated_team_member_mapping(sync_team_info.team, _FAKE_AUTH)
|
||||||
|
assert set(service_user_map.keys()) == users_expected
|
||||||
|
|
||||||
|
quay_users = model.team.list_team_users(sync_team_info.team)
|
||||||
|
assert len(quay_users) == len(users_expected)
|
||||||
|
|
||||||
|
for quay_user in quay_users:
|
||||||
|
fakeauth_record = model.user.lookup_federated_login(quay_user, _FAKE_AUTH)
|
||||||
|
assert fakeauth_record is not None
|
||||||
|
assert fakeauth_record.service_ident in users_expected
|
||||||
|
assert service_user_map[fakeauth_record.service_ident] == quay_user.id
|
||||||
|
|
||||||
|
# Check that the team's robots match those expected.
|
||||||
|
robots_found = set([r.username for r in model.team.list_team_robots(sync_team_info.team)])
|
||||||
|
assert robots_expected == robots_found
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_teams_to_groups(app):
|
||||||
|
# Necessary for the fake auth entries to be created in FederatedLogin.
|
||||||
|
database.LoginService.create(name=_FAKE_AUTH)
|
||||||
|
|
||||||
|
# Assert the team has not yet been updated.
|
||||||
|
sync_team_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
assert sync_team_info.last_updated is None
|
||||||
|
|
||||||
|
# Call to sync all teams.
|
||||||
|
fake_auth = FakeUsers([])
|
||||||
|
sync_teams_to_groups(fake_auth, timedelta(seconds=1))
|
||||||
|
|
||||||
|
# Ensure the team was synced.
|
||||||
|
updated_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
assert updated_sync_info.last_updated is not None
|
||||||
|
assert updated_sync_info.transaction_id != sync_team_info.transaction_id
|
||||||
|
|
||||||
|
# Set the stale threshold to a high amount and ensure the team is not resynced.
|
||||||
|
current_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
current_info.last_updated = datetime.now() - timedelta(seconds=2)
|
||||||
|
current_info.save()
|
||||||
|
|
||||||
|
sync_teams_to_groups(fake_auth, timedelta(seconds=120))
|
||||||
|
|
||||||
|
third_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
assert third_sync_info.last_updated == current_info.last_updated
|
||||||
|
assert third_sync_info.transaction_id == updated_sync_info.transaction_id
|
||||||
|
|
||||||
|
# Set the stale threshold to 10 seconds, and ensure the team is resynced, after making it
|
||||||
|
# "updated" 20s ago.
|
||||||
|
current_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
current_info.last_updated = datetime.now() - timedelta(seconds=20)
|
||||||
|
current_info.save()
|
||||||
|
|
||||||
|
sync_teams_to_groups(fake_auth, timedelta(seconds=10))
|
||||||
|
|
||||||
|
fourth_sync_info = model.team.get_team_sync_information('buynlarge', 'synced')
|
||||||
|
assert fourth_sync_info.transaction_id != updated_sync_info.transaction_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('auth_system_builder,config', [
|
||||||
|
(mock_ldap, {'group_dn': 'cn=AwesomeFolk'}),
|
||||||
|
(fake_keystone, {'group_id': 'somegroupid'}),
|
||||||
|
])
|
||||||
|
def test_teamsync_end_to_end(auth_system_builder, config, app):
|
||||||
|
with auth_system_builder() as auth:
|
||||||
|
# Create an new team to sync.
|
||||||
|
org = model.organization.get_organization('buynlarge')
|
||||||
|
new_synced_team = model.team.create_team('synced2', org, 'member', 'Some synced team.')
|
||||||
|
sync_team_info = model.team.set_team_syncing(new_synced_team, auth.federated_service, config)
|
||||||
|
|
||||||
|
# Sync the team.
|
||||||
|
assert sync_team(auth, sync_team_info)
|
||||||
|
|
||||||
|
# Ensure we now have members.
|
||||||
|
msg = 'Auth system: %s' % auth.federated_service
|
||||||
|
sync_team_info = model.team.get_team_sync_information('buynlarge', 'synced2')
|
||||||
|
assert len(list(model.team.list_team_users(sync_team_info.team))) > 0, msg
|
|
@ -6,7 +6,7 @@ from flask import request
|
||||||
|
|
||||||
import features
|
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,
|
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
||||||
related_user_resource, internal_only, require_user_admin, log_action,
|
related_user_resource, internal_only, require_user_admin, log_action,
|
||||||
show_if, path_param, require_scope, require_fresh_login)
|
show_if, path_param, require_scope, require_fresh_login)
|
||||||
|
@ -33,6 +33,8 @@ def team_view(orgname, team):
|
||||||
|
|
||||||
'repo_count': team.repo_count,
|
'repo_count': team.repo_count,
|
||||||
'member_count': team.member_count,
|
'member_count': team.member_count,
|
||||||
|
|
||||||
|
'is_synced': team.is_synced,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -157,7 +159,8 @@ class Organization(ApiResource):
|
||||||
|
|
||||||
teams = None
|
teams = None
|
||||||
if OrganizationMemberPermission(orgname).can():
|
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)
|
return org_view(org, teams)
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,27 @@
|
||||||
""" Create, list and manage an organization's teams. """
|
""" Create, list and manage an organization's teams. """
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
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,
|
from auth.permissions import (AdministerOrganizationPermission, ViewTeamPermission,
|
||||||
truthy_bool, parse_args, require_user_admin, show_if)
|
SuperUserPermission)
|
||||||
from endpoints.exception import Unauthorized, NotFound
|
|
||||||
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,
|
||||||
|
verify_not_prod, require_fresh_login)
|
||||||
|
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 +32,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 +47,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 +88,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 +99,30 @@ 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 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>')
|
@resource('/v1/organization/<orgname>/team/<teamname>')
|
||||||
@path_param('orgname', 'The name of the organization')
|
@path_param('orgname', 'The name of the organization')
|
||||||
|
@ -180,6 +209,58 @@ class OrganizationTeam(ApiResource):
|
||||||
raise Unauthorized()
|
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')
|
@resource('/v1/organization/<orgname>/team/<teamname>/members')
|
||||||
@path_param('orgname', 'The name of the organization')
|
@path_param('orgname', 'The name of the organization')
|
||||||
@path_param('teamname', 'The name of the team')
|
@path_param('teamname', 'The name of the team')
|
||||||
|
@ -211,9 +292,29 @@ class TeamMemberList(ApiResource):
|
||||||
data = {
|
data = {
|
||||||
'name': teamname,
|
'name': teamname,
|
||||||
'members': [member_view(m) for m in members] + [invite_view(i) for i in invites],
|
'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
|
return data
|
||||||
|
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
@ -228,6 +329,7 @@ class TeamMember(ApiResource):
|
||||||
|
|
||||||
@require_scope(scopes.ORG_ADMIN)
|
@require_scope(scopes.ORG_ADMIN)
|
||||||
@nickname('updateOrganizationTeamMember')
|
@nickname('updateOrganizationTeamMember')
|
||||||
|
@disallow_nonrobots_for_synced_team
|
||||||
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 +367,7 @@ class TeamMember(ApiResource):
|
||||||
|
|
||||||
@require_scope(scopes.ORG_ADMIN)
|
@require_scope(scopes.ORG_ADMIN)
|
||||||
@nickname('deleteOrganizationTeamMember')
|
@nickname('deleteOrganizationTeamMember')
|
||||||
|
@disallow_nonrobots_for_synced_team
|
||||||
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 +411,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_all_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 +511,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',
|
||||||
|
|
|
@ -2,7 +2,6 @@ import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.api import api
|
from endpoints.api import api
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import pytest
|
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.test.shared import client_with_identity, conduct_api_call
|
||||||
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
|
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
|
||||||
from endpoints.api.superuser import SuperUserRepositoryBuildStatus
|
from endpoints.api.superuser import SuperUserRepositoryBuildStatus
|
||||||
|
@ -9,6 +11,16 @@ TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
|
||||||
BUILD_PARAMS = {'build_uuid': 'test-1234'}
|
BUILD_PARAMS = {'build_uuid': 'test-1234'}
|
||||||
|
|
||||||
@pytest.mark.parametrize('resource,method,params,body,identity,expected', [
|
@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, None, 401),
|
||||||
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
|
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
|
||||||
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'reader', 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
|
import pytest
|
||||||
|
|
||||||
from endpoints.oauth.login import _conduct_oauth_login
|
|
||||||
|
|
||||||
from oauth.services.github import GithubOAuthService
|
|
||||||
|
|
||||||
from data import model, database
|
from data import model, database
|
||||||
from data.users import get_users_handler, DatabaseUsers
|
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.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
from test.test_ldap import mock_ldap
|
from test.test_ldap import mock_ldap
|
||||||
|
|
||||||
|
|
16
initdb.py
16
initdb.py
|
@ -657,6 +657,9 @@ def populate_database(minimal=False, with_storage=False):
|
||||||
liborg = model.organization.create_organization('library', 'quay+library@devtable.com', new_user_1)
|
liborg = model.organization.create_organization('library', 'quay+library@devtable.com', new_user_1)
|
||||||
liborg.save()
|
liborg.save()
|
||||||
|
|
||||||
|
thirdorg = model.organization.create_organization('sellnsmall', 'quay+sell@devtable.com', new_user_1)
|
||||||
|
thirdorg.save()
|
||||||
|
|
||||||
model.user.create_robot('coolrobot', org)
|
model.user.create_robot('coolrobot', org)
|
||||||
|
|
||||||
oauth_app_1 = model.oauth.create_application(org, 'Some Test App', 'http://localhost:8000',
|
oauth_app_1 = model.oauth.create_application(org, 'Some Test App', 'http://localhost:8000',
|
||||||
|
@ -700,6 +703,19 @@ 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)
|
||||||
|
|
||||||
|
sell_owners = model.team.get_organization_team('sellnsmall', 'owners')
|
||||||
|
sell_owners.description = 'Owners have unfettered access across the entire org.'
|
||||||
|
sell_owners.save()
|
||||||
|
|
||||||
|
model.team.add_user_to_team(new_user_4, sell_owners)
|
||||||
|
|
||||||
|
sync_config = {'group_dn': 'cn=Test-Group,ou=Users', 'group_id': 'somegroupid'}
|
||||||
|
synced_team = model.team.create_team('synced', org, 'member', 'Some synced team.')
|
||||||
|
model.team.set_team_syncing(synced_team, 'ldap', sync_config)
|
||||||
|
|
||||||
|
another_synced_team = model.team.create_team('synced', thirdorg, 'member', 'Some synced team.')
|
||||||
|
model.team.set_team_syncing(another_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'),
|
||||||
|
|
|
@ -1521,7 +1521,7 @@ a:focus {
|
||||||
top: 11px;
|
top: 11px;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
color: #E4C212;
|
color: #FCA657;
|
||||||
}
|
}
|
||||||
|
|
||||||
.co-alert.co-alert-danger {
|
.co-alert.co-alert-danger {
|
||||||
|
@ -1566,6 +1566,14 @@ a:focus {
|
||||||
left: 19px;
|
left: 19px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.co-alert-inline:before {
|
||||||
|
position: relative !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.co-alert-popin-warning {
|
.co-alert-popin-warning {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
@ -1579,6 +1587,14 @@ a:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.co-alert-inline {
|
||||||
|
border: 0px;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: transparent !important;
|
||||||
|
margin: 0px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.co-list-table tr td:first-child {
|
.co-list-table tr td:first-child {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
|
|
|
@ -111,3 +111,7 @@
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teams-manager .fa-refresh {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,18 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-view .team-sync-table {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-view .team-sync-table td {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-view .team-sync-table td:first-child {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.team-view .team-title {
|
.team-view .team-title {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
@ -14,10 +26,10 @@
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-view .team-view-header {
|
.team-view .team-view-header, .team-view .team-sync-header {
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-view .team-view-header button i.fa {
|
.team-view .team-view-header button i.fa {
|
||||||
|
|
|
@ -194,7 +194,7 @@
|
||||||
<span class="config-string-field" binding="mapped.redis.host"
|
<span class="config-string-field" binding="mapped.redis.host"
|
||||||
placeholder="The redis server hostname"
|
placeholder="The redis server hostname"
|
||||||
pattern="{{ HOSTNAME_REGEX }}"
|
pattern="{{ HOSTNAME_REGEX }}"
|
||||||
validator="validateHostname(value)">></span>
|
validator="validateHostname(value)"></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -553,7 +553,7 @@
|
||||||
prevent passwords from being saved as plaintext by the Docker client.
|
prevent passwords from being saved as plaintext by the Docker client.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="config-table">
|
<table class="config-table" style="margin-bottom: 20px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="non-input">Authentication:</td>
|
<td class="non-input">Authentication:</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -565,6 +565,28 @@
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr ng-if="config.AUTHENTICATION_TYPE == 'LDAP' || config.AUTHENTICATION_TYPE == 'Keystone'">
|
||||||
|
<td>Team synchronization:</td>
|
||||||
|
<td>
|
||||||
|
<div class="config-bool-field" binding="config.FEATURE_TEAM_SYNCING">
|
||||||
|
Enable Team Synchronization Support
|
||||||
|
</div>
|
||||||
|
<div class="help-text">
|
||||||
|
If enabled, organization administrators who are also superusers can set teams to have their membership synchronized with a backing group in {{ config.AUTHENTICATION_TYPE }}.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr ng-if="(config.AUTHENTICATION_TYPE == 'LDAP' || config.AUTHENTICATION_TYPE == 'Keystone') && config.FEATURE_TEAM_SYNCING">
|
||||||
|
<td>Resynchronization duration:</td>
|
||||||
|
<td>
|
||||||
|
<span class="config-string-field" binding="config.TEAM_RESYNC_STALE_TIME"
|
||||||
|
pattern="[0-9]+(m|h|d|s)"></span>
|
||||||
|
<div class="help-text">
|
||||||
|
The duration before a team must be re-synchronized. Must be expressed in a duration string form: <code>30m</code>, <code>1h</code>, <code>1d</code>.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Keystone Authentication -->
|
<!-- Keystone Authentication -->
|
||||||
|
@ -758,7 +780,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>Administrator DN Password:</td>
|
<td>Administrator DN Password:</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="co-alert co-alert-warning">
|
<div class="co-alert co-alert-warning" style="margin-bottom: 10px;">
|
||||||
Note: This will be stored in
|
Note: This will be stored in
|
||||||
<strong>plaintext</strong> inside the config.yaml, so setting up a dedicated account or using
|
<strong>plaintext</strong> inside the config.yaml, so setting up a dedicated account or using
|
||||||
<a href="http://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html" ng-safenewtab>a password hash</a> is <strong>highly</strong> recommended.
|
<a href="http://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html" ng-safenewtab>a password hash</a> is <strong>highly</strong> recommended.
|
||||||
|
|
|
@ -1,20 +1,26 @@
|
||||||
<div class="team-view-add-element" focusable-popover-content>
|
<div class="team-view-add-element" focusable-popover-content>
|
||||||
<div class="entity-search"
|
<div class="entity-search"
|
||||||
namespace="orgname" placeholder="allowEmail ? 'Add a registered user, robot or email address to the team' : 'Add a registered user or robot to the team'"
|
namespace="orgname"
|
||||||
|
placeholder="getAddPlaceholder(allowEmail, syncInfo)"
|
||||||
entity-selected="addNewMember(entity)"
|
entity-selected="addNewMember(entity)"
|
||||||
email-selected="inviteEmail(email)"
|
email-selected="inviteEmail(email)"
|
||||||
current-entity="selectedMember"
|
current-entity="selectedMember"
|
||||||
auto-clear="true"
|
auto-clear="true"
|
||||||
allowed-entities="['user', 'robot']"
|
allowed-entities="allowedEntities"
|
||||||
pull-right="true"
|
pull-right="true"
|
||||||
allow-emails="allowEmail"
|
allow-emails="allowEmail && syncInfo"
|
||||||
email-message="Press enter to invite the entered e-mail address to this team"
|
email-message="Press enter to invite the entered e-mail address to this team"
|
||||||
ng-show="!addingMember"></div>
|
ng-show="!addingMember"></div>
|
||||||
<div ng-show="addingMember">
|
<div ng-show="addingMember">
|
||||||
<div class="cor-loader-inline"></div> Inviting team member
|
<div class="cor-loader-inline"></div> Inviting team member
|
||||||
</div>
|
</div>
|
||||||
<div class="help-text" ng-show="!addingMember">
|
<div class="help-text" ng-show="!addingMember">
|
||||||
<span ng-if="allowEmail">Search by registry username, robot account name or enter an email address to invite</span>
|
<span ng-if="!syncInfo">
|
||||||
<span ng-if="!allowEmail">Search by registry username or robot account name</span>
|
<span ng-if="allowEmail">Search by registry username, robot account name or enter an email address to invite</span>
|
||||||
|
<span ng-if="!allowEmail">Search by registry username or robot account name</span>
|
||||||
|
</span>
|
||||||
|
<span ng-if="syncInfo">
|
||||||
|
Search by robot account name. Users must be added in {{ syncInfo.service }}.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -41,6 +41,7 @@
|
||||||
|
|
||||||
<table class="co-table" style="margin-top: 10px;">
|
<table class="co-table" style="margin-top: 10px;">
|
||||||
<thead>
|
<thead>
|
||||||
|
<td class="options-col" ng-if="::Config.AUTHENTICATION_TYPE != 'Database' && Features.TEAM_SYNCING"></td>
|
||||||
<td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
|
<td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
|
||||||
<a ng-click="TableService.orderBy('name', options)">Team Name</a>
|
<a ng-click="TableService.orderBy('name', options)">Team Name</a>
|
||||||
</td>
|
</td>
|
||||||
|
@ -65,6 +66,9 @@
|
||||||
<tr class="co-checkable-row"
|
<tr class="co-checkable-row"
|
||||||
ng-repeat="team in orderedTeams.visibleEntries"
|
ng-repeat="team in orderedTeams.visibleEntries"
|
||||||
bindonce>
|
bindonce>
|
||||||
|
<td class="options-col" ng-if="::Config.AUTHENTICATION_TYPE != 'Database' && Features.TEAM_SYNCING">
|
||||||
|
<i class="fa fa-refresh" ng-if="team.is_synced" data-title="Team is synchronized with a backing group" bs-tooltip></i>
|
||||||
|
</td>
|
||||||
<td style="white-space: nowrap;">
|
<td style="white-space: nowrap;">
|
||||||
<span class="avatar" data="team.avatar" size="24"></span>
|
<span class="avatar" data="team.avatar" size="24"></span>
|
||||||
<span bo-show="team.can_view">
|
<span bo-show="team.can_view">
|
||||||
|
@ -97,7 +101,8 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="role-group" current-role="team.role" pull-left="true"
|
<span class="role-group" current-role="team.role" pull-left="true"
|
||||||
role-changed="setRole(role, team.name)" roles="teamRoles"></span>
|
role-changed="setRole(role, team.name)" roles="teamRoles"
|
||||||
|
read-only="!organization.is_admin"></span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="cor-options-menu" ng-show="organization.is_admin">
|
<span class="cor-options-menu" ng-show="organization.is_admin">
|
||||||
|
|
|
@ -12,8 +12,10 @@ angular.module('quay').directive('teamsManager', function () {
|
||||||
'organization': '=organization',
|
'organization': '=organization',
|
||||||
'isEnabled': '=isEnabled'
|
'isEnabled': '=isEnabled'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, ApiService, $timeout, UserService, TableService, UIService) {
|
controller: function($scope, $element, ApiService, $timeout, UserService, TableService, UIService, Config, Features) {
|
||||||
$scope.TableService = TableService;
|
$scope.TableService = TableService;
|
||||||
|
$scope.Config = Config;
|
||||||
|
$scope.Features = Features;
|
||||||
|
|
||||||
$scope.options = {
|
$scope.options = {
|
||||||
'predicate': 'ordered_team_index',
|
'predicate': 'ordered_team_index',
|
||||||
|
|
|
@ -14,12 +14,14 @@
|
||||||
var teamname = $routeParams.teamname;
|
var teamname = $routeParams.teamname;
|
||||||
var orgname = $routeParams.orgname;
|
var orgname = $routeParams.orgname;
|
||||||
|
|
||||||
|
$scope.context = {};
|
||||||
$scope.orgname = orgname;
|
$scope.orgname = orgname;
|
||||||
$scope.teamname = teamname;
|
$scope.teamname = teamname;
|
||||||
$scope.addingMember = false;
|
$scope.addingMember = false;
|
||||||
$scope.memberMap = null;
|
$scope.memberMap = null;
|
||||||
$scope.allowEmail = Features.MAILING;
|
$scope.allowEmail = Features.MAILING;
|
||||||
$scope.feedback = null;
|
$scope.feedback = null;
|
||||||
|
$scope.allowedEntities = ['user', 'robot'];
|
||||||
|
|
||||||
$rootScope.title = 'Loading...';
|
$rootScope.title = 'Loading...';
|
||||||
|
|
||||||
|
@ -146,6 +148,39 @@
|
||||||
}, ApiService.errorDisplay('Cannot remove team member'));
|
}, ApiService.errorDisplay('Cannot remove team member'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.getServiceName = function(service) {
|
||||||
|
switch (service) {
|
||||||
|
case 'ldap':
|
||||||
|
return 'LDAP';
|
||||||
|
|
||||||
|
case 'keystone':
|
||||||
|
return 'Keystone Auth';
|
||||||
|
|
||||||
|
case 'jwtauthn':
|
||||||
|
return 'External JWT Auth';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return synced.service;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.getAddPlaceholder = function(email, synced) {
|
||||||
|
var kinds = [];
|
||||||
|
|
||||||
|
if (!synced) {
|
||||||
|
kinds.push('registered user');
|
||||||
|
}
|
||||||
|
|
||||||
|
kinds.push('robot');
|
||||||
|
|
||||||
|
if (email && !synced) {
|
||||||
|
kinds.push('email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
kind_string = kinds.join(', ')
|
||||||
|
return 'Add a ' + kind_string + ' to the team';
|
||||||
|
};
|
||||||
|
|
||||||
$scope.updateForDescription = function(content) {
|
$scope.updateForDescription = function(content) {
|
||||||
$scope.organization.teams[teamname].description = content;
|
$scope.organization.teams[teamname].description = content;
|
||||||
|
|
||||||
|
@ -166,6 +201,48 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.showEnableSyncing = function() {
|
||||||
|
$scope.enableSyncingInfo = {
|
||||||
|
'service_info': $scope.canSync,
|
||||||
|
'config': {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.showDisableSyncing = function() {
|
||||||
|
msg = 'Are you sure you want to disable group syncing on this team? ' +
|
||||||
|
'The team will once again become editable.';
|
||||||
|
bootbox.confirm(msg, function(result) {
|
||||||
|
if (result) {
|
||||||
|
$scope.disableSyncing();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.disableSyncing = function() {
|
||||||
|
var params = {
|
||||||
|
'orgname': orgname,
|
||||||
|
'teamname': teamname
|
||||||
|
};
|
||||||
|
|
||||||
|
var errorHandler = ApiService.errorDisplay('Could not disable team syncing');
|
||||||
|
ApiService.disableOrganizationTeamSync(null, params).then(function(resp) {
|
||||||
|
loadMembers();
|
||||||
|
}, errorHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.enableSyncing = function(config, callback) {
|
||||||
|
var params = {
|
||||||
|
'orgname': orgname,
|
||||||
|
'teamname': teamname
|
||||||
|
};
|
||||||
|
|
||||||
|
var errorHandler = ApiService.errorDisplay('Cannot enable team syncing', callback);
|
||||||
|
ApiService.enableOrganizationTeamSync(config, params).then(function(resp) {
|
||||||
|
loadMembers();
|
||||||
|
callback(true);
|
||||||
|
}, errorHandler);
|
||||||
|
};
|
||||||
|
|
||||||
var loadOrganization = function() {
|
var loadOrganization = function() {
|
||||||
$scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) {
|
$scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) {
|
||||||
$scope.organization = org;
|
$scope.organization = org;
|
||||||
|
@ -187,6 +264,9 @@
|
||||||
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
|
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
|
||||||
$scope.members = resp.members;
|
$scope.members = resp.members;
|
||||||
$scope.canEditMembers = resp.can_edit;
|
$scope.canEditMembers = resp.can_edit;
|
||||||
|
$scope.canSync = resp.can_sync;
|
||||||
|
$scope.syncInfo = resp.synced;
|
||||||
|
$scope.allowedEntities = resp.synced ? ['robot'] : ['user', 'robot'];
|
||||||
|
|
||||||
$('.info-icon').popover({
|
$('.info-icon').popover({
|
||||||
'trigger': 'hover',
|
'trigger': 'hover',
|
||||||
|
|
|
@ -18,6 +18,44 @@
|
||||||
<div class="co-main-content-panel">
|
<div class="co-main-content-panel">
|
||||||
<div class="feedback-bar" feedback="feedback"></div>
|
<div class="feedback-bar" feedback="feedback"></div>
|
||||||
|
|
||||||
|
<div class="team-sync-header" ng-if="canSync && !syncInfo">
|
||||||
|
<div class="section-header">Directory Synchronization</div>
|
||||||
|
<p>Directory synchronization allows this team's user membership to be backed by a group in {{ getServiceName(canSync.service) }}.</p>
|
||||||
|
<button class="btn btn-primary" ng-click="showEnableSyncing()">Enable Directory Synchronization</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Header -->
|
||||||
|
<div ng-if="syncInfo">
|
||||||
|
<div class="co-alert co-alert-info">
|
||||||
|
This team is synchronized with a group in <strong>{{ getServiceName(syncInfo.service) }}</strong> and its user membership is therefore <strong>read-only</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="team-sync-header" ng-if="syncInfo.config">
|
||||||
|
<div class="section-header">Directory Synchronization</div>
|
||||||
|
<table class="team-sync-table">
|
||||||
|
<tr>
|
||||||
|
<td>Bound to group:</td>
|
||||||
|
<td>
|
||||||
|
<div ng-if="syncInfo.service == 'ldap'">
|
||||||
|
<code>{{ syncInfo.config.group_dn }}</code>
|
||||||
|
</div>
|
||||||
|
<div ng-if="syncInfo.service == 'keystone'">
|
||||||
|
<code>{{ syncInfo.config.group_id }}</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Last Updated:</td>
|
||||||
|
<td ng-if="syncInfo.last_updated"><span am-time-ago="syncInfo.last_updated"></span> at {{ syncInfo.last_updated | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}</td>
|
||||||
|
<td ng-if="!syncInfo.last_updated" style="color: #aaa;">Never</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button class="btn btn-default" ng-click="showDisableSyncing()" ng-if="canSync">Remove Synchronization</button>
|
||||||
|
<div ng-if="!canSync" class="co-alert co-alert-warning co-alert-inline">You must be an admin of this organization to disable team synchronization</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="section-header">Team Description</div>
|
<div class="section-header">Team Description</div>
|
||||||
<div class="team-view-header">
|
<div class="team-view-header">
|
||||||
|
@ -33,12 +71,15 @@
|
||||||
<div ng-include="'/static/directives/team-view-add.html'" style="max-width: 500px;"></div>
|
<div ng-include="'/static/directives/team-view-add.html'" style="max-width: 500px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-header">Team Members</div>
|
<div class="section-header" style="margin-bottom: 55px;">Team Members</div>
|
||||||
|
|
||||||
<div class="empty" ng-if="!members.length">
|
<div class="empty" ng-if="!members.length">
|
||||||
<div class="empty-primary-msg">This team has no members.</div>
|
<div class="empty-primary-msg">This team has no members.</div>
|
||||||
<div class="empty-secondary-msg">
|
<div class="empty-secondary-msg" ng-if="!syncInfo">
|
||||||
Click the "Add Team Member" button above to add or invite team members.
|
Enter a user or robot above to add or invite to the team.
|
||||||
|
</div>
|
||||||
|
<div class="empty-secondary-msg" ng-if="syncInfo">
|
||||||
|
This team is synchronized with an external group defined in {{ getServiceName(syncInfo.service) }}. To add a user to this team, add them in the backing group. To add a robot account to this team, enter them above.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -46,7 +87,7 @@
|
||||||
<!-- Team Members -->
|
<!-- Team Members -->
|
||||||
<tr class="co-table-header-row"
|
<tr class="co-table-header-row"
|
||||||
ng-if="(members | filter: filterFunction(false, false)).length">
|
ng-if="(members | filter: filterFunction(false, false)).length">
|
||||||
<td colspan="3"><i class="fa fa-user"></i> Team Members</td>
|
<td colspan="3"><i class="fa fa-user"></i> Team Members <span ng-if="syncInfo">(defined in {{ getServiceName(syncInfo.service) }})</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr class="indented-row"
|
<tr class="indented-row"
|
||||||
|
@ -56,7 +97,7 @@
|
||||||
show-avatar="true" avatar-size="24"></span>
|
show-avatar="true" avatar-size="24"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="options-col">
|
<td class="options-col">
|
||||||
<span class="cor-options-menu" ng-if="canEditMembers">
|
<span class="cor-options-menu" ng-if="canEditMembers && !syncInfo">
|
||||||
<span class="cor-option" option-click="removeMember(member.name)">
|
<span class="cor-option" option-click="removeMember(member.name)">
|
||||||
<i class="fa fa-times"></i> Remove {{ member.name }}
|
<i class="fa fa-times"></i> Remove {{ member.name }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -122,6 +163,29 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Directory binding dialog -->
|
||||||
|
<div class="cor-confirm-dialog"
|
||||||
|
dialog-context="enableSyncingInfo"
|
||||||
|
dialog-action="enableSyncing(info.config, callback)"
|
||||||
|
dialog-title="Enable Directory Syncing"
|
||||||
|
dialog-action-title="Enable Group Sync"
|
||||||
|
dialog-form="context.syncform">
|
||||||
|
<div class="co-alert co-alert-warning">Please note that once team syncing is enabled, the team's user membership from within <span class="registry-name"></span> will be read-only.</div>
|
||||||
|
<form name="context.syncform" class="co-single-field-dialog">
|
||||||
|
<div ng-switch on="enableSyncingInfo.service_info.service">
|
||||||
|
<div ng-switch-when="ldap">
|
||||||
|
Enter the distinguished name of the group, relative to <code>{{ enableSyncingInfo.service_info.base_dn }}</code>:
|
||||||
|
<input type="text" class="form-control" placeholder="Group DN" ng-model="enableSyncingInfo.config.group_dn" required>
|
||||||
|
</div>
|
||||||
|
<div ng-switch-when="keystone">
|
||||||
|
Enter the Keystone group ID:
|
||||||
|
<input type="text" class="form-control" placeholder="Group ID" ng-model="enableSyncingInfo.config.group_id" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Modal message dialog -->
|
<!-- Modal message dialog -->
|
||||||
<div class="modal fade" id="cannotChangeTeamModal">
|
<div class="modal fade" id="cannotChangeTeamModal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|
Binary file not shown.
|
@ -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:
|
||||||
|
@ -1005,7 +1007,7 @@ class TestConductSearch(ApiTestCase):
|
||||||
json = self.getJsonResponse(ConductSearch,
|
json = self.getJsonResponse(ConductSearch,
|
||||||
params=dict(query='owners'))
|
params=dict(query='owners'))
|
||||||
|
|
||||||
self.assertEquals(2, len(json['results']))
|
self.assertEquals(3, len(json['results']))
|
||||||
self.assertEquals(json['results'][0]['kind'], 'team')
|
self.assertEquals(json['results'][0]['kind'], 'team')
|
||||||
self.assertEquals(json['results'][0]['name'], 'owners')
|
self.assertEquals(json['results'][0]['name'], 'owners')
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -7,14 +7,14 @@ import requests
|
||||||
from flask import Flask, request, abort, make_response
|
from flask import Flask, request, abort, make_response
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from helpers import liveserver_app
|
from test.helpers import liveserver_app
|
||||||
from data.users.keystone import get_keystone_users
|
from data.users.keystone import get_keystone_users
|
||||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
|
|
||||||
_PORT_NUMBER = 5001
|
_PORT_NUMBER = 5001
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def fake_keystone(version, requires_email=True):
|
def fake_keystone(version=3, requires_email=True):
|
||||||
""" Context manager which instantiates and runs a webserver with a fake Keystone implementation,
|
""" Context manager which instantiates and runs a webserver with a fake Keystone implementation,
|
||||||
until the result is yielded.
|
until the result is yielded.
|
||||||
|
|
||||||
|
@ -47,6 +47,23 @@ def _create_app(requires_email=True):
|
||||||
{'username': 'some.neat.user', 'name': 'Neat User', 'password': 'foobar'},
|
{'username': 'some.neat.user', 'name': 'Neat User', 'password': 'foobar'},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
groups = [
|
||||||
|
{'id': 'somegroupid', 'name': 'somegroup', 'description': 'Hi there!',
|
||||||
|
'members': ['adminuser', 'cool.user']},
|
||||||
|
]
|
||||||
|
|
||||||
|
def _get_user(username):
|
||||||
|
for user in users:
|
||||||
|
if user['username'] == username:
|
||||||
|
user_data = {}
|
||||||
|
user_data['id'] = username
|
||||||
|
user_data['name'] = username
|
||||||
|
if requires_email:
|
||||||
|
user_data['email'] = username + '@example.com'
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
ks_app = Flask('testks')
|
ks_app = Flask('testks')
|
||||||
ks_app.config['SERVER_HOSTNAME'] = 'localhost:%s' % _PORT_NUMBER
|
ks_app.config['SERVER_HOSTNAME'] = 'localhost:%s' % _PORT_NUMBER
|
||||||
if os.environ.get('DEBUG') == 'true':
|
if os.environ.get('DEBUG') == 'true':
|
||||||
|
@ -66,6 +83,35 @@ def _create_app(requires_email=True):
|
||||||
|
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
@ks_app.route('/v3/identity/groups/<groupid>/users', methods=['GET'])
|
||||||
|
def getv3groupmembers(groupid):
|
||||||
|
for group in groups:
|
||||||
|
if group['id'] == groupid:
|
||||||
|
group_data = {
|
||||||
|
"links": {},
|
||||||
|
"users": [_get_user(username) for username in group['members']],
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(group_data)
|
||||||
|
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
@ks_app.route('/v3/identity/groups/<groupid>', methods=['GET'])
|
||||||
|
def getv3group(groupid):
|
||||||
|
for group in groups:
|
||||||
|
if group['id'] == groupid:
|
||||||
|
group_data = {
|
||||||
|
"description": group['description'],
|
||||||
|
"domain_id": "default",
|
||||||
|
"id": groupid,
|
||||||
|
"links": {},
|
||||||
|
"name": group['name'],
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps({'group': group_data})
|
||||||
|
|
||||||
|
abort(404)
|
||||||
|
|
||||||
@ks_app.route('/v3/identity/users/<userid>', methods=['GET'])
|
@ks_app.route('/v3/identity/users/<userid>', methods=['GET'])
|
||||||
def getv3user(userid):
|
def getv3user(userid):
|
||||||
for user in users:
|
for user in users:
|
||||||
|
@ -321,6 +367,32 @@ class KeystoneV3AuthTests(KeystoneAuthTestsMixin, unittest.TestCase):
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
self.assertEquals('cool_user', result.username)
|
self.assertEquals('cool_user', result.username)
|
||||||
|
|
||||||
|
def test_check_group_lookup_args(self):
|
||||||
|
with self.fake_keystone() as keystone:
|
||||||
|
(status, err) = keystone.check_group_lookup_args({})
|
||||||
|
self.assertFalse(status)
|
||||||
|
self.assertEquals('Missing group_id', err)
|
||||||
|
|
||||||
|
(status, err) = keystone.check_group_lookup_args({'group_id': 'unknownid'})
|
||||||
|
self.assertFalse(status)
|
||||||
|
self.assertEquals('Group not found', err)
|
||||||
|
|
||||||
|
(status, err) = keystone.check_group_lookup_args({'group_id': 'somegroupid'})
|
||||||
|
self.assertTrue(status)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
|
||||||
|
def test_iterate_group_members(self):
|
||||||
|
with self.fake_keystone() as keystone:
|
||||||
|
(itt, err) = keystone.iterate_group_members({'group_id': 'somegroupid'})
|
||||||
|
self.assertIsNone(err)
|
||||||
|
|
||||||
|
results = list(itt)
|
||||||
|
results.sort()
|
||||||
|
|
||||||
|
self.assertEquals(2, len(results))
|
||||||
|
self.assertEquals('adminuser', results[0][0].id)
|
||||||
|
self.assertEquals('cool.user', results[1][0].id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
import ldap
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
from data.users import LDAPUsers
|
from data.users import LDAPUsers
|
||||||
|
@ -34,18 +36,25 @@ def mock_ldap(requires_email=True):
|
||||||
'dc': ['quay', 'io'],
|
'dc': ['quay', 'io'],
|
||||||
'ou': 'otheremployees'
|
'ou': 'otheremployees'
|
||||||
},
|
},
|
||||||
|
'cn=AwesomeFolk,dc=quay,dc=io': {
|
||||||
|
'dc': ['quay', 'io'],
|
||||||
|
'cn': 'AwesomeFolk'
|
||||||
|
},
|
||||||
'uid=testy,ou=employees,dc=quay,dc=io': {
|
'uid=testy,ou=employees,dc=quay,dc=io': {
|
||||||
'dc': ['quay', 'io'],
|
'dc': ['quay', 'io'],
|
||||||
'ou': 'employees',
|
'ou': 'employees',
|
||||||
'uid': 'testy',
|
'uid': ['testy'],
|
||||||
'userPassword': ['password']
|
'userPassword': ['password'],
|
||||||
|
'mail': ['bar@baz.com'],
|
||||||
|
'memberOf': ['cn=AwesomeFolk,dc=quay,dc=io'],
|
||||||
},
|
},
|
||||||
'uid=someuser,ou=employees,dc=quay,dc=io': {
|
'uid=someuser,ou=employees,dc=quay,dc=io': {
|
||||||
'dc': ['quay', 'io'],
|
'dc': ['quay', 'io'],
|
||||||
'ou': 'employees',
|
'ou': 'employees',
|
||||||
'uid': ['someuser'],
|
'uid': ['someuser'],
|
||||||
'userPassword': ['somepass'],
|
'userPassword': ['somepass'],
|
||||||
'mail': ['foo@bar.com']
|
'mail': ['foo@bar.com'],
|
||||||
|
'memberOf': ['cn=AwesomeFolk,dc=quay,dc=io'],
|
||||||
},
|
},
|
||||||
'uid=nomail,ou=employees,dc=quay,dc=io': {
|
'uid=nomail,ou=employees,dc=quay,dc=io': {
|
||||||
'dc': ['quay', 'io'],
|
'dc': ['quay', 'io'],
|
||||||
|
@ -116,6 +125,45 @@ def mock_ldap(requires_email=True):
|
||||||
obj.search_s.seed('ou=employees,dc=quay,dc=io', 2, '(|(uid=unknown*)(mail=unknown*))')([])
|
obj.search_s.seed('ou=employees,dc=quay,dc=io', 2, '(|(uid=unknown*)(mail=unknown*))')([])
|
||||||
obj.search_s.seed('ou=otheremployees,dc=quay,dc=io', 2,
|
obj.search_s.seed('ou=otheremployees,dc=quay,dc=io', 2,
|
||||||
'(|(uid=unknown*)(mail=unknown*))')([])
|
'(|(uid=unknown*)(mail=unknown*))')([])
|
||||||
|
|
||||||
|
obj._results = {}
|
||||||
|
|
||||||
|
def result3(messageid):
|
||||||
|
if messageid is None:
|
||||||
|
return None, [], None, None
|
||||||
|
|
||||||
|
return obj._results[messageid]
|
||||||
|
|
||||||
|
def search_ext(user_search_dn, scope, search_flt, serverctrls=None, attrlist=None):
|
||||||
|
if scope != ldap.SCOPE_SUBTREE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not serverctrls:
|
||||||
|
return None
|
||||||
|
|
||||||
|
page_control = serverctrls[0]
|
||||||
|
if page_control.controlType != ldap.controls.SimplePagedResultsControl.controlType:
|
||||||
|
return None
|
||||||
|
|
||||||
|
msgid = obj.search(user_search_dn, scope, search_flt, attrlist=attrlist)
|
||||||
|
_, rdata = obj.result(msgid)
|
||||||
|
|
||||||
|
msgid = 'messageid'
|
||||||
|
cookie = int(page_control.cookie) if page_control.cookie else 0
|
||||||
|
|
||||||
|
results = rdata[cookie:cookie+page_control.size]
|
||||||
|
cookie = cookie + page_control.size
|
||||||
|
if cookie > len(results):
|
||||||
|
page_control.cookie = None
|
||||||
|
else:
|
||||||
|
page_control.cookie = cookie
|
||||||
|
|
||||||
|
obj._results['messageid'] = (None, results, None, [page_control])
|
||||||
|
return msgid
|
||||||
|
|
||||||
|
obj.search_ext = search_ext
|
||||||
|
obj.result3 = result3
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
mockldap.start()
|
mockldap.start()
|
||||||
|
@ -301,6 +349,71 @@ class TestLDAP(unittest.TestCase):
|
||||||
requires_email=False, timeout=5)
|
requires_email=False, timeout=5)
|
||||||
ldap.query_users('cool')
|
ldap.query_users('cool')
|
||||||
|
|
||||||
|
def test_iterate_group_members(self):
|
||||||
|
with mock_ldap() as ldap:
|
||||||
|
(it, err) = ldap.iterate_group_members({'group_dn': 'cn=AwesomeFolk'},
|
||||||
|
disable_pagination=True)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
|
||||||
|
results = list(it)
|
||||||
|
self.assertEquals(2, len(results))
|
||||||
|
|
||||||
|
first = results[0][0]
|
||||||
|
second = results[1][0]
|
||||||
|
|
||||||
|
if first.id == 'testy':
|
||||||
|
testy, someuser = first, second
|
||||||
|
else:
|
||||||
|
testy, someuser = second, first
|
||||||
|
|
||||||
|
self.assertEquals('testy', testy.id)
|
||||||
|
self.assertEquals('testy', testy.username)
|
||||||
|
self.assertEquals('bar@baz.com', testy.email)
|
||||||
|
|
||||||
|
self.assertEquals('someuser', someuser.id)
|
||||||
|
self.assertEquals('someuser', someuser.username)
|
||||||
|
self.assertEquals('foo@bar.com', someuser.email)
|
||||||
|
|
||||||
|
def test_iterate_group_members_with_pagination(self):
|
||||||
|
with mock_ldap() as ldap:
|
||||||
|
(it, err) = ldap.iterate_group_members({'group_dn': 'cn=AwesomeFolk'}, page_size=1)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
|
||||||
|
results = list(it)
|
||||||
|
self.assertEquals(2, len(results))
|
||||||
|
|
||||||
|
first = results[0][0]
|
||||||
|
second = results[1][0]
|
||||||
|
|
||||||
|
if first.id == 'testy':
|
||||||
|
testy, someuser = first, second
|
||||||
|
else:
|
||||||
|
testy, someuser = second, first
|
||||||
|
|
||||||
|
self.assertEquals('testy', testy.id)
|
||||||
|
self.assertEquals('testy', testy.username)
|
||||||
|
self.assertEquals('bar@baz.com', testy.email)
|
||||||
|
|
||||||
|
self.assertEquals('someuser', someuser.id)
|
||||||
|
self.assertEquals('someuser', someuser.username)
|
||||||
|
self.assertEquals('foo@bar.com', someuser.email)
|
||||||
|
|
||||||
|
def test_check_group_lookup_args(self):
|
||||||
|
with mock_ldap() as ldap:
|
||||||
|
(result, err) = ldap.check_group_lookup_args({'group_dn': 'cn=invalid'},
|
||||||
|
disable_pagination=True)
|
||||||
|
self.assertFalse(result)
|
||||||
|
self.assertIsNotNone(err)
|
||||||
|
|
||||||
|
(result, err) = ldap.check_group_lookup_args({'group_dn': 'cn=AwesomeFolk'},
|
||||||
|
disable_pagination=True)
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
|
||||||
|
def test_metadata(self):
|
||||||
|
with mock_ldap() as ldap:
|
||||||
|
assert 'base_dn' in ldap.service_metadata()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
|
@ -94,3 +94,4 @@ class TestConfig(DefaultConfig):
|
||||||
RECAPTCHA_SECRET_KEY = 'somesecretkey'
|
RECAPTCHA_SECRET_KEY = 'somesecretkey'
|
||||||
|
|
||||||
FEATURE_APP_REGISTRY = True
|
FEATURE_APP_REGISTRY = True
|
||||||
|
FEATURE_TEAM_SYNCING = True
|
||||||
|
|
|
@ -76,3 +76,4 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname):
|
||||||
config_obj['PREFERRED_URL_SCHEME'] = config_obj.get('PREFERRED_URL_SCHEME', 'http')
|
config_obj['PREFERRED_URL_SCHEME'] = config_obj.get('PREFERRED_URL_SCHEME', 'http')
|
||||||
config_obj['ENTERPRISE_LOGO_URL'] = config_obj.get(
|
config_obj['ENTERPRISE_LOGO_URL'] = config_obj.get(
|
||||||
'ENTERPRISE_LOGO_URL', '/static/img/quay-logo.png')
|
'ENTERPRISE_LOGO_URL', '/static/img/quay-logo.png')
|
||||||
|
config_obj['TEAM_RESYNC_STALE_TIME'] = '60m'
|
||||||
|
|
40
workers/teamsyncworker.py
Normal file
40
workers/teamsyncworker.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import features
|
||||||
|
|
||||||
|
from app import app, authentication
|
||||||
|
from data.users.teamsync import sync_teams_to_groups
|
||||||
|
from workers.worker import Worker
|
||||||
|
from util.timedeltastring import convert_to_timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WORKER_FREQUENCY = app.config.get('TEAM_SYNC_WORKER_FREQUENCY', 60)
|
||||||
|
STALE_CUTOFF = convert_to_timedelta(app.config.get('TEAM_RESYNC_STALE_TIME', '30m'))
|
||||||
|
|
||||||
|
class TeamSynchronizationWorker(Worker):
|
||||||
|
""" Worker which synchronizes teams with their backing groups in LDAP/Keystone/etc.
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
super(TeamSynchronizationWorker, self).__init__()
|
||||||
|
self.add_operation(self._sync_teams_to_groups, WORKER_FREQUENCY)
|
||||||
|
|
||||||
|
def _sync_teams_to_groups(self):
|
||||||
|
sync_teams_to_groups(authentication, STALE_CUTOFF)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False)
|
||||||
|
|
||||||
|
if not features.TEAM_SYNCING or not authentication.federated_service:
|
||||||
|
logger.debug('Team syncing is disabled; sleeping')
|
||||||
|
while True:
|
||||||
|
time.sleep(100000)
|
||||||
|
|
||||||
|
worker = TeamSynchronizationWorker()
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Reference in a new issue