Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test-functional-microshift.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ jobs:
run: |
bash ./ci/setup-oc-client.sh

- name: Install Keycloak
run: |
bash ./ci/setup-keycloak.sh

- name: Install Microshift
run: |
./ci/microshift.sh
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/test-functional-microstack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ jobs:
with:
python-version: 3.12

- name: Install Keycloak
run: |
bash ./ci/setup-keycloak.sh

- name: Install dependencies, ColdFront and plugin
run: |
./ci/setup.sh
Expand Down
5 changes: 5 additions & 0 deletions ci/run_functional_tests_openshift.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
# Tests expect the resource to be name Devstack
set -xe

export KEYCLOAK_BASE_URL="http://localhost:8080"
export KEYCLOAK_REALM="master"
export KEYCLOAK_CLIENT_ID="coldfront"
export KEYCLOAK_CLIENT_SECRET="nomoresecret"

export OPENSHIFT_MICROSHIFT_TOKEN="$(oc create token -n onboarding onboarding-serviceaccount)"
export OPENSHIFT_MICROSHIFT_VERIFY="false"

Expand Down
5 changes: 5 additions & 0 deletions ci/run_functional_tests_openstack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
# Tests expect the resource to be name Devstack
set -xe

export KEYCLOAK_BASE_URL="http://localhost:8080"
export KEYCLOAK_REALM="master"
export KEYCLOAK_CLIENT_ID="coldfront"
export KEYCLOAK_CLIENT_SECRET="nomoresecret"

export CREDENTIAL_NAME=$(openssl rand -base64 12)

export OPENSTACK_DEVSTACK_APPLICATION_CREDENTIAL_SECRET=$(
Expand Down
59 changes: 59 additions & 0 deletions ci/setup-keycloak.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/bin/bash

set -xe

sudo docker run -d --name keycloak \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=nomoresecret \
-p 8080:8080 \
-p 8443:8443 \
quay.io/keycloak/keycloak:25.0 start-dev

# wait for keycloak to be ready
until curl -s http://localhost:8080/auth/realms/master; do
echo "Waiting for Keycloak to be ready..."
sleep 5
done

# Create client and add admin role to client's service account
ACCESS_TOKEN=$(curl -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \
-d "username=admin" \
-d "password=nomoresecret" \
-d "grant_type=password" \
-d "client_id=admin-cli" \
-d "scope=openid" \
| jq -r '.access_token')


curl -X POST "http://localhost:8080/admin/realms/master/clients" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "coldfront",
"secret": "nomoresecret",
"redirectUris": ["http://localhost:8080/*"],
"serviceAccountsEnabled": true
}'

COLDFRONT_CLIENT_ID=$(curl -X GET "http://localhost:8080/admin/realms/master/clients?clientId=coldfront" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq -r '.[0].id')


COLDFRONT_SERVICE_ACCOUNT_ID=$(curl -X GET "http://localhost:8080/admin/realms/master/clients/$COLDFRONT_CLIENT_ID/service-account-user" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
| jq -r '.id')

ADMIN_ROLE_ID=$(curl -X GET "http://localhost:8080/admin/realms/master/roles/admin" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq -r '.id')

# Add admin role to the service account user
curl -X POST "http://localhost:8080/admin/realms/master/users/$COLDFRONT_SERVICE_ACCOUNT_ID/role-mappings/realm" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '[
{
"id": "'$ADMIN_ROLE_ID'",
"name": "admin"
}
]'
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ python-novaclient
python-neutronclient
python-swiftclient
pytz
requests
36 changes: 27 additions & 9 deletions src/coldfront_plugin_cloud/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
import functools
import json
from typing import NamedTuple
import logging

from coldfront.core.allocation import models as allocation_models
from coldfront.core.resource import models as resource_models

from coldfront_plugin_cloud import attributes
from coldfront_plugin_cloud import attributes, kc_client
from coldfront_plugin_cloud.models.quota_models import QuotaSpecs

logger = logging.getLogger(__name__)


class ResourceAllocator(abc.ABC):
resource_type = ""
Expand Down Expand Up @@ -45,6 +48,29 @@ def get_or_create_federated_user(self, username):
user = self.create_federated_user(username)
return user

def assign_role_on_user(self, username, project_id):
self.kc_admin_client.create_group(project_id)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using the group ID, how about introducing a new Resource Attribute to Resources that accepts a format string. For example defaulting to "{resource_name}/{project_name}"

This would allow operator to specify their own format for group names for the clusters.

So you'd read the resource attribute and then call format on the string providing a few documented options for the available variables.

>>> template = "{resource_name}/{project_name}"
>>> group = template.format(**{"resource_name": "NERC-OCP", "project_name": "1234"}
... )
>>> 
>>> group
'NERC-OCP/1234'

if user_id := self.kc_admin_client.get_user_id(username):
group_id = self.kc_admin_client.get_group_id(project_id)
self.kc_admin_client.add_user_to_group(user_id, group_id)
else:
logger.warning(
f"User {username} not found in Keycloak, cannot add to group."
)

def remove_role_from_user(self, username, project_id):
if user_id := self.kc_admin_client.get_user_id(username):
group_id = self.kc_admin_client.get_group_id(project_id)
self.kc_admin_client.remove_user_from_group(user_id, group_id)
else:
logger.warning(
f"User {username} not found in Keycloak, cannot remove from group."
)

@functools.cached_property
def kc_admin_client(self):
return kc_client.KeyCloakAPIClient()

@functools.cached_property
def auth_url(self):
return self.resource.get_attribute(attributes.RESOURCE_AUTH_URL).rstrip("/")
Expand Down Expand Up @@ -88,11 +114,3 @@ def create_federated_user(self, unique_id):
@abc.abstractmethod
def get_federated_user(self, unique_id):
pass

@abc.abstractmethod
def assign_role_on_user(self, username, project_id):
pass

@abc.abstractmethod
def remove_role_from_user(self, username, project_id):
pass
83 changes: 83 additions & 0 deletions src/coldfront_plugin_cloud/kc_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import os
import functools

import requests


class KeyCloakAPIClient:
def __init__(self):
self.base_url = os.getenv("KEYCLOAK_BASE_URL")
self.realm = os.getenv("KEYCLOAK_REALM")
self.client_id = os.getenv("KEYCLOAK_CLIENT_ID", "coldfront")
self.client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET", "nomoresecret")

self.token_url = (
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token"
)

@functools.cached_property
def api_client(self):
params = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
}
r = requests.post(self.token_url, data=params).json()
headers = {
"Authorization": ("Bearer %s" % r["access_token"]),
"Content-Type": "application/json",
}
session = requests.session()
session.headers.update(headers)
return session

def create_group(self, group_name):
url = f"{self.base_url}/admin/realms/{self.realm}/groups"
payload = {"name": group_name}
response = self.api_client.post(url, json=payload)

# If group already exists, ignore and move on
if response.status_code not in (201, 409):
response.raise_for_status()

def create_user(self, cf_username):
"""Helper function to create user in Keycloak, for testing purposes only"""
url = f"{self.base_url}/admin/realms/{self.realm}/users"
payload = {
"username": cf_username,
"enabled": True,
"email": cf_username,
}
r = self.api_client.post(url, json=payload)
r.raise_for_status()

def get_group_id(self, group_name) -> str | None:
"""Return None if group not found"""
query = f"search={group_name}&exact=true"
url = f"{self.base_url}/admin/realms/{self.realm}/groups?{query}"
r = self.api_client.get(url).json()
return r[0]["id"] if r else None

def get_user_id(self, cf_username) -> str | None:
"""Return None if user not found"""
# TODO (Quan): Confirm that Coldfront usernames map to Keycloak emails, not email, or something else?
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@QuanMPhm Coldfront usernames map to Keycloak usernames, not emails.

query = f"username={cf_username}&exact=true"
url = f"{self.base_url}/admin/realms/{self.realm}/users?{query}"
r = self.api_client.get(url).json()
return r[0]["id"] if r else None

def add_user_to_group(self, user_id, group_id):
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
r = self.api_client.put(url)
r.raise_for_status()

def remove_user_from_group(self, user_id, group_id):
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}"
r = self.api_client.delete(url)
r.raise_for_status()

def get_user_groups(self, user_id) -> list[str]:
url = f"{self.base_url}/admin/realms/{self.realm}/users/{user_id}/groups"
r = self.api_client.get(url)
r.raise_for_status()
return [group["name"] for group in r.json()]
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ def sync_users(project_id, allocation, allocator, apply):
if apply:
tasks.add_user_to_allocation(coldfront_user.pk)

# remove users that are in the resource but not in coldfront
# remove users that are in the resource but not in coldfront allocation
users = set(
[coldfront_user.user.username for coldfront_user in coldfront_users]
)
for allocation_user in allocation_users:
if allocation_user not in users:
failed_validation = True
logger.warning(
f"{allocation_user} exists in the resource {project_id} but not in coldfront"
f"{allocation_user} exists in the resource {project_id} but not in coldfront allocation"
)
if apply:
allocator.remove_role_from_user(allocation_user, project_id)
Expand Down
4 changes: 4 additions & 0 deletions src/coldfront_plugin_cloud/openshift.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@ def assign_role_on_user(self, username, project_id):
# Role already exists, ignore
pass

super().assign_role_on_user(username, project_id)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of the resource allocators, I think this should be handled in

def add_user_to_allocation(allocation_user_pk):


def remove_role_from_user(self, username, project_id):
"""Remove a role from a user in a project using direct OpenShift API calls"""
try:
Expand All @@ -386,6 +388,8 @@ def remove_role_from_user(self, username, project_id):
# Rolebinding doesn't exist, nothing to remove
pass

super().remove_role_from_user(username, project_id)

def _create_project(self, project_name, project_id):
pi_username = self.allocation.project.pi.username

Expand Down
2 changes: 2 additions & 0 deletions src/coldfront_plugin_cloud/openstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,14 @@ def assign_role_on_user(self, username, project_id):

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

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

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

def create_default_network(self, project_id):
neutron = neutronclient.Client(session=get_session_for_resource(self.resource))
Expand Down
17 changes: 12 additions & 5 deletions src/coldfront_plugin_cloud/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from coldfront.core.field_of_science.models import FieldOfScience
from django.core.management import call_command

from coldfront_plugin_cloud import kc_client


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

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

@staticmethod
def new_esi_resource(name=None, auth_url=None) -> Resource:
Expand Down Expand Up @@ -104,6 +102,15 @@ def new_openshift_resource(
)
return Resource.objects.get(name=resource_name)

def new_user(self, username=None, add_to_keycloak=True) -> User:
username = username or f"{uuid.uuid4().hex}@example.com"
User.objects.create(username=username, email=username)

if add_to_keycloak:
self.kc_admin_client.create_user(username)

return User.objects.get(username=username)

def new_project(self, title=None, pi=None) -> Project:
title = title or uuid.uuid4().hex
pi = pi or self.new_user()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import os
import time
import unittest
import uuid

from coldfront_plugin_cloud import attributes, openshift, tasks, utils
from coldfront_plugin_cloud.tests import base
Expand Down Expand Up @@ -51,7 +50,13 @@ def test_new_allocation(self):

allocator._get_role(user.username, project_id)

# Check Keycloak group and user membership
self.kc_admin_client.get_group_id(project_id)
user_id = self.kc_admin_client.get_user_id(user.username)
assert project_id in self.kc_admin_client.get_user_groups(user_id)

allocator.remove_role_from_user(user.username, project_id)
assert project_id not in self.kc_admin_client.get_user_groups(user_id)

with self.assertRaises(openshift.NotFound):
allocator._get_role(user.username, project_id)
Expand Down Expand Up @@ -108,7 +113,7 @@ def test_add_remove_user(self):

# directly add a user to openshift which should then be
# deleted when validate_allocations is called
non_coldfront_user = uuid.uuid4().hex
non_coldfront_user = self.new_user(add_to_keycloak=True).username
allocator.get_or_create_federated_user(non_coldfront_user)
allocator.assign_role_on_user(non_coldfront_user, project_id)
assert non_coldfront_user in allocator.get_users(project_id)
Expand Down
Loading
Loading