2020-07-01 06:32:44 +03:00
|
|
|
import atexit
|
2020-06-18 23:48:01 +03:00
|
|
|
import os
|
2020-07-01 06:32:44 +03:00
|
|
|
import time
|
2021-03-25 06:36:24 +03:00
|
|
|
import json
|
2021-04-07 09:37:39 +03:00
|
|
|
import github3
|
2020-07-01 06:32:44 +03:00
|
|
|
from distutils.util import strtobool
|
2021-10-06 20:13:12 +03:00
|
|
|
import threading
|
|
|
|
import sys
|
|
|
|
import traceback
|
2021-10-15 00:06:44 +03:00
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2020-07-01 06:32:44 +03:00
|
|
|
|
2020-06-20 01:00:02 +03:00
|
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
2020-07-01 01:08:15 +03:00
|
|
|
from apscheduler.triggers.cron import CronTrigger
|
2020-07-01 06:32:44 +03:00
|
|
|
from flask import Flask
|
|
|
|
|
2021-05-21 23:26:49 +03:00
|
|
|
from githubapp import (
|
|
|
|
GitHubApp,
|
|
|
|
DirectoryClient,
|
|
|
|
CRON_INTERVAL,
|
|
|
|
TEST_MODE,
|
|
|
|
ADD_MEMBER,
|
2022-05-11 16:04:31 +03:00
|
|
|
REMOVE_ORG_MEMBERS_WITHOUT_TEAM,
|
2021-05-25 16:29:28 +03:00
|
|
|
USER_SYNC_ATTRIBUTE,
|
2021-09-22 00:59:48 +03:00
|
|
|
SYNCMAP_ONLY,
|
2021-05-21 23:26:49 +03:00
|
|
|
)
|
2020-06-17 17:43:47 +03:00
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
github_app = GitHubApp(app)
|
|
|
|
|
2020-06-20 01:00:02 +03:00
|
|
|
# Schedule a full sync
|
|
|
|
scheduler = BackgroundScheduler(daemon=True)
|
|
|
|
scheduler.start()
|
|
|
|
atexit.register(lambda: scheduler.shutdown(wait=False))
|
|
|
|
|
2020-07-01 07:24:37 +03:00
|
|
|
|
2021-03-22 07:55:00 +03:00
|
|
|
@github_app.on("team.created")
|
2020-07-01 00:33:46 +03:00
|
|
|
def sync_new_team():
|
2020-06-30 22:30:09 +03:00
|
|
|
"""
|
2020-07-01 00:33:46 +03:00
|
|
|
Sync a new team when it is created
|
2020-06-30 22:30:09 +03:00
|
|
|
:return:
|
|
|
|
"""
|
2021-03-22 07:55:00 +03:00
|
|
|
owner = github_app.payload["organization"]["login"]
|
|
|
|
team_id = github_app.payload["team"]["id"]
|
2021-03-22 18:54:04 +03:00
|
|
|
if os.environ["USER_DIRECTORY"].upper() == "AAD":
|
2021-04-07 10:58:27 +03:00
|
|
|
# Azure APIs don't currently support case insensitive searching
|
2021-03-22 18:54:04 +03:00
|
|
|
slug = github_app.payload["team"]["name"].replace(" ", "-")
|
2021-03-22 18:53:35 +03:00
|
|
|
else:
|
|
|
|
slug = github_app.payload["team"]["slug"]
|
2020-07-01 00:33:46 +03:00
|
|
|
client = github_app.installation_client
|
2021-03-22 07:55:00 +03:00
|
|
|
sync_team(client=client, owner=owner, team_id=team_id, slug=slug)
|
2020-06-20 01:00:02 +03:00
|
|
|
|
|
|
|
|
2020-07-01 00:33:46 +03:00
|
|
|
def sync_team(client=None, owner=None, team_id=None, slug=None):
|
2020-06-19 21:38:37 +03:00
|
|
|
"""
|
2020-07-01 07:24:37 +03:00
|
|
|
Prepare the team sync
|
2020-07-01 00:33:46 +03:00
|
|
|
:param client:
|
|
|
|
:param owner:
|
|
|
|
:param team_id:
|
|
|
|
:param slug:
|
2020-06-19 21:38:37 +03:00
|
|
|
:return:
|
|
|
|
"""
|
2021-03-25 06:45:04 +03:00
|
|
|
print("-------------------------------")
|
|
|
|
print(f"Processing Team: {slug}")
|
2021-10-06 20:13:12 +03:00
|
|
|
|
2021-03-25 06:17:56 +03:00
|
|
|
try:
|
2021-10-06 20:13:12 +03:00
|
|
|
org = client.organization(owner)
|
|
|
|
team = org.team(team_id)
|
2022-05-03 13:57:56 +03:00
|
|
|
custom_map, ignore_users = load_custom_map()
|
2020-07-01 06:51:35 +03:00
|
|
|
try:
|
2021-10-06 20:13:12 +03:00
|
|
|
directory_group = custom_map[slug] if slug in custom_map else slug
|
|
|
|
directory_members = directory_group_members(group=directory_group)
|
|
|
|
except Exception as e:
|
|
|
|
directory_members = []
|
2021-10-15 21:55:13 +03:00
|
|
|
traceback.print_exc(file=sys.stderr)
|
|
|
|
|
2021-10-06 20:13:12 +03:00
|
|
|
team_members = github_team_members(
|
2022-05-03 13:57:56 +03:00
|
|
|
client=client, owner=owner, team_id=team_id,
|
|
|
|
attribute=USER_SYNC_ATTRIBUTE, ignore_users=ignore_users
|
2021-10-06 20:13:12 +03:00
|
|
|
)
|
|
|
|
compare = compare_members(
|
|
|
|
group=directory_members, team=team_members, attribute=USER_SYNC_ATTRIBUTE
|
|
|
|
)
|
|
|
|
if TEST_MODE:
|
2022-04-26 19:03:39 +03:00
|
|
|
print(f"TEST_MODE: Pending changes for team {team.slug}:")
|
2021-10-06 20:13:12 +03:00
|
|
|
print(json.dumps(compare, indent=2))
|
|
|
|
else:
|
|
|
|
try:
|
|
|
|
execute_sync(org=org, team=team, slug=slug, state=compare)
|
2022-04-26 19:03:39 +03:00
|
|
|
except (AssertionError, ValueError) as e:
|
2021-10-06 20:13:12 +03:00
|
|
|
if strtobool(os.environ["OPEN_ISSUE_ON_FAILURE"]):
|
|
|
|
open_issue(client=client, slug=slug, message=e)
|
2022-04-26 19:03:39 +03:00
|
|
|
raise Exception(f"Team {team.slug} sync failed: {e}")
|
|
|
|
print(f"Processing Team Successful: {team.slug}")
|
2021-10-06 20:13:12 +03:00
|
|
|
except Exception:
|
|
|
|
traceback.print_exc(file=sys.stderr)
|
|
|
|
raise
|
2020-06-19 00:58:56 +03:00
|
|
|
|
2020-06-17 17:43:47 +03:00
|
|
|
|
2021-03-22 07:13:27 +03:00
|
|
|
def directory_group_members(group=None):
|
2020-06-17 17:43:47 +03:00
|
|
|
"""
|
2021-03-22 07:13:27 +03:00
|
|
|
Look up members of a group in your user directory
|
|
|
|
:param group: The name of the group to query in your directory server
|
2020-06-17 18:20:02 +03:00
|
|
|
:type group: str
|
2021-03-22 07:13:27 +03:00
|
|
|
:return: group_members
|
2020-06-17 18:20:02 +03:00
|
|
|
:rtype: list
|
2020-06-17 17:43:47 +03:00
|
|
|
"""
|
2021-03-25 06:17:56 +03:00
|
|
|
try:
|
2021-09-02 13:36:33 +03:00
|
|
|
directory = DirectoryClient()
|
2021-03-25 06:17:56 +03:00
|
|
|
members = directory.get_group_members(group_name=group)
|
|
|
|
group_members = [member for member in members]
|
|
|
|
except Exception as e:
|
|
|
|
group_members = []
|
2021-10-15 21:55:13 +03:00
|
|
|
traceback.print_exc(file=sys.stderr)
|
2021-03-22 07:13:27 +03:00
|
|
|
return group_members
|
2020-06-17 17:43:47 +03:00
|
|
|
|
2020-06-19 21:38:37 +03:00
|
|
|
|
2020-07-02 03:19:12 +03:00
|
|
|
def github_team_info(client=None, owner=None, team_id=None):
|
2020-06-19 21:38:37 +03:00
|
|
|
"""
|
|
|
|
Look up team info in GitHub
|
2020-07-01 00:33:46 +03:00
|
|
|
:param client:
|
|
|
|
:param owner:
|
2020-06-19 21:38:37 +03:00
|
|
|
:param team_id:
|
|
|
|
:return:
|
|
|
|
"""
|
2020-07-01 00:33:46 +03:00
|
|
|
org = client.organization(owner)
|
2020-06-19 00:58:56 +03:00
|
|
|
return org.team(team_id)
|
2020-06-17 17:43:47 +03:00
|
|
|
|
2020-06-19 21:38:37 +03:00
|
|
|
|
2022-05-03 13:57:56 +03:00
|
|
|
def github_team_members(client=None, owner=None, team_id=None, attribute="username", ignore_users=[]):
|
2020-06-17 17:43:47 +03:00
|
|
|
"""
|
2022-05-03 13:57:56 +03:00
|
|
|
Look up members of a given team in GitHub
|
2020-07-01 00:33:46 +03:00
|
|
|
:param client:
|
|
|
|
:param owner:
|
2020-06-17 17:43:47 +03:00
|
|
|
:param team_id:
|
2020-06-17 23:31:54 +03:00
|
|
|
:param attribute:
|
2020-07-01 00:33:46 +03:00
|
|
|
:type owner: str
|
2020-06-17 18:20:02 +03:00
|
|
|
:type team_id: int
|
2020-06-17 23:31:54 +03:00
|
|
|
:type attribute: str
|
2020-06-17 18:20:02 +03:00
|
|
|
:return: team_members
|
|
|
|
:rtype: list
|
2020-06-17 17:43:47 +03:00
|
|
|
"""
|
2020-06-17 23:31:54 +03:00
|
|
|
team_members = []
|
2020-07-02 03:19:12 +03:00
|
|
|
team = github_team_info(client=client, owner=owner, team_id=team_id)
|
2021-03-22 07:55:00 +03:00
|
|
|
if attribute == "email":
|
2020-06-17 23:31:54 +03:00
|
|
|
for m in team.members():
|
2020-07-01 00:33:46 +03:00
|
|
|
user = client.user(m.login)
|
2021-03-22 07:55:00 +03:00
|
|
|
team_members.append(
|
|
|
|
{
|
2022-05-03 13:44:30 +03:00
|
|
|
"username": str(user.login),
|
|
|
|
"email": str(user.email),
|
2021-03-22 07:55:00 +03:00
|
|
|
}
|
|
|
|
)
|
2020-06-17 23:31:54 +03:00
|
|
|
else:
|
|
|
|
for member in team.members():
|
2022-05-03 13:44:30 +03:00
|
|
|
team_members.append({"username": str(member), "email": ""})
|
2022-05-03 13:57:56 +03:00
|
|
|
return [m for m in team_members if m["username"] not in ignore_users]
|
2020-06-17 17:43:47 +03:00
|
|
|
|
|
|
|
|
2021-03-22 07:55:00 +03:00
|
|
|
def compare_members(group, team, attribute="username"):
|
2020-06-17 18:20:02 +03:00
|
|
|
"""
|
2021-03-22 07:13:27 +03:00
|
|
|
Compare users in GitHub and the User Directory to see which users need to be added or removed
|
2020-07-01 06:32:44 +03:00
|
|
|
:param group:
|
|
|
|
:param team:
|
2020-06-17 23:31:54 +03:00
|
|
|
:param attribute:
|
2020-06-17 18:20:02 +03:00
|
|
|
:return: sync_state
|
|
|
|
:rtype: dict
|
|
|
|
"""
|
2022-05-03 13:44:30 +03:00
|
|
|
directory_list = [x[attribute].casefold() for x in group]
|
|
|
|
github_list = [x[attribute].casefold() for x in team]
|
2021-03-22 18:53:35 +03:00
|
|
|
add_users = list(set(directory_list) - set(github_list))
|
2021-03-22 07:13:27 +03:00
|
|
|
remove_users = list(set(github_list) - set(directory_list))
|
2020-06-17 18:20:02 +03:00
|
|
|
sync_state = {
|
2021-03-22 07:55:00 +03:00
|
|
|
"directory": group,
|
|
|
|
"github": team,
|
|
|
|
"action": {"add": add_users, "remove": remove_users},
|
2020-06-17 18:20:02 +03:00
|
|
|
}
|
|
|
|
return sync_state
|
2020-06-17 17:43:47 +03:00
|
|
|
|
|
|
|
|
2020-06-19 22:58:55 +03:00
|
|
|
def execute_sync(org, team, slug, state):
|
2020-06-19 21:38:37 +03:00
|
|
|
"""
|
|
|
|
Perform the synchronization
|
|
|
|
:param org:
|
|
|
|
:param team:
|
2020-06-19 22:58:55 +03:00
|
|
|
:param slug:
|
2020-06-19 21:38:37 +03:00
|
|
|
:param state:
|
|
|
|
:return:
|
|
|
|
"""
|
2021-04-07 11:04:33 +03:00
|
|
|
total_changes = len(state["action"]["remove"]) + len(state["action"]["add"])
|
2021-03-22 07:55:00 +03:00
|
|
|
if len(state["directory"]) == 0:
|
2021-03-22 07:13:27 +03:00
|
|
|
message = f"{os.environ.get('USER_DIRECTORY', 'LDAP').upper()} group returned empty: {slug}"
|
2020-06-19 22:58:55 +03:00
|
|
|
raise ValueError(message)
|
2021-03-22 07:55:00 +03:00
|
|
|
elif int(total_changes) > int(os.environ.get("CHANGE_THRESHOLD", 25)):
|
2020-06-19 22:58:55 +03:00
|
|
|
message = "Skipping sync for {}.<br>".format(slug)
|
|
|
|
message += "Total number of changes ({}) would exceed the change threshold ({}).".format(
|
2021-03-22 07:55:00 +03:00
|
|
|
str(total_changes), str(os.environ.get("CHANGE_THRESHOLD", 25))
|
2020-06-19 22:58:55 +03:00
|
|
|
)
|
|
|
|
message += "<br>Please investigate this change and increase your threshold if this is accurate."
|
|
|
|
raise AssertionError(message)
|
|
|
|
else:
|
2021-03-22 07:55:00 +03:00
|
|
|
for user in state["action"]["add"]:
|
2020-06-19 22:58:55 +03:00
|
|
|
# Validate that user is in org
|
2021-04-12 15:33:41 +03:00
|
|
|
if org.is_member(user) or ADD_MEMBER:
|
2021-04-07 09:37:39 +03:00
|
|
|
try:
|
2021-04-07 10:59:53 +03:00
|
|
|
print(f"Adding {user} to {slug}")
|
|
|
|
team.add_or_update_membership(user)
|
2021-04-07 09:37:39 +03:00
|
|
|
except github3.exceptions.NotFoundError:
|
2021-04-07 10:59:53 +03:00
|
|
|
print(f"User: {user} not found")
|
|
|
|
pass
|
2020-06-19 22:58:55 +03:00
|
|
|
else:
|
2021-03-25 06:36:24 +03:00
|
|
|
print(f"Skipping {user} as they are not part of the org")
|
2020-06-19 22:58:55 +03:00
|
|
|
|
2021-03-22 07:55:00 +03:00
|
|
|
for user in state["action"]["remove"]:
|
2021-03-25 06:36:24 +03:00
|
|
|
print(f"Removing {user} from {slug}")
|
2020-06-19 22:58:55 +03:00
|
|
|
team.revoke_membership(user)
|
|
|
|
|
2020-07-01 00:33:46 +03:00
|
|
|
|
|
|
|
def open_issue(client, slug, message):
|
2020-07-01 07:24:37 +03:00
|
|
|
"""
|
|
|
|
Open an issue with the failed sync details
|
|
|
|
:param client: Our installation client
|
|
|
|
:param slug: Team slug
|
|
|
|
:param message: Error message to detail
|
|
|
|
:return:
|
|
|
|
"""
|
2021-03-22 07:55:00 +03:00
|
|
|
repo_for_issues = os.environ["REPO_FOR_ISSUES"]
|
|
|
|
owner = repo_for_issues.split("/")[0]
|
|
|
|
repository = repo_for_issues.split("/")[1]
|
|
|
|
assignee = os.environ["ISSUE_ASSIGNEE"]
|
2020-07-01 00:33:46 +03:00
|
|
|
client.create_issue(
|
2020-06-19 22:03:09 +03:00
|
|
|
owner=owner,
|
|
|
|
repository=repository,
|
|
|
|
assignee=assignee,
|
2020-06-19 22:58:55 +03:00
|
|
|
title="Team sync failed for @{}/{}".format(owner, slug),
|
2021-03-22 07:55:00 +03:00
|
|
|
body=str(message),
|
2020-06-19 22:03:09 +03:00
|
|
|
)
|
2020-06-30 22:30:09 +03:00
|
|
|
|
2020-07-01 07:24:37 +03:00
|
|
|
|
2021-03-22 07:55:00 +03:00
|
|
|
def load_custom_map(file="syncmap.yml"):
|
2020-07-02 03:19:12 +03:00
|
|
|
"""
|
|
|
|
Custom team synchronization
|
|
|
|
:param file:
|
|
|
|
:return:
|
|
|
|
"""
|
|
|
|
syncmap = {}
|
2022-05-03 13:57:56 +03:00
|
|
|
ignore_users = []
|
2020-07-02 03:19:12 +03:00
|
|
|
if os.path.isfile(file):
|
|
|
|
from yaml import load, Loader
|
2021-03-22 07:55:00 +03:00
|
|
|
|
|
|
|
with open(file, "r") as f:
|
2020-07-02 03:19:12 +03:00
|
|
|
data = load(f, Loader=Loader)
|
2021-03-22 07:55:00 +03:00
|
|
|
for d in data["mapping"]:
|
|
|
|
syncmap[d["github"]] = d["directory"]
|
2020-07-02 03:19:12 +03:00
|
|
|
|
2022-05-03 13:57:56 +03:00
|
|
|
ignore_users = data.get('ignore_users', [])
|
|
|
|
|
|
|
|
return (syncmap, ignore_users)
|
2020-07-02 03:19:12 +03:00
|
|
|
|
|
|
|
|
2021-03-25 06:17:56 +03:00
|
|
|
def get_app_installations():
|
|
|
|
"""
|
|
|
|
Get a list of installations for this app
|
|
|
|
:return:
|
|
|
|
"""
|
|
|
|
with app.app_context() as ctx:
|
2021-04-07 09:30:37 +03:00
|
|
|
try:
|
2021-04-07 10:58:27 +03:00
|
|
|
c = ctx.push()
|
|
|
|
gh = GitHubApp(c)
|
|
|
|
installations = gh.app_client.app_installations
|
2021-04-07 09:30:37 +03:00
|
|
|
finally:
|
2021-04-07 10:58:27 +03:00
|
|
|
ctx.pop()
|
2021-03-25 06:17:56 +03:00
|
|
|
return installations
|
|
|
|
|
|
|
|
|
2021-03-22 07:55:00 +03:00
|
|
|
@scheduler.scheduled_job(
|
|
|
|
trigger=CronTrigger.from_crontab(CRON_INTERVAL), id="sync_all_teams"
|
|
|
|
)
|
2020-07-01 00:33:46 +03:00
|
|
|
def sync_all_teams():
|
|
|
|
"""
|
2021-03-22 07:13:27 +03:00
|
|
|
Lookup teams in a GitHub org and synchronize all teams with your user directory
|
2020-07-01 00:33:46 +03:00
|
|
|
:return:
|
|
|
|
"""
|
2021-10-15 00:06:44 +03:00
|
|
|
|
2021-03-25 06:17:56 +03:00
|
|
|
print(f'Syncing all teams: {time.strftime("%A, %d. %B %Y %I:%M:%S %p")}')
|
2021-10-15 00:06:44 +03:00
|
|
|
|
2021-03-25 06:17:56 +03:00
|
|
|
installations = get_app_installations()
|
2022-05-03 13:57:56 +03:00
|
|
|
custom_map, _ = load_custom_map()
|
2021-10-15 00:06:44 +03:00
|
|
|
futures = []
|
2022-04-26 19:03:39 +03:00
|
|
|
install_count = 0
|
2021-10-15 00:06:44 +03:00
|
|
|
with ThreadPoolExecutor(max_workers=10) as exe:
|
|
|
|
for i in installations():
|
2022-04-26 19:03:39 +03:00
|
|
|
install_count += 1
|
2021-10-15 00:06:44 +03:00
|
|
|
print("========================================================")
|
|
|
|
print(f"## Processing Organization: {i.account['login']}")
|
|
|
|
print("========================================================")
|
|
|
|
with app.app_context() as ctx:
|
|
|
|
try:
|
|
|
|
gh = GitHubApp(ctx.push())
|
|
|
|
client = gh.app_installation(installation_id=i.id)
|
|
|
|
org = client.organization(i.account["login"])
|
2022-05-11 16:04:31 +03:00
|
|
|
if REMOVE_ORG_MEMBERS_WITHOUT_TEAM:
|
|
|
|
org_members = [member for member in org.members()]
|
2022-05-12 13:24:32 +03:00
|
|
|
team_members = [
|
|
|
|
member for team in org.teams() for member in team.members()
|
|
|
|
]
|
2022-05-11 16:04:31 +03:00
|
|
|
remove_members = list(set(org_members) - set(team_members))
|
|
|
|
for member in remove_members:
|
|
|
|
print(f"Removing {member}")
|
2022-05-12 18:13:45 +03:00
|
|
|
org.remove_membership(str(member))
|
2021-10-15 00:06:44 +03:00
|
|
|
for team in org.teams():
|
|
|
|
futures.append(
|
|
|
|
exe.submit(sync_team_helper, team, custom_map, client, org)
|
2021-04-07 09:30:37 +03:00
|
|
|
)
|
2021-10-15 00:06:44 +03:00
|
|
|
except Exception as e:
|
|
|
|
print(f"DEBUG: {e}")
|
|
|
|
finally:
|
|
|
|
ctx.pop()
|
2022-04-26 19:03:39 +03:00
|
|
|
if not install_count:
|
|
|
|
raise Exception(f"No installation defined for APP_ID {os.getenv('APP_ID')}")
|
2021-10-15 00:06:44 +03:00
|
|
|
for future in futures:
|
|
|
|
future.result()
|
2022-04-26 19:03:39 +03:00
|
|
|
print(f'Syncing all teams successful: {time.strftime("%A, %d. %B %Y %I:%M:%S %p")}')
|
2021-10-15 00:06:44 +03:00
|
|
|
|
|
|
|
|
|
|
|
def sync_team_helper(team, custom_map, client, org):
|
2022-04-26 19:03:39 +03:00
|
|
|
print(f"Organization: {org.login}")
|
2021-10-15 00:06:44 +03:00
|
|
|
try:
|
|
|
|
if SYNCMAP_ONLY and team.slug not in custom_map:
|
|
|
|
print(f"skipping team {team.slug} - not in sync map")
|
|
|
|
return
|
|
|
|
sync_team(
|
|
|
|
client=client,
|
|
|
|
owner=org.login,
|
|
|
|
team_id=team.id,
|
|
|
|
slug=team.slug,
|
|
|
|
)
|
|
|
|
except Exception as e:
|
|
|
|
print(f"Organization: {org.login}")
|
|
|
|
print(f"Unable to sync team: {team.slug}")
|
|
|
|
print(f"DEBUG: {e}")
|
2021-03-25 06:17:56 +03:00
|
|
|
|
2021-04-07 11:16:53 +03:00
|
|
|
|
2021-10-06 20:13:12 +03:00
|
|
|
thread = threading.Thread(target=sync_all_teams)
|
|
|
|
thread.start()
|
2020-07-01 00:33:46 +03:00
|
|
|
|
2021-03-22 07:55:00 +03:00
|
|
|
if __name__ == "__main__":
|
2020-07-01 06:32:44 +03:00
|
|
|
app.run(
|
2021-03-22 07:55:00 +03:00
|
|
|
host=os.environ.get("FLASK_RUN_HOST", "0.0.0.0"),
|
|
|
|
port=os.environ.get("FLASK_RUN_PORT", "5000"),
|
2020-07-01 07:24:37 +03:00
|
|
|
)
|