-
Notifications
You must be signed in to change notification settings - Fork 14
Allow pushing user-allocation membership to Keycloak #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| name: test-functional-keycloak | ||
|
|
||
| on: | ||
| push: | ||
| branches: [ main ] | ||
| pull_request: | ||
| branches: [ main ] | ||
|
|
||
| jobs: | ||
| build: | ||
| runs-on: ubuntu-22.04 | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v6 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: 3.12 | ||
|
|
||
| - name: Upgrade and install packages | ||
| run: | | ||
| bash ./ci/setup-ubuntu.sh | ||
|
|
||
| - name: Install Keycloak | ||
| run: | | ||
| bash ./ci/setup-keycloak.sh | ||
|
|
||
| - name: Install ColdFront and plugin | ||
| run: | | ||
| ./ci/setup.sh | ||
|
|
||
| - name: Run functional tests | ||
| run: | | ||
| ./ci/run_functional_tests_keycloak.sh |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| #!/bin/bash | ||
| set -xe | ||
|
|
||
| if [[ ! "${CI}" == "true" ]]; then | ||
| source /tmp/coldfront_venv/bin/activate | ||
| fi | ||
|
|
||
| export DJANGO_SETTINGS_MODULE="local_settings" | ||
| export PYTHONWARNINGS="ignore:Unverified HTTPS request" | ||
|
|
||
| export KEYCLOAK_BASE_URL="http://localhost:8080" | ||
| export KEYCLOAK_REALM="master" | ||
| export KEYCLOAK_CLIENT_ID="coldfront" | ||
| export KEYCLOAK_CLIENT_SECRET="nomoresecret" | ||
|
|
||
| coverage run --source="." -m django test coldfront_plugin_cloud.tests.functional.keycloak | ||
| coverage report |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,61 @@ | ||||||
| #!/bin/bash | ||||||
|
|
||||||
| set -xe | ||||||
|
|
||||||
| sudo docker rm -f keycloak | ||||||
|
|
||||||
| sudo docker run -d --name keycloak \ | ||||||
| -e KEYCLOAK_ADMIN=admin \ | ||||||
| -e KEYCLOAK_ADMIN_PASSWORD=nomoresecret \ | ||||||
| -p 8080:8080 \ | ||||||
| -p 8443:8443 \ | ||||||
| quay.io/keycloak/keycloak:25.0 start-dev | ||||||
|
|
||||||
| # wait for keycloak to be ready | ||||||
| until curl -s http://localhost:8080/auth/realms/master; do | ||||||
|
||||||
| until curl -s http://localhost:8080/auth/realms/master; do | |
| until curl -fsS http://localhost:8080/realms/master >/dev/null; do |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,3 +12,4 @@ python-novaclient | |
| python-neutronclient | ||
| python-swiftclient | ||
| pytz | ||
| requests | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import os | ||
|
|
||
| import requests | ||
|
|
||
|
|
||
| class KeyCloakAPIClient: | ||
| def __init__(self): | ||
| self.base_url = os.getenv("KEYCLOAK_BASE_URL") | ||
| self.realm = os.getenv("KEYCLOAK_REALM") | ||
| self.client_id = os.getenv("KEYCLOAK_CLIENT_ID") | ||
| self.client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET") | ||
|
|
||
| self.token_url = ( | ||
| f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token" | ||
| ) | ||
|
|
||
| @property | ||
| def api_client(self): | ||
| params = { | ||
| "grant_type": "client_credentials", | ||
| "client_id": self.client_id, | ||
| "client_secret": self.client_secret, | ||
| } | ||
| r = requests.post(self.token_url, data=params) | ||
| r.raise_for_status() | ||
| headers = { | ||
| "Authorization": ("Bearer %s" % r.json()["access_token"]), | ||
| "Content-Type": "application/json", | ||
| } | ||
| session = requests.session() | ||
| session.headers.update(headers) | ||
| return session | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are creating and caching this session with a token (and cache the instantiation of the class again later). When does the keycloak token expire? Just wondering how long we can continue to use this. Or is that not a concern since we'll run this thing as needed and then when its needed we start from scratch . I admit I don't know how coldfront tasks call these.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @naved001 The keycloak API will be called whenever Coldfront's @naved001 @knikolla Do we still want to cache the api client? Or make a follow-up PR to set a TTL mechanism?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just remove the caching for now. |
||
|
|
||
|
Comment on lines
+17
to
+33
|
||
| def create_group(self, group_name): | ||
| url = f"{self.base_url}/admin/realms/{self.realm}/groups" | ||
| payload = {"name": group_name} | ||
| response = self.api_client.post(url, json=payload) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this return a group id when a group is created? If yes, we should return that to the caller and we won't need an additional call to get_group_id
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From local testing and the online documentation, it just returns the status code |
||
|
|
||
| # If group already exists, ignore and move on | ||
| if response.status_code not in (201, 409): | ||
| response.raise_for_status() | ||
|
|
||
| def get_group_id(self, group_name) -> str | None: | ||
| """Return None if group not found""" | ||
| query = f"search={group_name}&exact=true" | ||
| url = f"{self.base_url}/admin/realms/{self.realm}/groups?{query}" | ||
| r = self.api_client.get(url) | ||
| r.raise_for_status() | ||
| r_json = r.json() | ||
| return r_json[0]["id"] if r_json else None | ||
|
|
||
| def get_user_id(self, cf_username) -> str | None: | ||
| """Return None if user not found""" | ||
| # (Quan) Coldfront usernames map to Keycloak usernames | ||
| # https://github.com/nerc-project/coldfront-plugin-cloud/pull/249#discussion_r2953393852 | ||
| query = f"username={cf_username}&exact=true" | ||
| url = f"{self.base_url}/admin/realms/{self.realm}/users?{query}" | ||
| r = self.api_client.get(url) | ||
| r.raise_for_status() | ||
| r_json = r.json() | ||
| return r_json[0]["id"] if r_json else None | ||
|
|
||
| def add_user_to_group(self, user_id, group_id): | ||
| url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}" | ||
| r = self.api_client.put(url) | ||
| r.raise_for_status() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the user is already part of the group don't error out here, just log an info.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From local testing, if the user is already part of the group, 204 is returned, so not error is raised. |
||
|
|
||
| def remove_user_from_group(self, user_id, group_id): | ||
| url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}" | ||
| r = self.api_client.delete(url) | ||
| r.raise_for_status() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the user was not part of the group, don't error out here, just log an info.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From local testing, it seems if the user is already not part of the group, |
||
|
|
||
| def get_user_groups(self, user_id) -> list[str]: | ||
| url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups" | ||
| r = self.api_client.get(url) | ||
| r.raise_for_status() | ||
| return [group["name"] for group in r.json()] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this may be my limited knowledge of keycloak, but are we guaranteed to have a user be part of some group?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can't say what the production environment will look like, but local testing doesn't suggest users are automatically added to some group. Regardless, I don't think that should be a problem for consumers of the Keycloak API? @knikolla ?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is likely that the user may be part of other groups besides the ones corresponding to respective allocations. This is at least guaranteed for PIs who are part of a group called |
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,8 +6,10 @@ | |||||||||||||||||
| from coldfront_plugin_cloud.tasks import ( | ||||||||||||||||||
| activate_allocation, | ||||||||||||||||||
| add_user_to_allocation, | ||||||||||||||||||
| add_user_to_keycloak, | ||||||||||||||||||
| disable_allocation, | ||||||||||||||||||
| remove_user_from_allocation, | ||||||||||||||||||
| remove_user_from_keycloak, | ||||||||||||||||||
| ) | ||||||||||||||||||
| from coldfront.core.allocation.signals import ( | ||||||||||||||||||
| allocation_activate, | ||||||||||||||||||
|
|
@@ -25,6 +27,10 @@ def is_async(): | |||||||||||||||||
| return os.getenv("REDIS_HOST") | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def is_keycloak_enabled(): | ||||||||||||||||||
| return os.getenv("KEYCLOAK_BASE_URL") | ||||||||||||||||||
|
||||||||||||||||||
| return os.getenv("KEYCLOAK_BASE_URL") | |
| required_env_vars = ( | |
| "KEYCLOAK_BASE_URL", | |
| "KEYCLOAK_REALM", | |
| "KEYCLOAK_CLIENT_ID", | |
| "KEYCLOAK_CLIENT_SECRET", | |
| ) | |
| return all(os.getenv(env_var) for env_var in required_env_vars) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think one env var is enough to indicate Keycloak is enabled. It's the operator's problem if the config is misconfigured
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,14 @@ | ||||||||||||||||
| import datetime | ||||||||||||||||
| import logging | ||||||||||||||||
| import time | ||||||||||||||||
| import functools | ||||||||||||||||
| from string import Template | ||||||||||||||||
|
|
||||||||||||||||
| from coldfront.core.allocation.models import Allocation, AllocationUser | ||||||||||||||||
| from coldfront.core.allocation.models import ( | ||||||||||||||||
| Allocation, | ||||||||||||||||
| AllocationUser, | ||||||||||||||||
| AllocationAttribute, | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| from coldfront_plugin_cloud import ( | ||||||||||||||||
| attributes, | ||||||||||||||||
|
|
@@ -12,11 +18,17 @@ | |||||||||||||||
| esi, | ||||||||||||||||
| openshift_vm, | ||||||||||||||||
| utils, | ||||||||||||||||
| kc_client, | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| @functools.lru_cache() | ||||||||||||||||
| def get_kc_client(): | ||||||||||||||||
| return kc_client.KeyCloakAPIClient() | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def find_allocator(allocation) -> base.ResourceAllocator: | ||||||||||||||||
| allocators = { | ||||||||||||||||
| "openstack": openstack.OpenStackResourceAllocator, | ||||||||||||||||
|
|
@@ -128,3 +140,85 @@ def remove_user_from_allocation(allocation_user_pk): | |||||||||||||||
| allocator.remove_role_from_user(username, project_id) | ||||||||||||||||
| else: | ||||||||||||||||
| logger.warning("No project has been created. Nothing to disable.") | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def _clean_template_string(template_string: str) -> str: | ||||||||||||||||
| return template_string.replace(" ", "_").lower() | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def _get_keycloak_group_name(allocation: Allocation, template_string: str) -> str: | ||||||||||||||||
| """ | ||||||||||||||||
| Acceptable variables for the group name template string is: | ||||||||||||||||
| - $resource_name: the name of the resource (e.g. "OpenShift") | ||||||||||||||||
| - Any allocation attribute defined for the allocation, with spaces replaced by underscores and | ||||||||||||||||
| all lowercase (e.g. for `Project Name`, the variable would be `$project_name`) | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is there a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is the syntax for Python string templates, |
||||||||||||||||
| """ | ||||||||||||||||
| resource_name = allocation.resources.first().name | ||||||||||||||||
| allocation_attrs_list: list[AllocationAttribute] = ( | ||||||||||||||||
| allocation.allocationattribute_set.all() | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| template_sub_dict = {"resource_name": resource_name} | ||||||||||||||||
| for attr in allocation_attrs_list: | ||||||||||||||||
| template_sub_dict[ | ||||||||||||||||
| _clean_template_string(attr.allocation_attribute_type.name) | ||||||||||||||||
| ] = attr.value | ||||||||||||||||
|
|
||||||||||||||||
| return Template(template_string).substitute(**template_sub_dict) | ||||||||||||||||
|
Comment on lines
+149
to
+167
|
||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def add_user_to_keycloak(allocation_user_pk): | ||||||||||||||||
| allocation_user = AllocationUser.objects.get(pk=allocation_user_pk) | ||||||||||||||||
| allocation = allocation_user.allocation | ||||||||||||||||
|
|
||||||||||||||||
| kc_admin_client = get_kc_client() | ||||||||||||||||
| username = allocation_user.user.username | ||||||||||||||||
|
|
||||||||||||||||
| group_name_template = allocation.resources.first().get_attribute( | ||||||||||||||||
| attributes.RESOURCE_KEYCLOAK_GROUP_TEMPLATE | ||||||||||||||||
| ) | ||||||||||||||||
| if group_name_template is None: | ||||||||||||||||
| logger.info( | ||||||||||||||||
| f"Keycloak enabled but no group name template specified for resource {allocation.resources.first().name}. Skipping addition to Keycloak group" | ||||||||||||||||
| ) | ||||||||||||||||
| return | ||||||||||||||||
|
|
||||||||||||||||
| if (user_id := kc_admin_client.get_user_id(username)) is None: | ||||||||||||||||
| logger.warning(f"User {username} not found in Keycloak, cannot add to group.") | ||||||||||||||||
| return | ||||||||||||||||
|
|
||||||||||||||||
| group_name = _get_keycloak_group_name(allocation, group_name_template) | ||||||||||||||||
| kc_admin_client.create_group(group_name) | ||||||||||||||||
| group_id = kc_admin_client.get_group_id(group_name) | ||||||||||||||||
|
||||||||||||||||
| group_id = kc_admin_client.get_group_id(group_name) | |
| group_id = kc_admin_client.get_group_id(group_name) | |
| if group_id is None: | |
| logger.warning( | |
| f"Keycloak group '{group_name}' could not be resolved after creation; cannot add user {username} to group." | |
| ) | |
| return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With
set -e,docker rm -f keycloakwill cause the script to exit if the container doesn't exist (common on first run). Add|| true(or similar) so setup remains idempotent.