Skip to content

Commit 3b80589

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
1 parent 44e1933 commit 3b80589

14 files changed

+178
-27
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: 21 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):
@@ -30,6 +33,23 @@ def get_or_create_federated_user(self, username):
3033
user = self.create_federated_user(username)
3134
return user
3235

36+
def assign_role_on_user(self, username, project_id):
37+
kc_admin_client = kc_client.KeyCloakAPIClient()
38+
kc_admin_client.create_group(project_id)
39+
if user_id := kc_admin_client.get_user_id(username):
40+
group_id = kc_admin_client.get_group_id(project_id)
41+
kc_admin_client.add_user_to_group(user_id, group_id)
42+
else:
43+
logger.warning(
44+
f"User {username} not found in Keycloak, cannot add to group."
45+
)
46+
47+
def remove_role_from_user(self, username, project_id):
48+
kc_admin_client = kc_client.KeyCloakAPIClient()
49+
user_id = kc_admin_client.get_user_id(username)
50+
group_id = kc_admin_client.get_group_id(project_id)
51+
kc_admin_client.remove_user_from_group(user_id, group_id)
52+
3353
@functools.cached_property
3454
def auth_url(self):
3555
return self.resource.get_attribute(attributes.RESOURCE_AUTH_URL).rstrip("/")
@@ -69,11 +89,3 @@ def create_federated_user(self, unique_id):
6989
@abc.abstractmethod
7090
def get_federated_user(self, unique_id):
7191
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/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

src/coldfront_plugin_cloud/openstack.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,12 +344,14 @@ def assign_role_on_user(self, username, project_id):
344344

345345
user = self.get_federated_user(username)
346346
self.identity.roles.grant(user=user["id"], project=project_id, role=role)
347+
super().assign_role_on_user(username, project_id)
347348

348349
def remove_role_from_user(self, username, project_id):
349350
role = self.identity.roles.find(name=self.member_role_name)
350351

351352
if user := self.get_federated_user(username):
352353
self.identity.roles.revoke(user=user["id"], project=project_id, role=role)
354+
super().remove_role_from_user(username, project_id)
353355

354356
def create_default_network(self, project_id):
355357
neutron = neutronclient.Client(session=get_session_for_resource(self.resource))

0 commit comments

Comments
 (0)