Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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: 1 addition & 1 deletion meta
1 change: 1 addition & 0 deletions openslides_backend/action/actions/user/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
participant_import,
participant_json_upload,
reset_password_to_default,
save_keycloak_account,
save_saml_account,
send_invitation_email,
set_password,
Expand Down
17 changes: 13 additions & 4 deletions openslides_backend/action/actions/user/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
from ...util.register import register_action
from ...util.typing import ActionResultElement
from ..meeting_user.mixin import CheckLockOutPermissionMixin
from .keycloak_sync_mixin import KeycloakCreateSyncMixin
from .password_mixins import SetPasswordMixin
from .user_mixins import LimitOfUserMixin, UserMixin, UsernameMixin, check_gender_exists


@register_action("user.create")
class UserCreate(
KeycloakCreateSyncMixin,
UserMixin,
EmailCheckMixin,
CreateAction,
Expand Down Expand Up @@ -57,6 +59,7 @@ class UserCreate(
"committee_management_ids",
"is_demo_user",
"saml_id",
"keycloak_id",
"member_number",
"external",
"home_committee_id",
Expand All @@ -77,27 +80,33 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]:
if instance.get("is_active"):
self.check_limit_of_user(1)
saml_id = instance.get("saml_id")
keycloak_id = instance.get("keycloak_id")
is_sso_user = saml_id or keycloak_id
if not instance.get("username"):
if saml_id:
instance["username"] = saml_id
elif keycloak_id:
instance["username"] = keycloak_id
else:
if not (instance.get("first_name") or instance.get("last_name")):
raise ActionException("Need username or first_name or last_name")
instance["username"] = self.generate_username(instance)
elif re.search(r"\s", instance["username"]):
raise ActionException("Username may not contain spaces")
self.check_locking_status(instance.get("meeting_id"), instance, None, None)
# Generate default_password BEFORE super() so KeycloakCreateSyncMixin can use it
if not is_sso_user and not instance.get("default_password"):
instance["default_password"] = get_random_password()
instance = super().update_instance(instance)
if saml_id:
if is_sso_user:
instance["can_change_own_password"] = False
instance["password"] = None
if instance.get("default_password"):
sso_id = saml_id or keycloak_id
raise ActionException(
f"user {instance['saml_id']} is a Single Sign On user and may not set the local default_passwort or the right to change it locally."
f"user {sso_id} is a Single Sign On user and may not set the local default_passwort or the right to change it locally."
)
else:
if not instance.get("default_password"):
instance["default_password"] = get_random_password()
self.reset_password(instance)
instance["organization_id"] = ONE_ORGANIZATION_ID
check_gender_exists(self.datastore, instance)
Expand Down
5 changes: 4 additions & 1 deletion openslides_backend/action/actions/user/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@
from ...generics.delete import DeleteAction
from ...util.default_schema import DefaultSchema
from ...util.register import register_action
from .keycloak_sync_mixin import KeycloakDeleteSyncMixin
from .user_mixins import AdminIntegrityCheckMixin


@register_action("user.delete")
class UserDelete(UserScopeMixin, DeleteAction, AdminIntegrityCheckMixin):
class UserDelete(
KeycloakDeleteSyncMixin, UserScopeMixin, DeleteAction, AdminIntegrityCheckMixin
):
"""
Action to delete a user.
"""
Expand Down
24 changes: 23 additions & 1 deletion openslides_backend/action/actions/user/generate_new_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
from ...util.crypto import get_random_password
from ...util.default_schema import DefaultSchema
from ...util.register import register_action
from ...util.typing import ActionResultElement
from .keycloak_sync_mixin import KeycloakPasswordSyncMixin
from .password_mixins import ClearSessionsMixin, SetPasswordMixin


@register_action("user.generate_new_password")
class UserGenerateNewPassword(
KeycloakPasswordSyncMixin,
SetPasswordMixin,
CheckForArchivedMeetingMixin,
UserScopeMixin,
Expand All @@ -24,6 +27,13 @@ class UserGenerateNewPassword(
schema = DefaultSchema(User()).get_update_schema()
permission = OrganizationManagementLevel.CAN_MANAGE_USERS

# Store the generated password to return in the result
_generated_passwords: dict[int, str]

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._generated_passwords = {}

def check_permissions(self, instance: dict[str, Any]) -> None:
self.check_permissions_for_scope(
instance["id"], meeting_permission=Permissions.User.CAN_UPDATE
Expand All @@ -33,7 +43,19 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]:
"""
Generates new password and call the super code.
"""
instance["password"] = get_random_password()
password = get_random_password()
instance["password"] = password
instance["set_as_default"] = True
# Store for returning in result
self._generated_passwords[instance["id"]] = password
self.set_password(instance)
return instance

def create_action_result_element(
self, instance: dict[str, Any]
) -> ActionResultElement | None:
"""Return the generated password so the client can display it."""
user_id = instance.get("id")
if user_id and user_id in self._generated_passwords:
return {"id": user_id, "password": self._generated_passwords[user_id]}
return {"id": user_id}
224 changes: 224 additions & 0 deletions openslides_backend/action/actions/user/keycloak_sync_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
from typing import Any

from ....shared.keycloak_admin_client import (
KeycloakAdminClient,
get_keycloak_admin_client,
)
from ....shared.patterns import fqid_from_collection_and_id
from ...action import Action

# Fields that are synchronized to Keycloak (Keycloak-leading)
# Maps OpenSlides field names to Keycloak field names
KEYCLOAK_SYNC_FIELDS = {
"email": "email",
"username": "username",
"is_active": "enabled",
"first_name": "firstName",
"last_name": "lastName",
}


class KeycloakSyncMixin(Action):
"""
Synchronizes user changes to Keycloak BEFORE the DB commit.

When updating users that have a keycloak_id and Keycloak sync is enabled,
this mixin will update the corresponding Keycloak user first. If the
Keycloak update fails, the entire action fails.
"""

def _get_keycloak_client(self) -> KeycloakAdminClient | None:
return get_keycloak_admin_client(self.logger)

def _get_keycloak_id(self, user_id: int) -> str | None:
"""Get keycloak_id for a user."""
user = self.datastore.get(
fqid_from_collection_and_id("user", user_id),
["keycloak_id"],
lock_result=False,
)
return user.get("keycloak_id")

def _has_keycloak_sync_fields(self, instance: dict[str, Any]) -> bool:
"""Check if any Keycloak-leading fields are being changed."""
return any(field in instance for field in KEYCLOAK_SYNC_FIELDS)

def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]:
"""Sync to Keycloak BEFORE DB update for Keycloak-leading fields."""
instance = super().update_instance(instance)

user_id = instance.get("id")
if not user_id:
return instance

# Only sync if Keycloak-leading fields are being changed
if not self._has_keycloak_sync_fields(instance):
return instance

keycloak_id = self._get_keycloak_id(user_id)
if not keycloak_id:
return instance # Not a Keycloak user

client = self._get_keycloak_client()
if not client:
return instance # Keycloak sync not enabled

# Build Keycloak update data
kc_data: dict[str, Any] = {}
for os_field, kc_field in KEYCLOAK_SYNC_FIELDS.items():
if os_field in instance:
kc_data[kc_field] = instance[os_field]

if kc_data:
# Raises ActionException on failure -> action fails
client.update_user(keycloak_id, kc_data)

return instance


class KeycloakDeleteSyncMixin(Action):
"""
Deletes user in Keycloak BEFORE the DB delete.

When deleting users that have a keycloak_id and Keycloak sync is enabled,
this mixin will delete the corresponding Keycloak user first. If the
Keycloak deletion fails, the entire action fails.
"""

def _get_keycloak_client(self) -> KeycloakAdminClient | None:
return get_keycloak_admin_client(self.logger)

def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]:
"""Delete user in Keycloak BEFORE DB delete."""
instance = super().update_instance(instance)

user_id = instance.get("id")
if not user_id:
return instance

user = self.datastore.get(
fqid_from_collection_and_id("user", user_id),
["keycloak_id"],
lock_result=False,
)
keycloak_id = user.get("keycloak_id")

if not keycloak_id:
return instance # Not a Keycloak user

client = self._get_keycloak_client()
if not client:
return instance # Keycloak sync not enabled

# Raises ActionException on failure -> deletion fails
client.delete_user(keycloak_id)

return instance


class KeycloakCreateSyncMixin(Action):
"""
Creates user in Keycloak during user.create action.

When OIDC admin API is enabled and no keycloak_id is provided,
this mixin will create a corresponding Keycloak user and store
the returned keycloak_id on the OpenSlides user.
"""

def _get_keycloak_client(self) -> KeycloakAdminClient | None:
return get_keycloak_admin_client(self.logger)

def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]:
"""Create user in Keycloak and store keycloak_id."""
instance = super().update_instance(instance)

# Skip if user already has a keycloak_id (SSO user) or saml_id
if instance.get("keycloak_id") or instance.get("saml_id"):
return instance

client = self._get_keycloak_client()
if not client:
return instance # Keycloak sync not enabled

# Build Keycloak user data
kc_data: dict[str, Any] = {}
for os_field, kc_field in KEYCLOAK_SYNC_FIELDS.items():
if os_field in instance and instance[os_field] is not None:
kc_data[kc_field] = instance[os_field]

# Ensure username is always provided
if "username" not in kc_data and "username" in instance:
kc_data["username"] = instance["username"]

if not kc_data.get("username"):
return instance # Cannot create user without username

# Create user in Keycloak - raises ActionException on failure
keycloak_id = client.create_user(kc_data)

# Store keycloak_id and enable self-password-change (syncs via admin API)
instance["keycloak_id"] = keycloak_id
instance["can_change_own_password"] = True

# Set password in Keycloak if default_password is provided
default_password = instance.get("default_password")
if default_password:
client.set_password(keycloak_id, default_password)

return instance


class KeycloakPasswordSyncMixin(Action):
"""
Synchronizes password changes to Keycloak.

When changing password for users that have a keycloak_id and
Keycloak sync is enabled, this mixin will also set the password
in Keycloak. The plaintext password must be stored in the instance
before hashing (stored in _keycloak_password attribute).
"""

def _get_keycloak_client(self) -> KeycloakAdminClient | None:
return get_keycloak_admin_client(self.logger)

def _get_keycloak_id(self, user_id: int) -> str | None:
"""Get keycloak_id for a user."""
user = self.datastore.get(
fqid_from_collection_and_id("user", user_id),
["keycloak_id"],
lock_result=False,
)
return user.get("keycloak_id")

def set_password(self, instance: dict[str, Any]) -> None:
"""
Override to capture plaintext password before hashing and sync to Keycloak.
"""
# Capture plaintext password before it gets hashed
plaintext_password = instance.get("password")

# Call parent's set_password which will hash the password
super().set_password(instance) # type: ignore

# Now sync to Keycloak if applicable
if plaintext_password:
self._sync_password_to_keycloak(instance, plaintext_password)

def _sync_password_to_keycloak(
self, instance: dict[str, Any], plaintext_password: str
) -> None:
"""Sync password to Keycloak for users with keycloak_id."""
user_id = instance.get("id")
if not user_id:
return

keycloak_id = self._get_keycloak_id(user_id)
if not keycloak_id:
return # Not a Keycloak user

client = self._get_keycloak_client()
if not client:
return # Keycloak sync not enabled

# Raises ActionException on failure -> action fails
client.set_password(keycloak_id, plaintext_password)
Loading
Loading