Skip to content

Commit 6cb51d7

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 using a format string defined in the allocation's resource attribute "Format String for Keystone Group Names" If the user does not already exist in Keycloak, the case is ignored for now Keycloak integration is optional, toggled by setting the env var "KEYCLOAK_BASE_URL" Authentication to Keycloak is done via client credentials grant When `deactivate_allocation` is called, the user is removed from the Keycloak group New functional test added for Keycloak integration 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).
2 parents 0bfb505 + 30236f0 commit 6cb51d7

17 files changed

Lines changed: 848 additions & 288 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: test-functional-keycloak
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-22.04
12+
13+
steps:
14+
- uses: actions/checkout@v6
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v6
18+
with:
19+
python-version: 3.12
20+
21+
- name: Upgrade and install packages
22+
run: |
23+
bash ./ci/setup-ubuntu.sh
24+
25+
- name: Install Keycloak
26+
run: |
27+
bash ./ci/setup-keycloak.sh
28+
29+
- name: Install ColdFront and plugin
30+
run: |
31+
./ci/setup.sh
32+
33+
- name: Run functional tests
34+
run: |
35+
./ci/run_functional_tests_keycloak.sh
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/bash
2+
set -xe
3+
4+
if [[ ! "${CI}" == "true" ]]; then
5+
source /tmp/coldfront_venv/bin/activate
6+
fi
7+
8+
export DJANGO_SETTINGS_MODULE="local_settings"
9+
export PYTHONWARNINGS="ignore:Unverified HTTPS request"
10+
11+
export KEYCLOAK_BASE_URL="http://localhost:8080"
12+
export KEYCLOAK_REALM="master"
13+
export KEYCLOAK_CLIENT_ID="coldfront"
14+
export KEYCLOAK_CLIENT_SECRET="nomoresecret"
15+
16+
coverage run --source="." -m django test coldfront_plugin_cloud.tests.functional.keycloak
17+
coverage report

ci/setup-keycloak.sh

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

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/attributes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ class CloudAllocationAttribute:
2424
RESOURCE_API_URL = "OpenShift API Endpoint URL"
2525
RESOURCE_IDENTITY_NAME = "OpenShift Identity Provider Name"
2626
RESOURCE_ROLE = "Role for User in Project"
27-
RESOURCE_QUOTA_RESOURCES = "Available Quota Resources"
2827

2928
RESOURCE_FEDERATION_PROTOCOL = "OpenStack Federation Protocol"
3029
RESOURCE_IDP = "OpenStack Identity Provider"
@@ -35,6 +34,8 @@ class CloudAllocationAttribute:
3534

3635
RESOURCE_EULA_URL = "EULA URL"
3736
RESOURCE_CLUSTER_NAME = "Internal Cluster Name"
37+
RESOURCE_QUOTA_RESOURCES = "Available Quota Resources"
38+
RESOURCE_KEYCLOAK_GROUP_TEMPLATE = "Template String for Keycloak Group Names"
3839

3940
RESOURCE_ATTRIBUTES = [
4041
CloudResourceAttribute(name=RESOURCE_AUTH_URL),
@@ -45,6 +46,7 @@ class CloudAllocationAttribute:
4546
CloudResourceAttribute(name=RESOURCE_PROJECT_DOMAIN),
4647
CloudResourceAttribute(name=RESOURCE_ROLE),
4748
CloudResourceAttribute(name=RESOURCE_QUOTA_RESOURCES),
49+
CloudResourceAttribute(name=RESOURCE_KEYCLOAK_GROUP_TEMPLATE),
4850
CloudResourceAttribute(name=RESOURCE_USER_DOMAIN),
4951
CloudResourceAttribute(name=RESOURCE_EULA_URL),
5052
CloudResourceAttribute(name=RESOURCE_DEFAULT_PUBLIC_NETWORK),

src/coldfront_plugin_cloud/base.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import abc
22
import functools
33
import json
4+
import logging
45
from typing import NamedTuple
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, utils
1011
from coldfront_plugin_cloud.models.quota_models import QuotaSpecs
1112

1213

14+
logger = logging.getLogger(__name__)
15+
16+
1317
class ResourceAllocator(abc.ABC):
1418
resource_type = ""
1519

@@ -45,6 +49,94 @@ def get_or_create_federated_user(self, username):
4549
user = self.create_federated_user(username)
4650
return user
4751

52+
def set_default_quota_on_allocation(self, coldfront_attr):
53+
resource_quotaspecs = self.resource_quotaspecs
54+
value = resource_quotaspecs.root[coldfront_attr].quota_by_su_quantity(
55+
self.allocation.quantity
56+
)
57+
utils.set_attribute_on_allocation(self.allocation, coldfront_attr, value)
58+
return value
59+
60+
def set_users(self, project_id, apply):
61+
coldfront_users = allocation_models.AllocationUser.objects.filter(
62+
allocation=self.allocation, status__name="Active"
63+
)
64+
cluster_users = self.get_users(project_id)
65+
failed_validation = False
66+
67+
# Create users that exist in coldfront but not in the resource
68+
for coldfront_user in coldfront_users:
69+
coldfront_username = coldfront_user.user.username
70+
if coldfront_username not in cluster_users:
71+
failed_validation = True
72+
logger.info(f"{coldfront_username} is not part of {project_id}")
73+
if apply:
74+
self.get_or_create_federated_user(coldfront_username)
75+
self.assign_role_on_user(coldfront_username, project_id)
76+
77+
# remove users that are in the resource but not in coldfront
78+
users = set(
79+
[coldfront_user.user.username for coldfront_user in coldfront_users]
80+
)
81+
for allocation_user in cluster_users:
82+
if allocation_user not in users:
83+
failed_validation = True
84+
logger.info(
85+
f"{allocation_user} exists in the resource {project_id} but not in coldfront"
86+
)
87+
if apply:
88+
self.remove_role_from_user(allocation_user, project_id)
89+
90+
return failed_validation
91+
92+
def check_and_apply_quota_attr(
93+
self,
94+
attr: str,
95+
expected_quota: int | None,
96+
current_quota: int | None,
97+
apply: bool,
98+
) -> bool:
99+
failed_validation = False
100+
if current_quota is None and expected_quota is None:
101+
msg = (
102+
f"Value for quota for {attr} is not set anywhere"
103+
f" on {self.allocation_str}"
104+
)
105+
failed_validation = True
106+
107+
if apply:
108+
expected_quota = self.set_default_quota_on_allocation(attr)
109+
msg = f"Added default quota for {attr} to {self.allocation_str} to {expected_quota}"
110+
logger.info(msg)
111+
elif current_quota is not None and expected_quota is None:
112+
msg = (
113+
f'Attribute "{attr}" expected on {self.allocation_str} but not set.'
114+
f" Current quota is {current_quota}."
115+
)
116+
117+
if apply:
118+
utils.set_attribute_on_allocation(self.allocation, attr, current_quota)
119+
120+
# To pass `current_quota != expected_quota` check
121+
expected_quota = current_quota
122+
123+
msg = f"{msg} Attribute set to match current quota."
124+
logger.info(msg)
125+
126+
if current_quota != expected_quota:
127+
msg = (
128+
f"Value for quota for {attr} = {current_quota} does not match expected"
129+
f" value of {expected_quota} on {self.allocation_str}"
130+
)
131+
logger.info(msg)
132+
failed_validation = True
133+
134+
return failed_validation
135+
136+
@functools.cached_property
137+
def allocation_str(self):
138+
return f'allocation {self.allocation.pk} of project "{self.allocation.project.title}"'
139+
48140
@functools.cached_property
49141
def auth_url(self):
50142
return self.resource.get_attribute(attributes.RESOURCE_AUTH_URL).rstrip("/")
@@ -54,7 +146,11 @@ def member_role_name(self):
54146
return self.resource.get_attribute(attributes.RESOURCE_ROLE) or "member"
55147

56148
@abc.abstractmethod
57-
def set_project_configuration(self, project_id, dry_run=False):
149+
def set_project_configuration(self, project_id, apply=True):
150+
pass
151+
152+
@abc.abstractmethod
153+
def get_project(self, project_id):
58154
pass
59155

60156
@abc.abstractmethod
@@ -85,6 +181,10 @@ def get_quota(self, project_id):
85181
def create_federated_user(self, unique_id):
86182
pass
87183

184+
@abc.abstractmethod
185+
def get_users(self, unique_id):
186+
pass
187+
88188
@abc.abstractmethod
89189
def get_federated_user(self, unique_id):
90190
pass
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import os
2+
3+
import requests
4+
5+
6+
class KeyCloakAPIClient:
7+
def __init__(self):
8+
self.base_url = os.getenv("KEYCLOAK_BASE_URL")
9+
self.realm = os.getenv("KEYCLOAK_REALM")
10+
self.client_id = os.getenv("KEYCLOAK_CLIENT_ID")
11+
self.client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
12+
13+
self.token_url = (
14+
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token"
15+
)
16+
17+
@property
18+
def api_client(self):
19+
params = {
20+
"grant_type": "client_credentials",
21+
"client_id": self.client_id,
22+
"client_secret": self.client_secret,
23+
}
24+
r = requests.post(self.token_url, data=params)
25+
r.raise_for_status()
26+
headers = {
27+
"Authorization": ("Bearer %s" % r.json()["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 get_group_id(self, group_name) -> str | None:
44+
"""Return None if group not found"""
45+
query = f"search={group_name}&exact=true"
46+
url = f"{self.base_url}/admin/realms/{self.realm}/groups?{query}"
47+
r = self.api_client.get(url)
48+
r.raise_for_status()
49+
r_json = r.json()
50+
return r_json[0]["id"] if r_json else None
51+
52+
def get_user_id(self, cf_username) -> str | None:
53+
"""Return None if user not found"""
54+
# (Quan) Coldfront usernames map to Keycloak usernames
55+
# https://github.com/nerc-project/coldfront-plugin-cloud/pull/249#discussion_r2953393852
56+
query = f"username={cf_username}&exact=true"
57+
url = f"{self.base_url}/admin/realms/{self.realm}/users?{query}"
58+
r = self.api_client.get(url)
59+
r.raise_for_status()
60+
r_json = r.json()
61+
return r_json[0]["id"] if r_json else None
62+
63+
def add_user_to_group(self, user_id, group_id):
64+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
65+
r = self.api_client.put(url)
66+
r.raise_for_status()
67+
68+
def remove_user_from_group(self, user_id, group_id):
69+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
70+
r = self.api_client.delete(url)
71+
r.raise_for_status()
72+
73+
def get_user_groups(self, user_id) -> list[str]:
74+
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups"
75+
r = self.api_client.get(url)
76+
r.raise_for_status()
77+
return [group["name"] for group in r.json()]

0 commit comments

Comments
 (0)