Added user creation for the admin integrated with keycloak
This commit is contained in:
@@ -10,6 +10,10 @@ class Settings(BaseSettings):
|
||||
keycloak_realm: str = Field(..., env="KEYCLOAK_REALM")
|
||||
keycloak_client_id: str = Field(..., env="KEYCLOAK_CLIENT_ID")
|
||||
keycloak_client_secret: str = Field(..., env="KEYCLOAK_CLIENT_SECRET")
|
||||
|
||||
# Keycloak Admin API settings (for creating users directly)
|
||||
keycloak_admin_client_id: str = Field(default="admin-cli", env="KEYCLOAK_ADMIN_CLIENT_ID")
|
||||
keycloak_admin_client_secret: str = Field(default="", env="KEYCLOAK_ADMIN_CLIENT_SECRET")
|
||||
|
||||
# Frontend settings
|
||||
frontend_url: str = Field(default="http://localhost:3000", env="FRONTEND_URL")
|
||||
|
||||
@@ -16,7 +16,8 @@ from src.models import (
|
||||
RequirementLinkHistoryResponse, RequirementGroupHistoryResponse, CurrentRequirementGroupResponse,
|
||||
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
||||
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
||||
RequirementStatusResponse, DeletedRequirementResponse
|
||||
RequirementStatusResponse, DeletedRequirementResponse,
|
||||
UserCreateRequest, UserCreateResponse
|
||||
)
|
||||
from src.controller import AuthController
|
||||
from src.config import get_openid, get_settings
|
||||
@@ -25,8 +26,9 @@ from src.repositories import (
|
||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
|
||||
RequirementStatusRepository
|
||||
RequirementStatusRepository, UserRepository
|
||||
)
|
||||
from src.service import KeycloakAdminService
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
@@ -710,6 +712,83 @@ async def update_member_role(
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/projects/{project_id}/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project_user(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
user_data: UserCreateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new user directly from the admin panel and add them to the project.
|
||||
Only project admins (role_id=3) can create users.
|
||||
The user will be created in Keycloak with a temporary password that must be changed on first login.
|
||||
|
||||
Args:
|
||||
project_id: The project ID to add the user to
|
||||
user_data: The user data (username, email, password, first_name, last_name, role_id)
|
||||
|
||||
Returns:
|
||||
The created user info.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
|
||||
# Only admins (role_id=3) can create users
|
||||
_require_role(current_user, [3], "create users")
|
||||
|
||||
await _verify_project_membership(project_id, current_user.id, db)
|
||||
|
||||
# Validate role exists
|
||||
role_repo = RoleRepository(db)
|
||||
role = await role_repo.get_by_id(user_data.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role id {user_data.role_id}"
|
||||
)
|
||||
|
||||
# Create user in Keycloak
|
||||
keycloak_sub = await KeycloakAdminService.create_user(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
password=user_data.password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name
|
||||
)
|
||||
|
||||
# Build full name from first_name and last_name
|
||||
full_name = None
|
||||
if user_data.first_name or user_data.last_name:
|
||||
full_name = f"{user_data.first_name or ''} {user_data.last_name or ''}".strip()
|
||||
|
||||
# Create user in local database
|
||||
user_repo = UserRepository(db)
|
||||
new_user = await user_repo.create(
|
||||
sub=keycloak_sub,
|
||||
role_id=user_data.role_id,
|
||||
username=user_data.username,
|
||||
full_name=full_name
|
||||
)
|
||||
|
||||
# Add user to the project
|
||||
project_repo = ProjectRepository(db)
|
||||
await project_repo.add_member(project_id, new_user.id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin {current_user.id} created new user {new_user.id} ({user_data.username}) for project {project_id}")
|
||||
|
||||
return UserCreateResponse(
|
||||
id=new_user.id,
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
full_name=full_name,
|
||||
role_id=user_data.role_id,
|
||||
role_name=role.role_name,
|
||||
role_display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title())
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/projects/{project_id}/relationship-types/{type_id}", response_model=RelationshipTypeResponse)
|
||||
async def update_relationship_type(
|
||||
project_id: int,
|
||||
|
||||
@@ -448,3 +448,28 @@ class CurrentRequirementGroupResponse(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# User Creation schemas (for admin to create users directly)
|
||||
class UserCreateRequest(BaseModel):
|
||||
"""Request schema for creating a user directly from admin panel."""
|
||||
username: str
|
||||
email: str
|
||||
password: str # Temporary password - user must change on first login
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role_id: int = 1 # Default to editor role
|
||||
|
||||
|
||||
class UserCreateResponse(BaseModel):
|
||||
"""Response schema for a created user."""
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
role_id: int
|
||||
role_name: str
|
||||
role_display_name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -6,6 +6,7 @@ from src.config import get_settings
|
||||
from src.models import UserInfo
|
||||
from src.repositories import UserRepository
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
@@ -186,3 +187,144 @@ class UserService:
|
||||
logger.debug(f"Existing user logged in: {sub} -> user_id: {user.id}, username: {username}")
|
||||
|
||||
return user.id, created
|
||||
|
||||
|
||||
class KeycloakAdminService:
|
||||
"""Service for Keycloak Admin API operations (creating users directly)."""
|
||||
|
||||
_access_token: str | None = None
|
||||
_token_expires_at: float = 0
|
||||
|
||||
@classmethod
|
||||
async def _get_admin_token(cls) -> str:
|
||||
"""
|
||||
Get an admin access token using client credentials grant.
|
||||
Caches the token until it expires.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Check if we have a valid cached token
|
||||
if cls._access_token and time.time() < cls._token_expires_at - 30:
|
||||
return cls._access_token
|
||||
|
||||
# Get new token using client credentials
|
||||
token_url = f"{settings.keycloak_server_url}realms/{settings.keycloak_realm}/protocol/openid-connect/token"
|
||||
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
response = await client.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": settings.keycloak_admin_client_id,
|
||||
"client_secret": settings.keycloak_admin_client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to get admin token: {response.status_code} - {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to authenticate with Keycloak Admin API"
|
||||
)
|
||||
|
||||
token_data = response.json()
|
||||
cls._access_token = token_data["access_token"]
|
||||
cls._token_expires_at = time.time() + token_data.get("expires_in", 300)
|
||||
|
||||
return cls._access_token
|
||||
|
||||
@classmethod
|
||||
async def create_user(
|
||||
cls,
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a new user in Keycloak.
|
||||
|
||||
Args:
|
||||
username: The username for the new user
|
||||
email: The email address
|
||||
password: The temporary password (user must change on first login)
|
||||
first_name: Optional first name
|
||||
last_name: Optional last name
|
||||
|
||||
Returns:
|
||||
The Keycloak user ID (sub)
|
||||
|
||||
Raises:
|
||||
HTTPException: If user creation fails
|
||||
"""
|
||||
token = await cls._get_admin_token()
|
||||
|
||||
# Create user payload
|
||||
user_payload = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"emailVerified": True, # Skip email verification since no email is configured
|
||||
"enabled": True,
|
||||
"firstName": first_name or "",
|
||||
"lastName": last_name or "",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": password,
|
||||
"temporary": True # Force password change on first login
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
users_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users"
|
||||
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
# Create the user
|
||||
response = await client.post(
|
||||
users_url,
|
||||
json=user_payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 409:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="A user with this username or email already exists"
|
||||
)
|
||||
|
||||
if response.status_code != 201:
|
||||
logger.error(f"Failed to create user in Keycloak: {response.status_code} - {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create user in Keycloak: {response.text}"
|
||||
)
|
||||
|
||||
# Get the user ID from the Location header
|
||||
location = response.headers.get("Location", "")
|
||||
keycloak_user_id = location.split("/")[-1] if location else None
|
||||
|
||||
if not keycloak_user_id:
|
||||
# Fetch user by username to get the ID
|
||||
search_response = await client.get(
|
||||
f"{users_url}?username={username}&exact=true",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if search_response.status_code == 200:
|
||||
users = search_response.json()
|
||||
if users:
|
||||
keycloak_user_id = users[0]["id"]
|
||||
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="User created but could not retrieve user ID"
|
||||
)
|
||||
|
||||
logger.info(f"Created user in Keycloak: {username} -> {keycloak_user_id}")
|
||||
return keycloak_user_id
|
||||
|
||||
Reference in New Issue
Block a user