This integration allows Github Team Sync to pull group data from Keycloak via its admin REST API.
Users can choose to do a username sync as normal, or they can link to an existing Github account by enabling KEYCLOAK_USE_GITHUB_IDP. This requires the user to add Github as an Identity provider on the user's realm.
This commit is contained in:
Ike Johnson-Woods 2024-05-19 22:53:33 +08:00
Родитель 41da7a8b50
Коммит 074220679f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 19675BB3F8E759D6
3 изменённых файлов: 218 добавлений и 0 удалений

89
.env.example.keycloak Normal file
Просмотреть файл

@ -0,0 +1,89 @@
#########################
## GitHub App Settings ##
#########################
## Webhook Secret
WEBHOOK_SECRET=development
## GitHub App ID
APP_ID=12345
## Private Key Path
PRIVATE_KEY_PATH=.ssh/team-sync.pem
## Uncomment the following line and use your own GitHub Enterprise
## instance if this will not be used on https://github.com
#GHE_HOST=github.example.com
## Uncomment if you are using a self-signed certificate on GitHub Enterprise.
## Defaults to False.
#VERIFY_SSL=False
## User directory to sync GitHub teams from
## Azure AD = AAD
## Active Directory = LDAP
## OpenLDAP = LDAP
## Okta = OKTA
## Keycloak = KEYCLOAK
USER_DIRECTORY=KEYCLOAK
## Attribute to compare users with
## username or email
USER_SYNC_ATTRIBUTE=username
###################
## Keycloak Settings ##
###################
## Your organizations Okta URL
KEYCLOAK_SERVER_URL=https://example.okta.com
###############################
## Keycloak authentication ##
###############################
## Keycloak account credentials
## This account needs to have access to the master (or equivalent) realm
## as it will be using the Admin API
KEYCLOAK_USERNAME=api-account
KEYCLOAK_PASSWORD=ExamplePassword
## Master (or equivalent) realm name
#KEYCLOAK_MASTER_REALM=master
## Realm where users are stored
#KEYCLOAK_USER_REALM=master
## Use the Github Identity Provider within Keycloak?
## This requires you to set up the provider as an Identity provider with
## the user realm
#KEYCLOAK_USE_GITHUB_IDP=true
#########################
## Additional settings ##
#########################
## Stop if number of changes exceeds this number
## Default: 25
#CHANGE_THRESHOLD=25
## Create an issue if the sync fails for any reason
## Default: false
#OPEN_ISSUE_ON_FAILURE=true
## Where to open the issue upon sync failure
#REPO_FOR_ISSUES=github-demo/demo-repo
## Who to assign the issues to
#ISSUE_ASSIGNEE=githubber
## Sync schedule, cron style schedule
## Shortcode for emu accounts
#EMU_SHORTCODE=volcano
## Default (hourly): 0 * * * *
SYNC_SCHEDULE=0 * * * *
## Show the changes, but do not make any changes
## Default: false
#TEST_MODE=false
## Automatically add users missing from the organization
ADD_MEMBER=false
## Automatically remove users from the organisation that are not part of a team
REMOVE_ORG_MEMBERS_WITHOUT_TEAM=false
####################
## Flask Settings ##
####################
## Default: app
FLASK_APP=app
## Default: production
FLASK_ENV=development
## Default: 5000
FLASK_RUN_PORT=5000
## Default: 127.0.0.1
FLASK_RUN_HOST=0.0.0.0

Просмотреть файл

@ -13,6 +13,8 @@ elif os.environ.get("USER_DIRECTORY", "LDAP").upper() == "ONELOGIN":
from .onelogin import OneLogin as DirectoryClient
elif os.environ.get("USER_DIRECTORY", "LDAP").upper() == "GOOGLE_WORKSPACE":
from .googleworkspace import GoogleWorkspaceClient as DirectoryClient
elif os.environ.get("USER_DIRECTORY", "LDAP").upper() == "KEYCLOAK":
from .keycloak import Keycloak as DirectoryClient
from .version import __version__
__all__ = ["GitHubApp", "DirectoryClient"]

127
githubapp/keycloak.py Normal file
Просмотреть файл

@ -0,0 +1,127 @@
import asyncio
import collections
import os
import logging
import re
from keycloak import KeycloakAdmin
LOG = logging.getLogger(__name__)
class Keycloak:
def __init__(self):
if not os.environ.get("KEYCLOAK_SERVER_URL", None):
raise Exception("KEYCLOAK_SERVER_URL not defined")
if not os.environ.get("KEYCLOAK_USERNAME", None):
raise Exception("KEYCLOAK_USERNAME not defined")
if not os.environ.get("KEYCLOAK_PASSWORD", None):
raise Exception("KEYCLOAK_PASSWORD not defined")
self.UseGithubIDP = os.environ.get("KEYCLOAK_USE_GITHUB_IDP", "true") == "true"
self.client = KeycloakAdmin(
server_url=os.environ["KEYCLOAK_SERVER_URL"],
username=os.environ["KEYCLOAK_USERNAME"],
password=os.environ["KEYCLOAK_PASSWORD"],
realm_name=os.environ.get("KEYCLOAK_MASTER_REALM", "master"),
user_realm_name=os.environ.get("KEYCLOAK_USER_REALM", "master")
)
def get_group_members(self, group_name: str = None):
"""
Get a list of users that are in a group in Keycloak
:param group_name: Group name to look up
:type group_name: str
:return member_list: A list of dictionaries containing usernames and emails
:rtype member_list: list
"""
member_list = []
def get_group_id(client: KeycloakAdmin = None):
"""
Get the UUID of the provided group from Keycloak
:param client: A KeycloakAdmin client
:return: The group's UUID in Keycloak
"""
group = client.get_groups(query={"search": group_name})
return group[0]["id"]
def get_members(client: KeycloakAdmin = None, group_id: str = None):
"""
Get the users that are in this group
:param client: A KeycloakAdmin client
:param group_id: The group's UUID in Keycloak
:return: A list containing all users in the group
"""
# Keycloak paginates the response when grabbing the list of members
# The response doesn't contain any info on the next page either
# Therefore, we'll need to iterate over the pages until the returned
# list is smaller than the provided page size
page_start = 1
page_size = 100
members = None
group_members = client.get_group_members(
group_id=group_id,
query={"first": page_start, "max": page_size}
)
members += group_members
while len(group_members) == page_size:
page_start += page_size
group_members = client.get_group_members(
group_id=group_id,
query={"first": page_start, "max": page_size}
)
members += group_members
return members
def get_github_username(client: KeycloakAdmin = None, user_id: str = None):
"""
Gets the GitHub username from the user's Keycloak profile
This only works if the Keycloak realm has GitHub set up
as an Identity provider.
:param client: A KeycloakAdmin client
:param user_id: The user's UUID in Keycloak
:return: The user's GitHub username
"""
profile = client.get_user(user_id=user_id)
github_username = None
for provider in profile["federatedIdentities"]:
if provider["identityProvider"] == "github":
github_username = provider["userName"]
if not github_username:
raise Exception("Cannot find Github username")
return github_username
gid = get_group_id(client=self.client)
users: collections.Iterable = get_members(client=self.client, group_id=gid)
for user in users:
try:
if self.UseGithubIDP:
username = get_github_username(client=self.client, user_id=user["id"])
else:
username = user["username"]
if not username:
raise Exception("Unable to find username in profile")
if "EMU_SHORTCODE" in os.environ:
username = username + "_" + os.environ["EMU_SHORTCODE"]
member_list.append(
{
"username": username,
"email": user["email"]
}
)
except Exception as e:
user_info = f'{user["username"]} ({user["email"]})'
print(f"User {user_info}: {e}")
return member_list