Skip to content

Commit cb1d628

Browse files
committed
Allow pushing user-allocation membership to Keycloak
A Keycloak admin client has been added When `activate_allocation` is called, the user is added to a Keycloak group named after the project ID on the remote cluster. If the user does not already exist in Keycloak, the case is ignored for now When `deactivate_allocation` is called, the user is removed from the Keycloak group Unit tests have been updated to remove dependancy on Keycloak A comment in `validate_allocations` has been updated to reflect the more restrictive validation behavior, where users on cluster projects will be removed if they are not part of the Coldfront allocation (rather than if they are registered on Coldfront at all). This is relevant for functional tests for this new feature.
1 parent 44e1933 commit cb1d628

17 files changed

+197
-33
lines changed

.github/workflows/test-functional-microshift.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ jobs:
3535
run: |
3636
bash ./ci/setup-oc-client.sh
3737
38+
- name: Install Keycloak
39+
run: |
40+
bash ./ci/setup-keycloak.sh
41+
3842
- name: Install Microshift
3943
run: |
4044
./ci/microshift.sh

.github/workflows/test-functional-microstack.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ jobs:
1818
with:
1919
python-version: 3.12
2020

21+
- name: Install Keycloak
22+
run: |
23+
bash ./ci/setup-keycloak.sh
24+
2125
- name: Install ColdFront and plugin
2226
run: |
2327
python -m pip install --upgrade pip

ci/run_functional_tests_openshift.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
# Tests expect the resource to be name Devstack
66
set -xe
77

8+
export KEYCLOAK_BASE_URL="http://localhost:8080"
9+
export KEYCLOAK_REALM="master"
10+
export KEYCLOAK_CLIENT_ID="admin-cli"
11+
export KEYCLOAK_ADMIN_USER="admin"
12+
export KEYCLOAK_ADMIN_PASSWORD="nomoresecret"
13+
814
export OPENSHIFT_MICROSHIFT_TOKEN="$(oc create token -n onboarding onboarding-serviceaccount)"
915
export OPENSHIFT_MICROSHIFT_VERIFY="false"
1016

ci/run_functional_tests_openstack.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
# Tests expect the resource to be name Devstack
66
set -xe
77

8+
export KEYCLOAK_BASE_URL="http://localhost:8080"
9+
export KEYCLOAK_REALM="master"
10+
export KEYCLOAK_CLIENT_ID="admin-cli"
11+
export KEYCLOAK_ADMIN_USER="admin"
12+
export KEYCLOAK_ADMIN_PASSWORD="nomoresecret"
13+
814
export CREDENTIAL_NAME=$(openssl rand -base64 12)
915

1016
export OPENSTACK_DEVSTACK_APPLICATION_CREDENTIAL_SECRET=$(

ci/setup-keycloak.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/bash
2+
3+
set -xe
4+
5+
sudo docker run -d --name keycloak \
6+
-e KEYCLOAK_ADMIN=admin \
7+
-e KEYCLOAK_ADMIN_PASSWORD=nomoresecret \
8+
-p 8080:8080 \
9+
-p 8443:8443 \
10+
quay.io/keycloak/keycloak:25.0 start-dev

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ python-keystoneclient
88
python-novaclient
99
python-neutronclient
1010
python-swiftclient
11+
requests

src/coldfront_plugin_cloud/base.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import abc
22
import functools
33
from typing import NamedTuple
4+
import logging
45

56
from coldfront.core.allocation import models as allocation_models
67
from coldfront.core.resource import models as resource_models
78

8-
from coldfront_plugin_cloud import attributes
9+
from coldfront_plugin_cloud import attributes, kc_client
10+
11+
logger = logging.getLogger(__name__)
912

1013

1114
class ResourceAllocator(abc.ABC):
@@ -25,11 +28,30 @@ def __init__(
2528
self.resource = resource
2629
self.allocation = allocation
2730

31+
@functools.cached_property
32+
def kc_admin_client(self):
33+
return kc_client.KeyCloakAPIClient()
34+
2835
def get_or_create_federated_user(self, username):
2936
if not (user := self.get_federated_user(username)):
3037
user = self.create_federated_user(username)
3138
return user
3239

40+
def assign_role_on_user(self, username, project_id):
41+
self.kc_admin_client.create_group(project_id)
42+
if user_id := self.kc_admin_client.get_user_id(username):
43+
group_id = self.kc_admin_client.get_group_id(project_id)
44+
self.kc_admin_client.add_user_to_group(user_id, group_id)
45+
else:
46+
logger.warning(
47+
f"User {username} not found in Keycloak, cannot add to group."
48+
)
49+
50+
def remove_role_from_user(self, username, project_id):
51+
user_id = self.kc_admin_client.get_user_id(username)
52+
group_id = self.kc_admin_client.get_group_id(project_id)
53+
self.kc_admin_client.remove_user_from_group(user_id, group_id)
54+
3355
@functools.cached_property
3456
def auth_url(self):
3557
return self.resource.get_attribute(attributes.RESOURCE_AUTH_URL).rstrip("/")
@@ -69,11 +91,3 @@ def create_federated_user(self, unique_id):
6991
@abc.abstractmethod
7092
def get_federated_user(self, unique_id):
7193
pass
72-
73-
@abc.abstractmethod
74-
def assign_role_on_user(self, username, project_id):
75-
pass
76-
77-
@abc.abstractmethod
78-
def remove_role_from_user(self, username, project_id):
79-
pass
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import os
2+
import functools
3+
4+
import requests
5+
6+
7+
class KeyCloakAPIClient:
8+
def __init__(self):
9+
self.base_url = os.getenv("KEYCLOAK_BASE_URL")
10+
self.realm = os.getenv("KEYCLOAK_REALM")
11+
self.admin_user = os.getenv("KEYCLOAK_ADMIN_USER")
12+
self.admin_password = os.getenv("KEYCLOAK_ADMIN_PASSWORD")
13+
self.client_id = os.getenv("KEYCLOAK_CLIENT_ID", "admin-cli")
14+
15+
self.token_url = (
16+
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token"
17+
)
18+
19+
@functools.cached_property
20+
def api_client(self):
21+
params = {
22+
"grant_type": "password",
23+
"client_id": self.client_id,
24+
"username": self.admin_user,
25+
"password": self.admin_password,
26+
"scope": "openid",
27+
}
28+
r = requests.post(self.token_url, data=params).json()
29+
headers = {
30+
"Authorization": ("Bearer %s" % r["access_token"]),
31+
"Content-Type": "application/json",
32+
}
33+
session = requests.session()
34+
session.headers.update(headers)
35+
return session
36+
37+
def create_group(self, group_name):
38+
url = f"{self.base_url}/admin/realms/{self.realm}/groups"
39+
payload = {"name": group_name}
40+
response = self.api_client.post(url, json=payload)
41+
42+
# If group already exists, ignore and move on
43+
if response.status_code not in (201, 409):
44+
response.raise_for_status()
45+
46+
def create_user(self, cf_username):
47+
"""Helper function to create user in Keycloak, for testing purposes only"""
48+
url = f"{self.base_url}/admin/realms/{self.realm}/users"
49+
payload = {
50+
"username": cf_username,
51+
"enabled": True,
52+
"email": cf_username,
53+
}
54+
r = self.api_client.post(url, json=payload)
55+
r.raise_for_status()
56+
57+
def get_group_id(self, group_name) -> str | None:
58+
"""Return None if group not found"""
59+
query = f"search={group_name}&exact=true"
60+
url = f"{self.base_url}/admin/realms/{self.realm}/groups?{query}"
61+
r = self.api_client.get(url).json()
62+
return r[0]["id"] if r else None
63+
64+
def get_user_id(self, cf_username) -> str | None:
65+
"""Return None if user not found"""
66+
# TODO (Quan): Confirm that Coldfront usernames map to Keycloak emails, not email, or something else?
67+
query = f"email={cf_username}&exact=true"
68+
url = f"{self.base_url}/admin/realms/{self.realm}/users?{query}"
69+
r = self.api_client.get(url).json()
70+
return r[0]["id"] if r else None
71+
72+
def add_user_to_group(self, user_id, group_id):
73+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
74+
r = self.api_client.put(url)
75+
r.raise_for_status()
76+
77+
def remove_user_from_group(self, user_id, group_id):
78+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
79+
r = self.api_client.delete(url)
80+
r.raise_for_status()
81+
82+
def get_user_groups(self, user_id) -> list[str]:
83+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups"
84+
r = self.api_client.get(url)
85+
r.raise_for_status()
86+
return [group["name"] for group in r.json()]

src/coldfront_plugin_cloud/management/commands/validate_allocations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,15 @@ def sync_users(project_id, allocation, allocator, apply):
5050
if apply:
5151
tasks.add_user_to_allocation(coldfront_user.pk)
5252

53-
# remove users that are in the resource but not in coldfront
53+
# remove users that are in the resource but not in coldfront allocation
5454
users = set(
5555
[coldfront_user.user.username for coldfront_user in coldfront_users]
5656
)
5757
for allocation_user in allocation_users:
5858
if allocation_user not in users:
5959
failed_validation = True
6060
logger.warn(
61-
f"{allocation_user} exists in the resource {project_id} but not in coldfront"
61+
f"{allocation_user} exists in the resource {project_id} but not in coldfront allocation"
6262
)
6363
if apply:
6464
allocator.remove_role_from_user(allocation_user, project_id)

src/coldfront_plugin_cloud/openshift.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ def assign_role_on_user(self, username, project_id):
253253
# Role already exists, ignore
254254
pass
255255

256+
super().assign_role_on_user(username, project_id)
257+
256258
def remove_role_from_user(self, username, project_id):
257259
"""Remove a role from a user in a project using direct OpenShift API calls"""
258260
try:
@@ -275,6 +277,8 @@ def remove_role_from_user(self, username, project_id):
275277
# Rolebinding doesn't exist, nothing to remove
276278
pass
277279

280+
super().remove_role_from_user(username, project_id)
281+
278282
def _create_project(self, project_name, project_id):
279283
pi_username = self.allocation.project.pi.username
280284

0 commit comments

Comments
 (0)