Skip to content

Commit

Permalink
backend: Syncing teams with OIDC group (PROJQUAY-6290)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sunandadadi committed Feb 14, 2024
1 parent 57c6cc8 commit f6ec276
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 9 deletions.
2 changes: 1 addition & 1 deletion auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def validate_sso_oauth_token(token):
options["verify_signature"] = False

decoded_id_token = service.decode_user_jwt(token, options=options)
sub, lusername, lemail = get_sub_username_email_from_token(
sub, lusername, lemail, additional_info = get_sub_username_email_from_token(
decoded_id_token, None, service.config, False
)

Expand Down
17 changes: 17 additions & 0 deletions data/model/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,3 +569,20 @@ def delete_members_not_present(team, member_id_set):
return query.execute()

return 0


def get_federated_user_teams(user_obj):
"""
Returns an iterator of all the *users* found in a team.
Does not include robots.
"""
query = (
Team.select(Team, TeamSync)
.join(TeamMember)
.switch(Team)
.join(TeamSync)
.join(LoginService)
.where(TeamMember.user == user_obj)
)
return query
80 changes: 80 additions & 0 deletions data/users/externaloidc.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import json
import logging

from data.model import InvalidTeamException, UserAlreadyInTeam, team
from data.users.federated import FederatedUsers

logger = logging.getLogger(__name__)


class OIDCUsers(FederatedUsers):
def __init__(
Expand Down Expand Up @@ -44,3 +50,77 @@ def query_users(self, query, limit):
No way to query users so returning empty list
"""
return ([], self.federated_service, None)

def fetch_org_team_from_oidc_group(self, oidc_group):
"""
OIDC group name is in the format - <org_name>:<group_name>
Extract and return org name and group name
"""
try:
org_name, group_name = oidc_group.split(":")
return org_name, group_name
except ValueError:
logger.exception(
f'Incorrect OIDC group name: {oidc_group}. The expected format is : "<org_name>:<team_name> "'
)

return None, None

def sync_oidc_groups(self, user_groups, user_obj):
"""
Adds user to quay teams that have team sync enabled with an OIDC group
"""
for oidc_group in user_groups:
print("oidc_group is", oidc_group)
org_name, group_name = self.fetch_org_team_from_oidc_group(oidc_group)
if not org_name or not group_name:
continue

# verify if team is in TeamSync table
if not team.get_team_sync_information(org_name, group_name):
logger.debug(f"OIDC group: {oidc_group} is not synced with a team in quay")
continue

# add user to team
try:
team_obj = team.get_organization_team(org_name, group_name)
team.add_user_to_team(user_obj, team_obj)
except InvalidTeamException as err:
logger.exception(err)
except UserAlreadyInTeam:
# Ignore
pass
return

def resync_quay_teams(self, user_groups, user_obj):
"""
Fetch quay teams that user is a member of.
Remove user from teams that are synced with an OIDC group but group does not exist in "user_groups"
"""
# fetch user's quay teams that have team sync enabled
existing_user_teams = team.get_federated_user_teams(user_obj)

for user_team in existing_user_teams:
try:
sync_group_name = json.loads(user_team.teamsync.config)

# remove user's membership from teams that were not returned from users OIDC groups
if (
sync_group_name.get("group_config", None)
and sync_group_name["group_config"] not in user_groups
):
org_name, group_name = self.fetch_org_team_from_oidc_group(
sync_group_name["group_config"]
)
team.remove_user_from_team(org_name, user_team.name, user_obj.username, None)
except Exception as err:
logger.exception(err)
return

def sync_user_groups(self, user_groups, user_obj):
if not user_groups or not user_obj:
return

self.sync_oidc_groups(user_groups, user_obj)
self.resync_quay_teams(user_groups, user_obj)
return
3 changes: 2 additions & 1 deletion endpoints/oauth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def callback_func():
# Exchange the OAuth code for login information.
code = request.values.get("code")
try:
lid, lusername, lemail = login_service.exchange_code_for_login(
lid, lusername, lemail, additional_info = login_service.exchange_code_for_login(
app.config, client, code, ""
)
except OAuthLoginException as ole:
Expand Down Expand Up @@ -149,6 +149,7 @@ def callback_func():
lemail,
metadata=metadata,
captcha_verified=captcha_verified,
additional_login_info=additional_info,
)
if result.requires_verification:
return render_page_template_with_routedata(
Expand Down
5 changes: 3 additions & 2 deletions oauth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix
login or attach their account.
Raises a OAuthLoginService exception on failure. Returns a tuple consisting of (service_id,
service_username, email)
service_username, email, additional_info)
"""

# Retrieve the token for the OAuth code.
Expand Down Expand Up @@ -136,6 +136,7 @@ def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix

service_user_id = self.get_login_service_id(user_info)
service_username = self.get_login_service_username(user_info)
additional_info = {}

logger.debug(
"Completed successful exchange for service %s: %s, %s, %s",
Expand All @@ -144,4 +145,4 @@ def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix
service_username,
email_address,
)
return (service_user_id, service_username, email_address)
return (service_user_id, service_username, email_address, additional_info)
24 changes: 22 additions & 2 deletions oauth/login_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ def get_jwt_issuer(token):
return decoded.get("iss", None)


def get_sub_username_email_from_token(decoded_id_token, user_info=None, config={}, mailing=False):
def get_sub_username_email_from_token(
decoded_id_token, user_info=None, config={}, mailing=False, fetch_groups=False
):
if not user_info:
user_info = decoded_id_token

additional_info = {}
# Verify for impersonation
if user_info.get("impersonated", False):
logger.debug("Requests from impersonated principals are not supported")
Expand Down Expand Up @@ -88,7 +91,10 @@ def get_sub_username_email_from_token(decoded_id_token, user_info=None, config={
if lusername.find("@") >= 0:
lusername = lusername[0 : lusername.find("@")]

return decoded_id_token["sub"], lusername, email_address
if fetch_groups and config.get("PREFERRED_GROUP_CLAIM_NAME"):
additional_info["groups"] = user_info.get(config["PREFERRED_GROUP_CLAIM_NAME"])

return decoded_id_token["sub"], lusername, email_address, additional_info


def _oauthresult(
Expand Down Expand Up @@ -126,6 +132,16 @@ def _attach_service(config, login_service, user_obj, lid, lusername):
return _oauthresult(service_name=login_service.service_name(), error_message=err)


def sync_oidc_groups(additional_login_info, user_obj, auth_system, config):
if (
config.get("AUTHENTICATION_TYPE", "oidc")
and config.get("FEATURE_TEAM_SYNCING", False)
and additional_login_info.get("groups", None)
):
auth_system.sync_user_groups(additional_login_info["groups"], user_obj)
return


def _conduct_oauth_login(
config,
analytics,
Expand All @@ -136,6 +152,7 @@ def _conduct_oauth_login(
lemail,
metadata=None,
captcha_verified=False,
additional_login_info=None,
):
"""
Conducts login from the result of an OAuth service's login flow and returns the status of the
Expand All @@ -148,6 +165,7 @@ def _conduct_oauth_login(
# and redirect.
user_obj = model.user.verify_federated_login(service_id, lid)
if user_obj is not None:
sync_oidc_groups(additional_login_info, user_obj, auth_system, config)
return _oauthresult(user_obj=user_obj, service_name=service_name)

# If the login service has a bound field name, and we have a defined internal auth type that is
Expand Down Expand Up @@ -182,6 +200,7 @@ def _conduct_oauth_login(
if result.error_message is not None:
return result

sync_oidc_groups(additional_login_info, user_obj, auth_system, config)
return _oauthresult(user_obj=user_obj, service_name=service_name)

# Otherwise, we need to create a new user account.
Expand Down Expand Up @@ -220,6 +239,7 @@ def _conduct_oauth_login(

# Success, tell analytics
analytics.track(user_obj.username, "register", {"service": service_name.lower()})
sync_oidc_groups(additional_login_info, user_obj, auth_system, config)
return _oauthresult(user_obj=user_obj, service_name=service_name)

except model.InvalidEmailAddressException:
Expand Down
2 changes: 1 addition & 1 deletion oauth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix
user_info = decoded_id_token

return get_sub_username_email_from_token(
decoded_id_token, user_info, self.config, self._mailing
decoded_id_token, user_info, self.config, self._mailing, fetch_groups=True
)

@property
Expand Down
4 changes: 2 additions & 2 deletions oauth/services/rhsso.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
class RHSSOOAuthService(OIDCLoginService):
def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix):

sub, lusername, email_address = super().exchange_code_for_login(
sub, lusername, email_address, additional_info = super().exchange_code_for_login(
app_config, http_client, code, redirect_suffix
)

Expand Down Expand Up @@ -73,4 +73,4 @@ def exchange_code_for_login(self, app_config, http_client, code, redirect_suffix
exc_info=True,
)

return sub, lusername, email_address
return sub, lusername, email_address, additional_info

0 comments on commit f6ec276

Please sign in to comment.