Add Keycloak integration
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:
Родитель
41da7a8b50
Коммит
074220679f
|
@ -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"]
|
||||
|
|
|
@ -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
|
Загрузка…
Ссылка в новой задаче