Skip to content

Commit 8fc4ea6

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 Authentication to Keycloak is done via client credentials grant 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 not registered on Coldfront at all). This is relevant for functional tests for this new feature.
1 parent ff87926 commit 8fc4ea6

18 files changed

Lines changed: 257 additions & 40 deletions

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

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

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ jobs:
2121
with:
2222
python-version: 3.12
2323

24+
- name: Install Keycloak
25+
run: |
26+
bash ./ci/setup-keycloak.sh
27+
2428
- name: Install dependencies, ColdFront and plugin
2529
run: |
2630
./ci/setup.sh

ci/run_functional_tests_openshift.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
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="coldfront"
11+
export KEYCLOAK_CLIENT_SECRET="nomoresecret"
12+
813
export OPENSHIFT_MICROSHIFT_TOKEN="$(oc create token -n onboarding onboarding-serviceaccount)"
914
export OPENSHIFT_MICROSHIFT_VERIFY="false"
1015

ci/run_functional_tests_openstack.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
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="coldfront"
11+
export KEYCLOAK_CLIENT_SECRET="nomoresecret"
12+
813
export CREDENTIAL_NAME=$(openssl rand -base64 12)
914

1015
export OPENSTACK_DEVSTACK_APPLICATION_CREDENTIAL_SECRET=$(

ci/setup-keycloak.sh

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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
11+
12+
# wait for keycloak to be ready
13+
until curl -s http://localhost:8080/auth/realms/master; do
14+
echo "Waiting for Keycloak to be ready..."
15+
sleep 5
16+
done
17+
18+
# Create client and add admin role to client's service account
19+
ACCESS_TOKEN=$(curl -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \
20+
-d "username=admin" \
21+
-d "password=nomoresecret" \
22+
-d "grant_type=password" \
23+
-d "client_id=admin-cli" \
24+
-d "scope=openid" \
25+
| jq -r '.access_token')
26+
27+
28+
curl -X POST "http://localhost:8080/admin/realms/master/clients" \
29+
-H "Authorization: Bearer $ACCESS_TOKEN" \
30+
-H "Content-Type: application/json" \
31+
-d '{
32+
"clientId": "coldfront",
33+
"secret": "nomoresecret",
34+
"redirectUris": ["http://localhost:8080/*"],
35+
"serviceAccountsEnabled": true
36+
}'
37+
38+
COLDFRONT_CLIENT_ID=$(curl -X GET "http://localhost:8080/admin/realms/master/clients?clientId=coldfront" \
39+
-H "Authorization: Bearer $ACCESS_TOKEN" | jq -r '.[0].id')
40+
41+
42+
COLDFRONT_SERVICE_ACCOUNT_ID=$(curl -X GET "http://localhost:8080/admin/realms/master/clients/$COLDFRONT_CLIENT_ID/service-account-user" \
43+
-H "Authorization: Bearer $ACCESS_TOKEN" \
44+
-H "Content-Type: application/json" \
45+
| jq -r '.id')
46+
47+
ADMIN_ROLE_ID=$(curl -X GET "http://localhost:8080/admin/realms/master/roles/admin" \
48+
-H "Authorization: Bearer $ACCESS_TOKEN" | jq -r '.id')
49+
50+
# Add admin role to the service account user
51+
curl -X POST "http://localhost:8080/admin/realms/master/users/$COLDFRONT_SERVICE_ACCOUNT_ID/role-mappings/realm" \
52+
-H "Authorization: Bearer $ACCESS_TOKEN" \
53+
-H "Content-Type: application/json" \
54+
-d '[
55+
{
56+
"id": "'$ADMIN_ROLE_ID'",
57+
"name": "admin"
58+
}
59+
]'

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ python-novaclient
1212
python-neutronclient
1313
python-swiftclient
1414
pytz
15+
requests

src/coldfront_plugin_cloud/base.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
import functools
33
import json
44
from typing import NamedTuple
5+
import logging
56

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

9-
from coldfront_plugin_cloud import attributes
10+
from coldfront_plugin_cloud import attributes, kc_client
1011
from coldfront_plugin_cloud.models.quota_models import QuotaSpecs
1112

13+
logger = logging.getLogger(__name__)
14+
1215

1316
class ResourceAllocator(abc.ABC):
1417
resource_type = ""
@@ -45,6 +48,29 @@ def get_or_create_federated_user(self, username):
4548
user = self.create_federated_user(username)
4649
return user
4750

51+
def assign_role_on_user(self, username, project_id):
52+
self.kc_admin_client.create_group(project_id)
53+
if user_id := self.kc_admin_client.get_user_id(username):
54+
group_id = self.kc_admin_client.get_group_id(project_id)
55+
self.kc_admin_client.add_user_to_group(user_id, group_id)
56+
else:
57+
logger.warning(
58+
f"User {username} not found in Keycloak, cannot add to group."
59+
)
60+
61+
def remove_role_from_user(self, username, project_id):
62+
if user_id := self.kc_admin_client.get_user_id(username):
63+
group_id = self.kc_admin_client.get_group_id(project_id)
64+
self.kc_admin_client.remove_user_from_group(user_id, group_id)
65+
else:
66+
logger.warning(
67+
f"User {username} not found in Keycloak, cannot remove from group."
68+
)
69+
70+
@functools.cached_property
71+
def kc_admin_client(self):
72+
return kc_client.KeyCloakAPIClient()
73+
4874
@functools.cached_property
4975
def auth_url(self):
5076
return self.resource.get_attribute(attributes.RESOURCE_AUTH_URL).rstrip("/")
@@ -88,11 +114,3 @@ def create_federated_user(self, unique_id):
88114
@abc.abstractmethod
89115
def get_federated_user(self, unique_id):
90116
pass
91-
92-
@abc.abstractmethod
93-
def assign_role_on_user(self, username, project_id):
94-
pass
95-
96-
@abc.abstractmethod
97-
def remove_role_from_user(self, username, project_id):
98-
pass
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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.client_id = os.getenv("KEYCLOAK_CLIENT_ID", "coldfront")
12+
self.client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET", "nomoresecret")
13+
14+
self.token_url = (
15+
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token"
16+
)
17+
18+
@functools.cached_property
19+
def api_client(self):
20+
params = {
21+
"grant_type": "client_credentials",
22+
"client_id": self.client_id,
23+
"client_secret": self.client_secret,
24+
}
25+
r = requests.post(self.token_url, data=params).json()
26+
headers = {
27+
"Authorization": ("Bearer %s" % r["access_token"]),
28+
"Content-Type": "application/json",
29+
}
30+
session = requests.session()
31+
session.headers.update(headers)
32+
return session
33+
34+
def create_group(self, group_name):
35+
url = f"{self.base_url}/admin/realms/{self.realm}/groups"
36+
payload = {"name": group_name}
37+
response = self.api_client.post(url, json=payload)
38+
39+
# If group already exists, ignore and move on
40+
if response.status_code not in (201, 409):
41+
response.raise_for_status()
42+
43+
def create_user(self, cf_username):
44+
"""Helper function to create user in Keycloak, for testing purposes only"""
45+
url = f"{self.base_url}/admin/realms/{self.realm}/users"
46+
payload = {
47+
"username": cf_username,
48+
"enabled": True,
49+
"email": cf_username,
50+
}
51+
r = self.api_client.post(url, json=payload)
52+
r.raise_for_status()
53+
54+
def get_group_id(self, group_name) -> str | None:
55+
"""Return None if group not found"""
56+
query = f"search={group_name}&exact=true"
57+
url = f"{self.base_url}/admin/realms/{self.realm}/groups?{query}"
58+
r = self.api_client.get(url).json()
59+
return r[0]["id"] if r else None
60+
61+
def get_user_id(self, cf_username) -> str | None:
62+
"""Return None if user not found"""
63+
# TODO (Quan): Confirm that Coldfront usernames map to Keycloak emails, not email, or something else?
64+
query = f"username={cf_username}&exact=true"
65+
url = f"{self.base_url}/admin/realms/{self.realm}/users?{query}"
66+
r = self.api_client.get(url).json()
67+
return r[0]["id"] if r else None
68+
69+
def add_user_to_group(self, user_id, group_id):
70+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
71+
r = self.api_client.put(url)
72+
r.raise_for_status()
73+
74+
def remove_user_from_group(self, user_id, group_id):
75+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
76+
r = self.api_client.delete(url)
77+
r.raise_for_status()
78+
79+
def get_user_groups(self, user_id) -> list[str]:
80+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups"
81+
r = self.api_client.get(url)
82+
r.raise_for_status()
83+
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
@@ -49,15 +49,15 @@ def sync_users(project_id, allocation, allocator, apply):
4949
if apply:
5050
tasks.add_user_to_allocation(coldfront_user.pk)
5151

52-
# remove users that are in the resource but not in coldfront
52+
# remove users that are in the resource but not in coldfront allocation
5353
users = set(
5454
[coldfront_user.user.username for coldfront_user in coldfront_users]
5555
)
5656
for allocation_user in allocation_users:
5757
if allocation_user not in users:
5858
failed_validation = True
5959
logger.warning(
60-
f"{allocation_user} exists in the resource {project_id} but not in coldfront"
60+
f"{allocation_user} exists in the resource {project_id} but not in coldfront allocation"
6161
)
6262
if apply:
6363
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
@@ -364,6 +364,8 @@ def assign_role_on_user(self, username, project_id):
364364
# Role already exists, ignore
365365
pass
366366

367+
super().assign_role_on_user(username, project_id)
368+
367369
def remove_role_from_user(self, username, project_id):
368370
"""Remove a role from a user in a project using direct OpenShift API calls"""
369371
try:
@@ -386,6 +388,8 @@ def remove_role_from_user(self, username, project_id):
386388
# Rolebinding doesn't exist, nothing to remove
387389
pass
388390

391+
super().remove_role_from_user(username, project_id)
392+
389393
def _create_project(self, project_name, project_id):
390394
pi_username = self.allocation.project.pi.username
391395

0 commit comments

Comments
 (0)