Skip to content

Commit

Permalink
backend: Syncing teams with OIDC group (PROJQUAY-6290) (quay#2693)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sunandadadi authored Feb 27, 2024
1 parent 5e3381a commit 74fd23d
Show file tree
Hide file tree
Showing 16 changed files with 471 additions and 67 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""insert oidc in loginservice
Revision ID: 135fd3e94615
Revises: b4da5b09c8df
Create Date: 2024-02-21 09:48:54.463860
"""

# revision identifiers, used by Alembic.
revision = "135fd3e94615"
down_revision = "b4da5b09c8df"

import sqlalchemy as sa


def upgrade(op, tables, tester):
op.bulk_insert(
tables.loginservice,
[
{"name": "oidc"},
],
)


def downgrade(op, tables, tester):
op.execute(
tables.loginservice.delete().where(tables.loginservice.name == op.inline_literal("oidc"))
)
46 changes: 46 additions & 0 deletions data/model/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,3 +569,49 @@ def delete_members_not_present(team, member_id_set):
return query.execute()

return 0


def get_federated_user_teams(user_obj, login_service_name):
"""
Return all user's teams that are synced with the login service
"""
query = (
Team.select(Team, TeamSync)
.join(TeamMember)
.switch(Team)
.join(TeamSync)
.join(LoginService)
.where(TeamMember.user == user_obj, LoginService.name == login_service_name)
)
return query


def delete_all_team_members(team):
"""
Delete all users that are a member of the given team
"""
with db_transaction():
user_ids = set([u.id for u in list_team_users(team)])
if user_ids:
query = TeamMember.delete().where(TeamMember.team == team, TeamMember.user << user_ids)
return query.execute()

return 0


def get_oidc_team_from_groupname(group_name, login_service_name):
"""
Fetch TeamSync row synced with login_service_name from `group_name` in TeamSync.config
"""
response = []
with db_transaction():
query_result = (
TeamSync.select()
.join(LoginService)
.where(TeamSync.config.contains(group_name), LoginService.name == login_service_name)
)
for row in query_result:
if json.loads(row.config).get("group_name", None) == group_name:
response.append(row)

return response
55 changes: 53 additions & 2 deletions data/model/test/test_team.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from test.fixtures import *

import pytest

from data.database import TeamMember
from data.model import DataModelException
from data.model.organization import create_organization
from data.model.team import (
Expand All @@ -10,12 +9,16 @@
add_user_to_team,
confirm_team_invite,
create_team,
delete_all_team_members,
get_federated_user_teams,
list_team_users,
remove_team,
remove_user_from_team,
set_team_syncing,
validate_team_name,
)
from data.model.user import create_user_noverify, get_user
from test.fixtures import *


@pytest.mark.parametrize(
Expand Down Expand Up @@ -115,3 +118,51 @@ def test_remove_user_from_team(initialized_db):

# Another admin should be able to
remove_user_from_team("testorg", "testteam", "randomuser", "devtable")


def test_delete_all_team_members(initialized_db):
dev_user = get_user("devtable")
random_user = get_user("randomuser")
public_user = get_user("public")
fresh_user = get_user("freshuser")
reader_user = get_user("reader")

new_org = create_organization("testorg", "testorg" + "@example.com", dev_user)

team_1 = create_team("team_1", new_org, "member")
assert add_user_to_team(dev_user, team_1)
assert add_user_to_team(random_user, team_1)
assert add_user_to_team(public_user, team_1)
assert add_user_to_team(fresh_user, team_1)
assert add_user_to_team(reader_user, team_1)

before_deletion_count = TeamMember.select().where(TeamMember.team == team_1).count()
assert before_deletion_count == 5
delete_all_team_members(team_1)

after_deletion_count = TeamMember.select().where(TeamMember.team == team_1).count()
assert after_deletion_count == 0


@pytest.mark.parametrize("login_service_name", ["oidc", "ldap"])
def test_get_federated_user_teams(login_service_name, initialized_db):
dev_user = get_user("devtable")
new_org = create_organization("testorg", "testorg" + "@example.com", dev_user)

team_1 = create_team("team_1", new_org, "member")
assert add_user_to_team(dev_user, team_1)
assert set_team_syncing(team_1, "oidc", None)

team_2 = create_team("team_2", new_org, "member")
assert add_user_to_team(dev_user, team_2)
assert set_team_syncing(team_2, "oidc", None)

team_3 = create_team("team_3", new_org, "member")
assert add_user_to_team(dev_user, team_3)
assert set_team_syncing(team_3, "ldap", None)

user_teams = get_federated_user_teams(dev_user, login_service_name)
if login_service_name == "oidc":
assert len(user_teams) == 2
elif login_service_name == "ldap":
assert len(user_teams) == 1
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 @@ -50,3 +56,77 @@ def query_users(self, query, limit):
No way to query users so returning empty list
"""
return ([], self.federated_service, None)

def sync_oidc_groups(self, user_groups, user_obj, service_name):
"""
Adds user to quay teams that have team sync enabled with an OIDC group
"""
if user_groups is None:
return

for oidc_group in user_groups:
# fetch TeamSync row if exists, for the oidc_group synced with the login service
synced_teams = team.get_oidc_team_from_groupname(oidc_group, service_name)
if len(synced_teams) == 0:
logger.debug(
f"OIDC group: {oidc_group} is either not synced with a team in quay or is not synced with the {service_name} service"
)
continue

# fetch team name and organization name for the Teamsync row
for team_synced in synced_teams:
team_name = team_synced.team.name
org_name = team_synced.team.organization.username
if not team_name or not org_name:
logger.debug(f"Cannot retrieve team for the oidc group: {oidc_group}")

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

def ping(self):
"""
TODO: get the OIDC connection here
"""
return (True, None)

def resync_quay_teams(self, user_groups, user_obj, login_service_name):
"""
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, login_service_name)
user_groups = user_groups or []
for user_team in existing_user_teams:
try:
sync_group_info = json.loads(user_team.teamsync.config)
# remove user's membership from teams that were not returned from users OIDC groups
if (
sync_group_info.get("group_name", None)
and sync_group_info["group_name"] not in user_groups
):
org_name = user_team.teamsync.team.organization.username
team.remove_user_from_team(org_name, user_team.name, user_obj.username, None)
logger.debug(
f"Successfully removed user: {user_obj.username} from team: {user_team.name} in organization: {org_name}"
)
except Exception as err:
logger.exception(err)
return

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

service_name = login_service.service_id()
self.sync_oidc_groups(user_groups, user_obj, service_name)
self.resync_quay_teams(user_groups, user_obj, service_name)
return
8 changes: 6 additions & 2 deletions endpoints/api/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from flask import request

import features
from app import authentication, avatar
from app import app, authentication, avatar
from auth import scopes
from auth.auth_context import get_authenticated_user
from auth.permissions import (
Expand Down Expand Up @@ -308,6 +308,10 @@ def post(self, orgname, teamname):
# Set the team's syncing config.
model.team.set_team_syncing(team, authentication.federated_service, config)

if app.config["AUTHENTICATION_TYPE"] == "OIDC":
# delete existing team members, team membership will be synced with OIDC group
model.team.delete_all_team_members(team)

return team_view(orgname, team)

raise Unauthorized()
Expand Down Expand Up @@ -384,7 +388,7 @@ def get(self, orgname, teamname, parsed_args):
"service": sync_info.service.name,
}

if SuperUserPermission().can():
if features.NONSUPERUSER_TEAM_SYNCING_SETUP or SuperUserPermission().can():
data["synced"].update(
{
"last_updated": format_date(sync_info.last_updated),
Expand Down
5 changes: 3 additions & 2 deletions 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 All @@ -170,7 +171,7 @@ def attach_func():
# Exchange the OAuth code for login information.
code = request.values.get("code")
try:
lid, lusername, _ = login_service.exchange_code_for_login(
lid, lusername, _, _ = login_service.exchange_code_for_login(
app.config, client, code, "/attach"
)
except OAuthLoginException as ole:
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)
Loading

0 comments on commit 74fd23d

Please sign in to comment.