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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"mitol-django-google_sheets_deferrals",
"mitol-django-google_sheets_refunds",
"mitol-django-hubspot_api",
"mitol-django-keycloak",
"mitol-django-oauth_toolkit_extensions",
"mitol-django-olposthog",
"mitol-django-openedx",
Expand Down Expand Up @@ -114,6 +115,7 @@ mitol-django-google_sheets = { workspace = true }
mitol-django-google_sheets_deferrals = { workspace = true }
mitol-django-google_sheets_refunds = { workspace = true }
mitol-django-hubspot_api = { workspace = true }
mitol-django-keycloak = { workspace = true }
mitol-django-oauth_toolkit_extensions = { workspace = true }
mitol-django-olposthog = { workspace = true }
mitol-django-openedx = { workspace = true }
Expand Down
7 changes: 7 additions & 0 deletions src/keycloak/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses date-based versioning.

<!-- scriv-insert-here -->
11 changes: 11 additions & 0 deletions src/keycloak/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
mitol-django-keycloak
---

This is the Open Learning Django Keycloak app.

### Getting started

`pip install mitol-django-keycloak`


### Configuration
41 changes: 41 additions & 0 deletions src/keycloak/changelog.d/20260513_155008_nlevesq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!--
A new scriv changelog fragment.

Uncomment the section that is right (remove the HTML comment wrapper).
For top level release notes, leave all the headers commented out.
-->

<!--
### Removed

- A bullet item for the Removed category.

-->
### Added

- Added the initial implementation of `mitol-django-keycloak`.

<!--
### Changed

- A bullet item for the Changed category.

-->
<!--
### Deprecated

- A bullet item for the Deprecated category.

-->
<!--
### Fixed

- A bullet item for the Fixed category.

-->
<!--
### Security

- A bullet item for the Security category.

-->
5 changes: 5 additions & 0 deletions src/keycloak/changelog.d/scriv.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[scriv]
format = md
md_header_level = 2
entry_title_template = file: ../../scripts/scriv/entry_title.${config:format}.j2
version = literal: __init__.py: __version__
6 changes: 6 additions & 0 deletions src/keycloak/mitol/keycloak/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""mitol.common"""

default_app_config = "mitol.common.apps.KeycloakApiApp"

__version__ = "2026.4.29"
__distributionname__ = "mitol-django-keycloak-api"
47 changes: 47 additions & 0 deletions src/keycloak/mitol/keycloak/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from django.conf import settings
from mitol.keycloak.constants import READONLY_USER_ATTRIBUTES, REQUIRED_CLIENT_SETTINGS
from mitol.keycloak.data_models import UserAttributes

from keycloak import KeycloakAdmin
from keycloak.openid_connection import KeycloakOpenIDConnection


def get_admin_client() -> KeycloakAdmin:
connection = KeycloakOpenIDConnection(
server_url=settings.MITOL_KEYCLOAK_BASE_URL,
realm_name=settings.MITOL_KEYCLOAK_REALM_NAME,
client_id=settings.MITOL_KEYCLOAK_ADMIN_CLIENT_ID,
client_secret_key=settings.MITOL_KEYCLOAK_ADMIN_CLIENT_SECRET,
)
return KeycloakAdmin(connection=connection)


def is_admin_client_configured() -> bool:
"""
Return True if the admin client is configured
"""
client = get_admin_client()

for prop in REQUIRED_CLIENT_SETTINGS:
if getattr(client, prop, None) is None:
return False
return True


def update_user(uuid: str, *, attributes: UserAttributes):
"""
Update a user
"""
client = get_admin_client()

# Keycloak doesn't support PATCH, instead it only has PUT which overwrites the user
# with whatever payload we send. So we mimic what would happen in a keycloak admin
# ui by loading the profile and then updating the attributes.
payload = client.get_user(uuid)

for attr in READONLY_USER_ATTRIBUTES:
payload.pop(attr, None)

payload["attributes"].update(attributes.model_dump(exclude_none=True))

client.update_user(uuid, payload)
16 changes: 16 additions & 0 deletions src/keycloak/mitol/keycloak/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Common app AppConfigs"""

import os

from django.apps import AppConfig


class KeycloakApp(AppConfig):
"""Default configuration for the keycloakapp"""

name = "mitol.keycloak"
label = "keycloak"
verbose_name = "Keycloak"

# necessary because this is a namespaced app
path = os.path.dirname(os.path.abspath(__file__)) # noqa: PTH100, PTH120
15 changes: 15 additions & 0 deletions src/keycloak/mitol/keycloak/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
READONLY_USER_ATTRIBUTES = (
"userProfileMetadata",
"access",
"notBefore",
"totp",
"disableableCredentialTypes",
"requiredActions",
"createdTimestamp",
)
REQUIRED_CLIENT_SETTINGS = (
"server_url",
"realm_name",
"client_id",
"client_secret_key",
)
20 changes: 20 additions & 0 deletions src/keycloak/mitol/keycloak/data_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Annotated, Any

from pydantic import BaseModel, ConfigDict, Field, field_serializer


class UserAttributes(BaseModel):
"""
Pydantic model for Keycloak user attributes
"""

full_name: Annotated[
str | None,
Field(serialization_alias="fullName", exclude_if=lambda v: v is None),
] = None

@field_serializer("*", mode="plain")
def as_array(self, value: Any) -> list[Any]:
return [value]

model_config = ConfigDict(serialize_by_alias=True, frozen=True)
Empty file.
Empty file.
55 changes: 55 additions & 0 deletions src/keycloak/mitol/keycloak/settings/keycloak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from mitol.common.envs import get_bool, get_string

MITOL_KEYCLOAK_BASE_URL = get_string(
name="MITOL_KEYCLOAK_BASE_URL",
default="http://mit-keycloak-base-url.edu",
description="Base URL for the Keycloak instance.",
)

MITOL_KEYCLOAK_REALM_NAME = get_string(
name="MITOL_KEYCLOAK_REALM_NAME",
default="olapps",
description="Name of the realm the app uses in Keycloak.",
)

MITOL_KEYCLOAK_CLIENT_ID = get_string(
name="MITOL_KEYCLOAK_CLIENT_ID",
default=None,
description="The client name for mitxonline.",
)

MITOL_KEYCLOAK_CLIENT_SECRET = get_string(
name="MITOL_KEYCLOAK_CLIENT_SECRET",
default=None,
description="The client secret for mitxonline.",
)

MITOL_KEYCLOAK_DISCOVERY_URL = get_string(
name="MITOL_KEYCLOAK_DISCOVERY_URL",
default=None,
description="The OpenID discovery URL for the Keycloak realm.",
)

MITOL_KEYCLOAK_ADMIN_CLIENT_ID = get_string(
name="MITOL_KEYCLOAK_ADMIN_CLIENT_ID",
default=None,
description="The client name for the admin client.",
)

MITOL_KEYCLOAK_ADMIN_CLIENT_SECRET = get_string(
name="MITOL_KEYCLOAK_ADMIN_CLIENT_SECRET",
default=None,
description="The client secret for the admin client.",
)

MITOL_KEYCLOAK_ADMIN_CLIENT_SCOPES = get_string(
name="MITOL_KEYCLOAK_ADMIN_CLIENT_SCOPES",
default=None,
description="The OpenID scopes to use for the admin client.",
)

MITOL_KEYCLOAK_ADMIN_CLIENT_NO_VERIFY_SSL = get_bool(
name="MITOL_KEYCLOAK_ADMIN_CLIENT_NO_VERIFY_SSL",
default=False,
description="If true, do not verify SSL certificates for the admin client.",
)
42 changes: 42 additions & 0 deletions src/keycloak/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[project]
name = "mitol-django-keycloak"
description = "MIT Open Learning django app extensions for keycloak integration"
version = "2026.4.29"
dependencies = [
"django>=4.2",
"pydantic",
"python-keycloak>=7.1.1,<8",
]
readme = "README.md"
license = "BSD-3-Clause"
requires-python = ">=3.11"

[dependency-groups]
dev = [
"factory-boy~=3.2",
"pytest>=9,<10",
]

[tool.bumpver]
current_version = "2026.4.29"
version_pattern = "YYYY.MM.DD[.INC0]"

[tool.bumpver.file_patterns]
"pyproject.toml" = [
'version = "{version}"',
]
"mitol/common/__init__.py" = [
'__version__ = "{version}"',
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.sdist]
include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"]
exclude = ["BUILD", "pyproject.toml"]

[tool.hatch.build.targets.wheel]
include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"]
exclude = ["BUILD", "pyproject.toml"]
41 changes: 41 additions & 0 deletions src/scim/changelog.d/20260519_154812_nlevesq_add_keycloak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!--
A new scriv changelog fragment.

Uncomment the section that is right (remove the HTML comment wrapper).
For top level release notes, leave all the headers commented out.
-->

<!--
### Removed

- A bullet item for the Removed category.

-->
<!--
### Added

- A bullet item for the Added category.

-->
### Changed

- Added logging of global_id when a user is created/updated.

<!--
### Deprecated

- A bullet item for the Deprecated category.

-->
<!--
### Fixed

- A bullet item for the Fixed category.

-->
<!--
### Security

- A bullet item for the Security category.

-->
4 changes: 3 additions & 1 deletion src/scim/mitol/scim/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ def save(self):
# user must be saved first due to FK Profile -> User
self._save_user()
self._save_related()
logger.info("User saved. User id %i", self.obj.id)
logger.info(
"User saved. User id=%i, global_id=%s", self.obj.id, self.obj.global_id
)

def delete(self):
"""
Expand Down
1 change: 1 addition & 0 deletions testapp/main/settings/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"mitol.apigateway.settings",
"mitol.olposthog.settings.olposthog",
"mitol.observability.settings.logging",
"mitol.keycloak.settings.keycloak",
)

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
Expand Down
Empty file added tests/key_cloak/__init__.py
Empty file.
Loading