Added super user for whole service management
This commit is contained in:
@@ -33,6 +33,11 @@ class Settings(BaseSettings):
|
||||
database_password: str = Field(default="postgres", env="DATABASE_PASSWORD")
|
||||
database_echo: bool = Field(default=False, env="DATABASE_ECHO")
|
||||
|
||||
# Super Admin settings (for auto-provisioning master user at startup)
|
||||
super_admin_username: str | None = Field(default=None, env="SUPER_ADMIN_USERNAME")
|
||||
super_admin_email: str | None = Field(default=None, env="SUPER_ADMIN_EMAIL")
|
||||
super_admin_password: str | None = Field(default=None, env="SUPER_ADMIN_PASSWORD")
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Construct the async database URL for SQLAlchemy."""
|
||||
|
||||
@@ -17,7 +17,9 @@ from src.models import (
|
||||
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
||||
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
||||
RequirementStatusResponse, DeletedRequirementResponse,
|
||||
UserCreateRequest, UserCreateResponse
|
||||
UserCreateRequest, UserCreateResponse,
|
||||
SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse,
|
||||
AssignUserToProjectRequest, SystemUserCreateRequest
|
||||
)
|
||||
from src.controller import AuthController
|
||||
from src.config import get_openid, get_settings
|
||||
@@ -83,6 +85,45 @@ async def lifespan(app: FastAPI):
|
||||
await session.commit()
|
||||
logger.info("Default validation statuses ensured")
|
||||
|
||||
# Provision super admin if configured
|
||||
if settings.super_admin_username and settings.super_admin_email and settings.super_admin_password:
|
||||
logger.info(f"Super admin configuration found, provisioning user: {settings.super_admin_username}")
|
||||
try:
|
||||
# Check if super admin already exists in local DB
|
||||
async with AsyncSessionLocal() as session:
|
||||
user_repo = UserRepository(session)
|
||||
existing_user = await user_repo.get_by_username(settings.super_admin_username)
|
||||
|
||||
if existing_user:
|
||||
logger.info(f"Super admin already exists in database: {existing_user.username} (id: {existing_user.id})")
|
||||
# Ensure they have super_admin role (role_id=6)
|
||||
if existing_user.role_id != 6:
|
||||
logger.info(f"Updating existing super admin to role_id=6")
|
||||
await user_repo.update_role(existing_user.id, 6)
|
||||
await session.commit()
|
||||
else:
|
||||
# Provision in Keycloak first
|
||||
keycloak_sub = await KeycloakAdminService.provision_super_admin(
|
||||
username=settings.super_admin_username,
|
||||
email=settings.super_admin_email,
|
||||
password=settings.super_admin_password
|
||||
)
|
||||
|
||||
# Create in local database with super_admin role (role_id=6)
|
||||
new_user = await user_repo.create(
|
||||
sub=keycloak_sub,
|
||||
role_id=6, # super_admin role
|
||||
username=settings.super_admin_username,
|
||||
full_name="Super Admin"
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"Super admin provisioned successfully: {new_user.username} (id: {new_user.id})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to provision super admin: {e}")
|
||||
# Don't fail startup if super admin provisioning fails
|
||||
else:
|
||||
logger.info("No super admin configuration found, skipping provisioning")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
@@ -2064,3 +2105,488 @@ async def delete_reply(
|
||||
|
||||
await reply_repo.soft_delete_reply(reply_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Super Admin System Endpoints (role_id=6 only)
|
||||
# ===========================================
|
||||
|
||||
def _require_super_admin(user):
|
||||
"""
|
||||
Helper to check if user is a super admin (role_id=6).
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 Forbidden if user is not a super admin
|
||||
"""
|
||||
if user.role_id != 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super admins can access this endpoint"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/admin/system/users", response_model=List[SystemUserResponse])
|
||||
async def get_all_system_users(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all users in the system (super admin only).
|
||||
Includes Keycloak enabled status.
|
||||
|
||||
Returns:
|
||||
List of all users with their roles and status.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
users = await user_repo.get_all()
|
||||
|
||||
# Build response with Keycloak status
|
||||
result = []
|
||||
for user in users:
|
||||
# Get enabled status from Keycloak
|
||||
is_enabled = True
|
||||
try:
|
||||
is_enabled = await KeycloakAdminService.get_user_status(user.sub)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get Keycloak status for user {user.id}: {e}")
|
||||
|
||||
result.append(SystemUserResponse(
|
||||
id=user.id,
|
||||
sub=user.sub,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
role_id=user.role_id,
|
||||
role_name=user.role.role_name if user.role else "unknown",
|
||||
role_display_name=ROLE_DISPLAY_NAMES.get(user.role.role_name, user.role.role_name.title()) if user.role else "Unknown",
|
||||
is_enabled=is_enabled,
|
||||
created_at=user.created_at
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/admin/system/projects", response_model=List[SystemProjectResponse])
|
||||
async def get_all_system_projects(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all projects in the system (super admin only).
|
||||
Includes member count for each project.
|
||||
|
||||
Returns:
|
||||
List of all projects with member counts.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
project_repo = ProjectRepository(db)
|
||||
projects = await project_repo.get_all()
|
||||
|
||||
result = []
|
||||
for project in projects:
|
||||
member_count = await project_repo.get_member_count(project.id)
|
||||
result.append(SystemProjectResponse(
|
||||
id=project.id,
|
||||
project_name=project.project_name,
|
||||
project_desc=project.project_desc,
|
||||
member_count=member_count,
|
||||
created_at=project.created_at
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/admin/system/projects", response_model=SystemProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_system_project(
|
||||
request: Request,
|
||||
project_data: ProjectCreateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new project (super admin only).
|
||||
Unlike regular project creation, this doesn't add the creator as a member.
|
||||
|
||||
Args:
|
||||
project_data: The project data
|
||||
|
||||
Returns:
|
||||
The created project.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
project_repo = ProjectRepository(db)
|
||||
project = await project_repo.create(
|
||||
project_name=project_data.project_name,
|
||||
project_desc=project_data.project_desc,
|
||||
creator_id=None, # Super admin creates without being a member
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return SystemProjectResponse(
|
||||
id=project.id,
|
||||
project_name=project.project_name,
|
||||
project_desc=project.project_desc,
|
||||
member_count=0,
|
||||
created_at=project.created_at
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/admin/system/projects/{project_id}/members", response_model=List[SystemProjectMemberResponse])
|
||||
async def get_system_project_members(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all members of a project (super admin only).
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
|
||||
Returns:
|
||||
List of project members.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
project_repo = ProjectRepository(db)
|
||||
|
||||
# Check if project exists
|
||||
project = await project_repo.get_by_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with id {project_id} not found"
|
||||
)
|
||||
|
||||
members = await project_repo.get_members(project_id)
|
||||
|
||||
return [
|
||||
SystemProjectMemberResponse(
|
||||
user_id=member.id,
|
||||
username=member.username,
|
||||
full_name=member.full_name,
|
||||
role_id=member.role_id,
|
||||
role_name=member.role.role_name if member.role else "unknown",
|
||||
joined_at=None # TODO: Add joined_at from project_members table
|
||||
)
|
||||
for member in members
|
||||
]
|
||||
|
||||
|
||||
@app.post("/api/admin/system/projects/{project_id}/members", status_code=status.HTTP_201_CREATED)
|
||||
async def assign_user_to_project(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
data: AssignUserToProjectRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Assign a user to a project (super admin only).
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
data: The user to assign
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
project_repo = ProjectRepository(db)
|
||||
user_repo = UserRepository(db)
|
||||
|
||||
# Check if project exists
|
||||
project = await project_repo.get_by_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with id {project_id} not found"
|
||||
)
|
||||
|
||||
# Check if user exists
|
||||
user = await user_repo.get_by_id(data.user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with id {data.user_id} not found"
|
||||
)
|
||||
|
||||
# Optionally update role if provided
|
||||
if data.role_id is not None:
|
||||
role_repo = RoleRepository(db)
|
||||
role = await role_repo.get_by_id(data.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role id {data.role_id}"
|
||||
)
|
||||
await user_repo.update_role(data.user_id, data.role_id)
|
||||
|
||||
# Add user to project
|
||||
added = await project_repo.add_member(project_id, data.user_id)
|
||||
|
||||
if not added:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User is already a member of this project"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "User assigned to project successfully"}
|
||||
|
||||
|
||||
@app.delete("/api/admin/system/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_user_from_project(
|
||||
project_id: int,
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Remove a user from a project (super admin only).
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
user_id: The user ID to remove
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
project_repo = ProjectRepository(db)
|
||||
|
||||
# Check if project exists
|
||||
project = await project_repo.get_by_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project with id {project_id} not found"
|
||||
)
|
||||
|
||||
removed = await project_repo.remove_member(project_id, user_id)
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User is not a member of this project"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@app.put("/api/admin/system/users/{user_id}/block", status_code=status.HTTP_200_OK)
|
||||
async def block_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Block a user by disabling them in Keycloak (super admin only).
|
||||
Blocked users cannot log in.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to block
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
# Prevent blocking yourself
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot block yourself"
|
||||
)
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with id {user_id} not found"
|
||||
)
|
||||
|
||||
# Disable in Keycloak
|
||||
await KeycloakAdminService.disable_user(user.sub)
|
||||
|
||||
return {"message": f"User {user.username or user.sub} has been blocked"}
|
||||
|
||||
|
||||
@app.put("/api/admin/system/users/{user_id}/unblock", status_code=status.HTTP_200_OK)
|
||||
async def unblock_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Unblock a user by enabling them in Keycloak (super admin only).
|
||||
|
||||
Args:
|
||||
user_id: The user ID to unblock
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with id {user_id} not found"
|
||||
)
|
||||
|
||||
# Enable in Keycloak
|
||||
await KeycloakAdminService.enable_user(user.sub)
|
||||
|
||||
return {"message": f"User {user.username or user.sub} has been unblocked"}
|
||||
|
||||
|
||||
@app.put("/api/admin/system/users/{user_id}/role", response_model=SystemUserResponse)
|
||||
async def update_system_user_role(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
role_data: UserRoleUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update a user's role (super admin only).
|
||||
Can promote users to super_admin or demote them.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to update
|
||||
role_data: The new role ID
|
||||
|
||||
Returns:
|
||||
The updated user info.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
# Prevent self-demotion from super admin
|
||||
if current_user.id == user_id and role_data.role_id != 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot demote yourself. Ask another super admin to change your role."
|
||||
)
|
||||
|
||||
# Verify role exists
|
||||
role_repo = RoleRepository(db)
|
||||
role = await role_repo.get_by_id(role_data.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role id {role_data.role_id}"
|
||||
)
|
||||
|
||||
# Update the user's role
|
||||
user_repo = UserRepository(db)
|
||||
updated_user = await user_repo.update_role(user_id, role_data.role_id)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with id {user_id} not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get enabled status from Keycloak
|
||||
is_enabled = True
|
||||
try:
|
||||
is_enabled = await KeycloakAdminService.get_user_status(updated_user.sub)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get Keycloak status for user {user_id}: {e}")
|
||||
|
||||
return SystemUserResponse(
|
||||
id=updated_user.id,
|
||||
sub=updated_user.sub,
|
||||
username=updated_user.username,
|
||||
full_name=updated_user.full_name,
|
||||
role_id=updated_user.role_id,
|
||||
role_name=role.role_name,
|
||||
role_display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title()),
|
||||
is_enabled=is_enabled,
|
||||
created_at=updated_user.created_at
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/admin/system/users", response_model=SystemUserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_system_user(
|
||||
request: Request,
|
||||
user_data: SystemUserCreateRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new user in the system (super admin only).
|
||||
Creates the user in Keycloak with a temporary password.
|
||||
|
||||
Args:
|
||||
user_data: The user data
|
||||
|
||||
Returns:
|
||||
The created user info.
|
||||
"""
|
||||
current_user = await _get_current_user_db(request, db)
|
||||
_require_super_admin(current_user)
|
||||
|
||||
# Validate role exists and is not super_admin unless current user is super admin
|
||||
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
|
||||
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 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
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Super admin {current_user.id} created new user {new_user.id} ({user_data.username})")
|
||||
|
||||
return SystemUserResponse(
|
||||
id=new_user.id,
|
||||
sub=new_user.sub,
|
||||
username=new_user.username,
|
||||
full_name=new_user.full_name,
|
||||
role_id=new_user.role_id,
|
||||
role_name=role.role_name,
|
||||
role_display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title()),
|
||||
is_enabled=True,
|
||||
created_at=new_user.created_at
|
||||
)
|
||||
|
||||
@@ -27,7 +27,10 @@ class UserInfo(BaseModel):
|
||||
ROLE_DISPLAY_NAMES = {
|
||||
"editor": "Editor",
|
||||
"auditor": "Auditor",
|
||||
"admin": "Project Admin"
|
||||
"admin": "Project Admin",
|
||||
"user": "User",
|
||||
"viewer": "Viewer",
|
||||
"super_admin": "Super Admin"
|
||||
}
|
||||
|
||||
|
||||
@@ -473,3 +476,61 @@ class UserCreateResponse(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Super Admin schemas
|
||||
class SystemUserResponse(BaseModel):
|
||||
"""Response schema for a user in the system admin view."""
|
||||
id: int
|
||||
sub: str
|
||||
username: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
role_id: int
|
||||
role_name: str
|
||||
role_display_name: str
|
||||
is_enabled: bool = True # From Keycloak
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SystemProjectResponse(BaseModel):
|
||||
"""Response schema for a project in the system admin view."""
|
||||
id: int
|
||||
project_name: str
|
||||
project_desc: Optional[str] = None
|
||||
member_count: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SystemProjectMemberResponse(BaseModel):
|
||||
"""Response schema for a project member in system admin view."""
|
||||
user_id: int
|
||||
username: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
role_id: int
|
||||
role_name: str
|
||||
joined_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssignUserToProjectRequest(BaseModel):
|
||||
"""Request schema for assigning a user to a project."""
|
||||
user_id: int
|
||||
role_id: Optional[int] = None # Optional: set role when assigning
|
||||
|
||||
|
||||
class SystemUserCreateRequest(BaseModel):
|
||||
"""Request schema for creating a user from super admin panel."""
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
role_id: int = 1 # Default to editor
|
||||
|
||||
@@ -234,3 +234,21 @@ class ProjectRepository:
|
||||
.where(ProjectMember.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_member_count(self, project_id: int) -> int:
|
||||
"""
|
||||
Get the count of members in a project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
|
||||
Returns:
|
||||
Number of members in the project
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
result = await self.session.execute(
|
||||
select(func.count())
|
||||
.select_from(ProjectMember)
|
||||
.where(ProjectMember.project_id == project_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@@ -59,10 +59,10 @@ class RoleRepository:
|
||||
"""
|
||||
Ensure default roles exist in the database.
|
||||
Called during application startup.
|
||||
Creates roles in order: editor (1), auditor (2), admin (3)
|
||||
Creates roles in order: editor (1), auditor (2), admin (3), user (4), viewer (5), super_admin (6)
|
||||
"""
|
||||
# Order matters for role IDs: editor=1, auditor=2, admin=3
|
||||
default_roles = ["editor", "auditor", "admin"]
|
||||
# Order matters for role IDs: editor=1, auditor=2, admin=3, user=4, viewer=5, super_admin=6
|
||||
default_roles = ["editor", "auditor", "admin", "user", "viewer", "super_admin"]
|
||||
|
||||
for role_name in default_roles:
|
||||
existing = await self.get_by_name(role_name)
|
||||
@@ -111,6 +111,37 @@ class UserRepository:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_username(self, username: str) -> Optional[User]:
|
||||
"""
|
||||
Get a user by their username.
|
||||
|
||||
Args:
|
||||
username: The username to search for
|
||||
|
||||
Returns:
|
||||
User if found, None otherwise
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.role))
|
||||
.where(User.username == username)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(self) -> List[User]:
|
||||
"""
|
||||
Get all users in the system.
|
||||
|
||||
Returns:
|
||||
List of all users with their roles
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.role))
|
||||
.order_by(User.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, sub: str, role_id: int, username: str, full_name: str | None = None) -> User:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
@@ -328,3 +328,189 @@ class KeycloakAdminService:
|
||||
|
||||
logger.info(f"Created user in Keycloak: {username} -> {keycloak_user_id}")
|
||||
return keycloak_user_id
|
||||
|
||||
@classmethod
|
||||
async def get_user_by_username(cls, username: str) -> dict | None:
|
||||
"""
|
||||
Get a user from Keycloak by username.
|
||||
|
||||
Args:
|
||||
username: The username to search for
|
||||
|
||||
Returns:
|
||||
The user data if found, None otherwise
|
||||
"""
|
||||
token = await cls._get_admin_token()
|
||||
users_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users"
|
||||
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
response = await client.get(
|
||||
f"{users_url}?username={username}&exact=true",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
users = response.json()
|
||||
if users:
|
||||
return users[0]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def provision_super_admin(
|
||||
cls,
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> str:
|
||||
"""
|
||||
Provision the super admin user in Keycloak.
|
||||
If user already exists, returns their ID. Otherwise creates them.
|
||||
|
||||
Args:
|
||||
username: The super admin username
|
||||
email: The super admin email
|
||||
password: The temporary password
|
||||
|
||||
Returns:
|
||||
The Keycloak user ID (sub)
|
||||
"""
|
||||
# Check if user already exists
|
||||
existing_user = await cls.get_user_by_username(username)
|
||||
if existing_user:
|
||||
logger.info(f"Super admin user already exists in Keycloak: {username} -> {existing_user['id']}")
|
||||
return existing_user["id"]
|
||||
|
||||
# Create the user
|
||||
logger.info(f"Creating super admin user in Keycloak: {username}")
|
||||
return await cls.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
password=password,
|
||||
first_name="Super",
|
||||
last_name="Admin"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def disable_user(cls, keycloak_user_id: str) -> bool:
|
||||
"""
|
||||
Disable a user in Keycloak (block them from logging in).
|
||||
|
||||
Args:
|
||||
keycloak_user_id: The Keycloak user ID (sub)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
HTTPException: If the operation fails
|
||||
"""
|
||||
token = await cls._get_admin_token()
|
||||
user_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users/{keycloak_user_id}"
|
||||
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
response = await client.put(
|
||||
user_url,
|
||||
json={"enabled": False},
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
logger.info(f"Disabled user in Keycloak: {keycloak_user_id}")
|
||||
return True
|
||||
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found in Keycloak"
|
||||
)
|
||||
|
||||
logger.error(f"Failed to disable user in Keycloak: {response.status_code} - {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to disable user in Keycloak: {response.text}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def enable_user(cls, keycloak_user_id: str) -> bool:
|
||||
"""
|
||||
Enable a user in Keycloak (unblock them).
|
||||
|
||||
Args:
|
||||
keycloak_user_id: The Keycloak user ID (sub)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
HTTPException: If the operation fails
|
||||
"""
|
||||
token = await cls._get_admin_token()
|
||||
user_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users/{keycloak_user_id}"
|
||||
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
response = await client.put(
|
||||
user_url,
|
||||
json={"enabled": True},
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
logger.info(f"Enabled user in Keycloak: {keycloak_user_id}")
|
||||
return True
|
||||
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found in Keycloak"
|
||||
)
|
||||
|
||||
logger.error(f"Failed to enable user in Keycloak: {response.status_code} - {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to enable user in Keycloak: {response.text}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_user_status(cls, keycloak_user_id: str) -> bool:
|
||||
"""
|
||||
Get a user's enabled status from Keycloak.
|
||||
|
||||
Args:
|
||||
keycloak_user_id: The Keycloak user ID (sub)
|
||||
|
||||
Returns:
|
||||
True if user is enabled, False if disabled
|
||||
|
||||
Raises:
|
||||
HTTPException: If the operation fails
|
||||
"""
|
||||
token = await cls._get_admin_token()
|
||||
user_url = f"{settings.keycloak_server_url}admin/realms/{settings.keycloak_realm}/users/{keycloak_user_id}"
|
||||
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
response = await client.get(
|
||||
user_url,
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
user_data = response.json()
|
||||
return user_data.get("enabled", True)
|
||||
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found in Keycloak"
|
||||
)
|
||||
|
||||
logger.error(f"Failed to get user status from Keycloak: {response.status_code} - {response.text}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get user status from Keycloak: {response.text}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user