Skip to content

Commit d7da5c4

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
1 parent 1cb7589 commit d7da5c4

12 files changed

+158
-18
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
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 get_user_groups(self, user_id) -> list[str]:
78+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups"
79+
r = self.api_client.get(url)
80+
r.raise_for_status()
81+
return [group["name"] for group in r.json()]

src/coldfront_plugin_cloud/tasks.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
esi,
1313
openshift_vm,
1414
utils,
15+
kc_client,
1516
)
1617

1718
logger = logging.getLogger(__name__)
@@ -154,6 +155,17 @@ def set_quota_attributes():
154155

155156
allocator.set_quota(project_id)
156157

158+
# After setting everything on cluster, add user to Keycloak group
159+
kc_admin_client = kc_client.KeyCloakAPIClient()
160+
kc_admin_client.create_group(project_id)
161+
if user_id := kc_admin_client.get_user_id(pi_username):
162+
group_id = kc_admin_client.get_group_id(project_id)
163+
kc_admin_client.add_user_to_group(user_id, group_id)
164+
else:
165+
logger.warning(
166+
f"User {pi_username} not found in Keycloak, cannot add to group."
167+
)
168+
157169

158170
def disable_allocation(allocation_pk):
159171
allocation = Allocation.objects.get(pk=allocation_pk)

src/coldfront_plugin_cloud/tests/base.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from coldfront.core.field_of_science.models import FieldOfScience
2525
from django.core.management import call_command
2626

27+
from coldfront_plugin_cloud import kc_client
28+
2729

2830
class TestBase(TestCase):
2931
def setUp(self) -> None:
@@ -37,11 +39,7 @@ def setUp(self) -> None:
3739
# For testing we can validate allocations with this status
3840
AllocationStatusChoice.objects.get_or_create(name="Active (Needs Renewal)")
3941

40-
@staticmethod
41-
def new_user(username=None) -> User:
42-
username = username or f"{uuid.uuid4().hex}@example.com"
43-
User.objects.create(username=username, email=username)
44-
return User.objects.get(username=username)
42+
self.kc_admin_client = kc_client.KeyCloakAPIClient()
4543

4644
@staticmethod
4745
def new_esi_resource(name=None, auth_url=None) -> Resource:
@@ -101,6 +99,15 @@ def new_openshift_resource(
10199
)
102100
return Resource.objects.get(name=resource_name)
103101

102+
def new_user(self, username=None, add_to_keycloak=True) -> User:
103+
username = username or f"{uuid.uuid4().hex}@example.com"
104+
User.objects.create(username=username, email=username)
105+
106+
if add_to_keycloak:
107+
self.kc_admin_client.create_user(username)
108+
109+
return User.objects.get(username=username)
110+
104111
def new_project(self, title=None, pi=None) -> Project:
105112
title = title or uuid.uuid4().hex
106113
pi = pi or self.new_user()

src/coldfront_plugin_cloud/tests/functional/openshift/test_allocation.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ def test_new_allocation(self):
5252

5353
allocator._get_role(user.username, project_id)
5454

55+
# Check Keycloak group and user membership
56+
self.kc_admin_client.get_group_id(project_id)
57+
user_id = self.kc_admin_client.get_user_id(user.username)
58+
assert project_id in self.kc_admin_client.get_user_groups(user_id)
59+
60+
# TODO (Quan): Confirm that user should also be removed from group on role removal
5561
allocator.remove_role_from_user(user.username, project_id)
5662

5763
with self.assertRaises(openshift.NotFound):

0 commit comments

Comments
 (0)