diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dab338..8fd6d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.5] - 2026-01-01 + +### Fixed + +- Separate httpx and SQLAlchemy async contexts in bulk sync (fixes greenlet error) + ## [1.4.4] - 2026-01-01 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index c603e46..d16023d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "core-api" -version = "1.4.4" +version = "1.4.5" description = "Core Code API - Infrastructure management and tools API" readme = "README.md" requires-python = ">=3.12" diff --git a/src/auth/service.py b/src/auth/service.py index 63ab50a..0117944 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -369,6 +369,8 @@ class AuthService: errors = [] total_in_authentik = 0 + # Step 1: Fetch all user data from Authentik API + authentik_users = [] try: async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: # Authenticate with Authentik to get session cookie @@ -393,72 +395,73 @@ class AuthService: authentik_users = data.get("results", []) total_in_authentik = data.get("pagination", {}).get("count", len(authentik_users)) - for auth_user in authentik_users: - try: - # Skip service accounts and inactive users - if auth_user.get("type") in ("service_account", "internal_service_account"): - continue - if not auth_user.get("is_active", True): - continue - - # Extract user data from Authentik - authentik_id = uuid.UUID(auth_user["uuid"]) - email = auth_user.get("email") or f"{auth_user['username']}@local" - name = auth_user.get("name") or auth_user.get("username", "Unknown") - avatar_url = auth_user.get("avatar") - - # Get user's groups for role mapping - groups = [] - groups_summary = auth_user.get("groups_obj", []) - for group in groups_summary: - groups.append(group.get("name", "")) - - # Check if user exists - stmt = select(User).where(User.authentik_id == authentik_id) - result = await self.session.execute(stmt) - user = result.scalar_one_or_none() - - if user is None: - # Create new user - user = User( - authentik_id=authentik_id, - email=email, - name=name, - avatar_url=avatar_url, - ) - self.session.add(user) - await self.session.flush() - - # Create default preferences - preferences = UserPreferences(user_id=user.id) - self.session.add(preferences) - created += 1 - logger.info(f"Created user from Authentik: {email}") - else: - # Update existing user - user.email = email - user.name = name - user.avatar_url = avatar_url - updated += 1 - logger.info(f"Updated user from Authentik: {email}") - - # Sync roles from groups - await self.sync_roles(user, groups) - - except Exception as e: - failed += 1 - error_msg = f"Failed to sync user {auth_user.get('username', 'unknown')}: {str(e)}" - errors.append(error_msg) - logger.warning(error_msg) - - # Commit all changes - await self.session.commit() - except httpx.HTTPStatusError as e: raise ValueError(f"Authentik API error: {e.response.status_code}") except httpx.RequestError as e: raise ValueError(f"Failed to connect to Authentik: {str(e)}") + # Step 2: Sync users to database (outside of httpx context to avoid greenlet issues) + for auth_user in authentik_users: + try: + # Skip service accounts and inactive users + if auth_user.get("type") in ("service_account", "internal_service_account"): + continue + if not auth_user.get("is_active", True): + continue + + # Extract user data from Authentik + authentik_id = uuid.UUID(auth_user["uuid"]) + email = auth_user.get("email") or f"{auth_user['username']}@local" + name = auth_user.get("name") or auth_user.get("username", "Unknown") + avatar_url = auth_user.get("avatar") + + # Get user's groups for role mapping + groups = [] + groups_summary = auth_user.get("groups_obj", []) + for group in groups_summary: + groups.append(group.get("name", "")) + + # Check if user exists + stmt = select(User).where(User.authentik_id == authentik_id) + result = await self.session.execute(stmt) + user = result.scalar_one_or_none() + + if user is None: + # Create new user + user = User( + authentik_id=authentik_id, + email=email, + name=name, + avatar_url=avatar_url, + ) + self.session.add(user) + await self.session.flush() + + # Create default preferences + preferences = UserPreferences(user_id=user.id) + self.session.add(preferences) + created += 1 + logger.info(f"Created user from Authentik: {email}") + else: + # Update existing user + user.email = email + user.name = name + user.avatar_url = avatar_url + updated += 1 + logger.info(f"Updated user from Authentik: {email}") + + # Sync roles from groups + await self.sync_roles(user, groups) + + except Exception as e: + failed += 1 + error_msg = f"Failed to sync user {auth_user.get('username', 'unknown')}: {str(e)}" + errors.append(error_msg) + logger.warning(error_msg) + + # Commit all changes + await self.session.commit() + return BulkSyncResultSchema( created=created, updated=updated,