feat(role_management): add crud for role and permission

This commit is contained in:
icecheng 2025-07-18 18:05:57 +08:00
parent 40e0fafc2c
commit 2382f0bece
30 changed files with 643 additions and 2 deletions

View File

View File

View File

@ -0,0 +1,76 @@
from typing import Optional, List, Tuple
from fastapi.exceptions import RequestValidationError
from backend.models.permission.models import PermissionDoc
from beanie import PydanticObjectId
from datetime import datetime
class PermissionHandler:
def __init__(self):
pass
async def create_permission(self, permission_key: str, permission_name: str,
description: Optional[str] = None) -> Optional[PermissionDoc]:
"""Create a new permission document"""
if not permission_key or not permission_name:
raise RequestValidationError("permission_key and permission_name are required.")
# if exists.
if await PermissionDoc.find_one(
{str(PermissionDoc.permission_key): permission_key}) or await PermissionDoc.find_one(
{str(PermissionDoc.permission_name): permission_name}):
raise RequestValidationError("permission has already been created.")
doc = PermissionDoc(
permission_key=permission_key,
permission_name=permission_name,
description=description,
created_at=datetime.now(),
updated_at=datetime.now()
)
await doc.insert()
return doc
async def update_permission(self, permission_id: PydanticObjectId, permission_key: Optional[str] = None,
permission_name: Optional[str] = None, description: Optional[str] = None) -> Optional[
PermissionDoc]:
"""Update an existing permission document by id, ensuring permission_key is unique"""
if not permission_id or not permission_key or not permission_name:
raise RequestValidationError("permission_id, permission_key and permission_name is required.")
doc = await PermissionDoc.get(permission_id)
if not doc:
raise RequestValidationError("Permission not found.")
# Check for uniqueness (exclude self)
if permission_key and permission_name:
conflict = await PermissionDoc.find_one({
"$and": [
{"_id": {"$ne": permission_id}},
{"$or": [
{str(PermissionDoc.permission_key): permission_key},
{str(PermissionDoc.permission_name): permission_name}
]}
]
})
if conflict:
raise RequestValidationError("Permission name or permission key already exists.")
doc.permission_key = permission_key
doc.updated_at = datetime.now()
await doc.save()
return doc
async def query_permissions(
self,
permission_key: Optional[str] = None,
permission_name: Optional[str] = None,
skip: int = 0,
limit: int = 10
) -> Tuple[List[PermissionDoc], int]:
"""Query permissions with pagination and fuzzy search"""
query = {}
if permission_key:
query[str(PermissionDoc.permission_key)] = {"$regex": permission_key, "$options": "i"}
if permission_name:
query[str(PermissionDoc.permission_name)] = {"$regex": permission_name, "$options": "i"}
cursor = PermissionDoc.find(query)
total = await cursor.count()
docs = await cursor.skip(skip).limit(limit).to_list()
return docs, total

View File

@ -0,0 +1,73 @@
from typing import Optional, List, Tuple
from fastapi.exceptions import RequestValidationError
from backend.models.permission.models import RoleDoc
from beanie import PydanticObjectId
from datetime import datetime
class RoleHandler:
def __init__(self):
pass
async def create_role(self, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> Optional[RoleDoc]:
"""Create a new role, ensuring role_key and role_name are unique and not empty"""
if not role_key or not role_name:
raise RequestValidationError("role_key and role_name are required.")
if await RoleDoc.find_one({str(RoleDoc.role_key): role_key}) or await RoleDoc.find_one(
{str(RoleDoc.role_name): role_name}):
raise RequestValidationError("role_key or role_name has already been created.")
doc = RoleDoc(
role_key=role_key,
role_name=role_name,
role_description=role_description,
permission_ids=[],
role_level=role_level,
created_at=datetime.now(),
updated_at=datetime.now()
)
await doc.insert()
return doc
async def update_role(self, role_id: PydanticObjectId, role_key: str, role_name: str,
role_description: Optional[str], role_level: int) -> Optional[
RoleDoc]:
"""Update an existing role, ensuring role_key and role_name are unique and not empty"""
if not role_id or not role_key or not role_name:
raise RequestValidationError("role_id, role_key and role_name are required.")
doc = await RoleDoc.get(role_id)
if not doc:
raise RequestValidationError("role not found.")
# Check for uniqueness (exclude self)
conflict = await RoleDoc.find_one({
"$and": [
{"_id": {"$ne": role_id}},
{"$or": [
{str(RoleDoc.role_key): role_key},
{str(RoleDoc.role_name): role_name}
]}
]
})
if conflict:
raise RequestValidationError("role_key or role_name already exists.")
doc.role_key = role_key
doc.role_name = role_name
doc.role_description = role_description
doc.role_level = role_level
doc.updated_at = datetime.now()
await doc.save()
return doc
async def query_roles(self, role_key: Optional[str], role_name: Optional[str], skip: int = 0, limit: int = 10) -> \
Tuple[List[RoleDoc], int]:
"""Query roles with pagination and fuzzy search by role_key and role_name"""
query = {}
if role_key:
query[str(RoleDoc.role_key)] = {"$regex": role_key, "$options": "i"}
if role_name:
query[str(RoleDoc.role_name)] = {"$regex": role_name, "$options": "i"}
cursor = RoleDoc.find(query)
total = await cursor.count()
docs = await cursor.skip(skip).limit(limit).to_list()
return docs, total

View File

@ -1,6 +1,8 @@
from .user import user_models from .user import user_models
from .user_profile import profile_models from .user_profile import profile_models
from .permission import permission_models
backend_models = [] backend_models = []
backend_models.extend(user_models) backend_models.extend(user_models)
backend_models.extend(profile_models) backend_models.extend(profile_models)
backend_models.extend(permission_models)

View File

@ -0,0 +1,3 @@
from .models import PermissionDoc, RoleDoc
permission_models = [PermissionDoc, RoleDoc]

View File

@ -1,4 +1,31 @@
from enum import IntEnum from dataclasses import dataclass
from enum import IntEnum, Enum
@dataclass(frozen=True) # frozen=True
class DefaultRole:
role_name: str
role_key: str
role_description: str
role_level: int
# Default roles, which all tenants will have, cannot be modified.
class DefaultRoleEnum(Enum):
ADMIN = DefaultRole("Administrator", "admin", "Have all permissions", 0)
@dataclass(frozen=True) # frozen=True
class DefaultPermission:
permission_key: str
permission_name: str
permission_description: str
# Default permissions, which all tenants will have, cannot be modified.
class DefaultPermissionEnum(Enum):
CHANGE_ROLES = DefaultPermission("change:roles", "Change roles", "Add/Update/Delete roles")
CHANGE_PERMISSIONS = DefaultPermission("change:permissions", "Change permissions", "Add/Update/Remove permissions")
class AdministrativeRole(IntEnum): class AdministrativeRole(IntEnum):

View File

@ -0,0 +1,37 @@
from beanie import Document
from datetime import datetime
from typing import Optional
class PermissionDoc(Document):
permission_name: str
permission_key: str
description: Optional[str] = None # Description of the permission, optional
created_at: datetime = datetime.now() # Creation timestamp, auto-generated
updated_at: datetime = datetime.now() # Last update timestamp, auto-updated
class Settings:
# Default collections created by Freeleaps for tenant databases use '_' prefix
# to prevent naming conflicts with tenant-created collections
name = "_permission"
indexes = [
"permission_key"
]
class RoleDoc(Document):
role_key: str
role_name: str
role_description: Optional[str] = None
permission_ids: list[str]
role_level: int
created_at: datetime = datetime.now() # Creation timestamp, auto-generated
updated_at: datetime = datetime.now() # Last update timestamp, auto-updated
class Settings:
# Default collections created by Freeleaps for tenant databases use '_' prefix
# to prevent naming conflicts with tenant-created collections
name = "_role"
indexes = [
"role_level"
]

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import Optional, List
from beanie import Document from beanie import Document
@ -18,6 +18,7 @@ class UserAccountDoc(Document):
service_plan_id: Optional[str] service_plan_id: Optional[str]
properties: UserAccountProperty properties: UserAccountProperty
capabilities: Capability capabilities: Capability
user_role_ids: List[str]
user_role: int = AdministrativeRole.NONE user_role: int = AdministrativeRole.NONE
preferred_region: UserRegion = UserRegion.ZH_CN preferred_region: UserRegion = UserRegion.ZH_CN

View File

@ -0,0 +1,32 @@
from typing import Optional, Dict, Any
from fastapi.exceptions import RequestValidationError
from backend.infra.permission.permission_handler import PermissionHandler
from backend.models.permission.models import PermissionDoc
from beanie import PydanticObjectId
class PermissionService:
def __init__(self):
self.permission_handler = PermissionHandler()
async def create_permission(self, permission_key: str, permission_name: str, description: Optional[str] = None) -> PermissionDoc:
"""Create a new permission document"""
return await self.permission_handler.create_permission(permission_key, permission_name, description)
async def update_permission(self, permission_id: str, permission_key: Optional[str] = None, permission_name: Optional[str] = None, description: Optional[str] = None) -> PermissionDoc:
"""Update an existing permission document by id"""
return await self.permission_handler.update_permission(PydanticObjectId(permission_id), permission_key, permission_name, description)
async def query_permissions(self, permission_key: Optional[str] = None, permission_name: Optional[str] = None, page: int = 1, page_size: int = 10) -> Dict[str, Any]:
"""Query permissions with pagination and fuzzy search"""
if page < 1 or page_size < 1:
raise RequestValidationError("page and page_size must be positive integers.")
skip = (page - 1) * page_size
docs, total = await self.permission_handler.query_permissions(permission_key, permission_name, skip, page_size)
return {
"items": [doc.dict() for doc in docs],
"total": total,
"page": page,
"page_size": page_size
}

View File

@ -0,0 +1,36 @@
from typing import Optional, Dict, Any
from fastapi.exceptions import RequestValidationError
from backend.infra.permission.role_handler import RoleHandler
from backend.models.permission.models import RoleDoc
from beanie import PydanticObjectId
class RoleService:
def __init__(self):
self.role_handler = RoleHandler()
async def create_role(self, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> RoleDoc:
"""Create a new role, ensuring role_key and role_name are unique and not empty"""
doc = await self.role_handler.create_role(role_key, role_name, role_description, role_level)
return doc
async def update_role(self, role_id: str, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> RoleDoc:
"""Update an existing role, ensuring role_key and role_name are unique and not empty"""
doc = await self.role_handler.update_role(PydanticObjectId(role_id), role_key, role_name, role_description, role_level)
return doc
async def query_roles(self, role_key: Optional[str], role_name: Optional[str], page: int = 1, page_size: int = 10) -> Dict[str, Any]:
"""Query roles with pagination and fuzzy search by role_key and role_name"""
if page < 1 or page_size < 1:
raise RequestValidationError("page and page_size must be positive integers.")
skip = (page - 1) * page_size
docs, total = await self.role_handler.query_roles(role_key, role_name, skip, page_size)
return {
"items": [doc.dict() for doc in docs],
"total": total,
"page": page,
"page_size": page_size
}

View File

View File

@ -0,0 +1,24 @@
APP_NAME=authentication
export SERVICE_API_ACCESS_HOST=0.0.0.0
export SERVICE_API_ACCESS_PORT=8004
export CONTAINER_APP_ROOT=/app
export LOG_BASE_PATH=$CONTAINER_APP_ROOT/log/$APP_NAME
export BACKEND_LOG_FILE_NAME=$APP_NAME
export APPLICATION_ACTIVITY_LOG=$APP_NAME-activity
export MONGODB_NAME=freeleaps2
export MONGODB_PORT=27017
export JWT_SECRET_KEY=ea84edf152976b2fcec12b78aa8e45bc26a5cf0ef61bf16f5c317ae33b3fd8b0
GIT_REPO_ROOT=/mnt/freeleaps/freeleaps-service-hub
CODEBASE_ROOT=/mnt/freeleaps/freeleaps-service-hub/apps/authentication
SITE_DEPLOY_FOLDER=/mnt/freeleaps/freeleaps-service-hub/sites/authentication/deploy
#!/bin/bash
export VENV_DIR=venv_t
export VENV_ACTIVATE=venv_t/bin/activate
export DOCKER_HOME=/var/lib/docker
export DOCKER_APP_HOME=$DOCKER_HOME/app
export DOCKER_BACKEND_HOME=$DOCKER_APP_HOME/$APP_NAME
export DOCKER_BACKEND_LOG_HOME=$DOCKER_BACKEND_HOME/log
export MONGODB_URI=mongodb://localhost:27017/
export FREELEAPS_ENV=local
export LOG_BASE_PATH=${CODEBASE_ROOT}/log

View File

@ -11,6 +11,7 @@ from webapi.providers import metrics
# from webapi.providers import scheduler # from webapi.providers import scheduler
from webapi.providers import exception_handler from webapi.providers import exception_handler
from webapi.providers import permission_initialize
from .freeleaps_app import FreeleapsApp from .freeleaps_app import FreeleapsApp
from common.config.app_settings import app_settings from common.config.app_settings import app_settings
@ -23,6 +24,7 @@ def create_app() -> FastAPI:
register(app, exception_handler) register(app, exception_handler)
register(app, database) register(app, database)
register(app, router) register(app, router)
register(app, permission_initialize)
# register(app, scheduler) # register(app, scheduler)
register(app, common) register(app, common)

View File

@ -1,3 +1,4 @@
from bson.errors import InvalidId
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from starlette.requests import Request from starlette.requests import Request
@ -26,6 +27,12 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
content={"error": str(exc)}, content={"error": str(exc)},
) )
async def validation_error_exception_handler(request: Request, exc: InvalidId):
return JSONResponse(
status_code=HTTP_400_BAD_REQUEST,
content={"error": str(exc)},
)
async def exception_handler(request: Request, exc: Exception): async def exception_handler(request: Request, exc: Exception):
return JSONResponse( return JSONResponse(
status_code=HTTP_500_INTERNAL_SERVER_ERROR, status_code=HTTP_500_INTERNAL_SERVER_ERROR,
@ -36,4 +43,5 @@ async def exception_handler(request: Request, exc: Exception):
def register(app: FastAPI): def register(app: FastAPI):
app.add_exception_handler(HTTPException, custom_http_exception_handler) app.add_exception_handler(HTTPException, custom_http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(InvalidId, exception_handler)
app.add_exception_handler(Exception, exception_handler) app.add_exception_handler(Exception, exception_handler)

View File

@ -0,0 +1,37 @@
import logging
from backend.models.permission import PermissionDoc, RoleDoc
from backend.models.permission.constants import DefaultPermissionEnum, DefaultRoleEnum
def register(app):
# Configure logging for pymongo
logging.getLogger("init_admin_permission").setLevel(logging.INFO) # Suppress DEBUG logs
@app.on_event("startup")
async def init_admin_permission():
# Initialize permissions if not exist
default_permission_ids = []
for default_permission in [DefaultPermissionEnum.CHANGE_PERMISSIONS, DefaultPermissionEnum.CHANGE_ROLES]:
if not await PermissionDoc.find_one(
{str(PermissionDoc.permission_key): default_permission.value.permission_key}):
doc = await PermissionDoc(
permission_key=default_permission.value.permission_key,
permission_name=default_permission.value.permission_name,
description=default_permission.value.permission_description,
).insert()
default_permission_ids.append(str(doc.id))
logging.info(f"default permissions initialized {default_permission_ids}")
# Initialize roles if not exist
default_role_ids = []
for default_role in [DefaultRoleEnum.ADMIN]:
if not await RoleDoc.find_one({str(RoleDoc.role_key): default_role.value.role_key}):
doc = await RoleDoc(
role_key=default_role.value.role_key,
role_name=default_role.value.role_name,
role_description=default_role.value.role_description,
permission_ids=default_permission_ids,
role_level=default_role.value.role_level,
).insert()
default_role_ids.append(str(doc.id))
logging.info(f"default roles initialized {default_role_ids}")

View File

@ -2,9 +2,13 @@ from fastapi import APIRouter
from .signin import router as signin_router from .signin import router as signin_router
from .tokens import router as token_router from .tokens import router as token_router
from .auth import router as auth_router from .auth import router as auth_router
from .permission import router as permission_router
from .role import router as role_router
api_router = APIRouter(prefix="/auth") api_router = APIRouter(prefix="/auth")
api_router.include_router(signin_router, tags=["user"]) api_router.include_router(signin_router, tags=["user"])
api_router.include_router(token_router, tags=["token"]) api_router.include_router(token_router, tags=["token"])
api_router.include_router(auth_router, tags=["auth"]) api_router.include_router(auth_router, tags=["auth"])
api_router.include_router(permission_router, tags=["permission"])
api_router.include_router(role_router, tags=["role"])
websocket_router = APIRouter() websocket_router = APIRouter()

View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from .create_permission import router as cp_router
from .query_permission import router as qp_router
from .update_permission import router as up_router
router = APIRouter()
router.include_router(cp_router, prefix="/permission", tags=["permission"])
router.include_router(qp_router, prefix="/permission", tags=["permission"])
router.include_router(up_router, prefix="/permission", tags=["permission"])

View File

@ -0,0 +1,36 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional
from backend.services.permission.permission_service import PermissionService
from common.token.token_manager import TokenManager
router = APIRouter()
token_manager = TokenManager()
permission_service = PermissionService()
class CreatePermissionRequest(BaseModel):
permission_key: str
permission_name: str
description: Optional[str] = None
class PermissionResponse(BaseModel):
id: str
permission_key: str
permission_name: str
description: Optional[str] = None
created_at: str
updated_at: str
@router.post(
"/create",
response_model=PermissionResponse,
operation_id="create-permission",
summary="Create Permission",
description="Create a new permission."
)
async def create_permission(
req: CreatePermissionRequest,
) -> PermissionResponse:
doc = await permission_service.create_permission(req.permission_key, req.permission_name, req.description)
return PermissionResponse(**doc.dict())

View File

@ -0,0 +1,50 @@
from datetime import datetime
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
from backend.services.permission.permission_service import PermissionService
from common.token.token_manager import TokenManager
router = APIRouter()
token_manager = TokenManager()
permission_service = PermissionService()
class QueryPermissionRequest(BaseModel):
permission_key: Optional[str] = None
permission_name: Optional[str] = None
page: int = 1
page_size: int = 10
class PermissionResponse(BaseModel):
id: str
permission_key: str
permission_name: str
description: Optional[str] = None
created_at: datetime
updated_at: datetime
class QueryPermissionResponse(BaseModel):
items: List[PermissionResponse]
total: int
page: int
page_size: int
@router.post(
"/query",
response_model=QueryPermissionResponse,
operation_id="query-permission",
summary="Query Permissions (paginated)",
description="Query permissions with pagination and fuzzy search. Only Admin role allowed."
)
async def query_permissions(
req: QueryPermissionRequest,
) -> QueryPermissionResponse:
result = await permission_service.query_permissions(req.permission_key, req.permission_name, req.page, req.page_size)
items = [PermissionResponse(**item) for item in result["items"]]
return QueryPermissionResponse(
items=items,
total=result["total"],
page=result["page"],
page_size=result["page_size"]
)

View File

@ -0,0 +1,38 @@
from datetime import datetime
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional
from backend.services.permission.permission_service import PermissionService
from common.token.token_manager import TokenManager
router = APIRouter()
token_manager = TokenManager()
permission_service = PermissionService()
class UpdatePermissionRequest(BaseModel):
permission_id: str
permission_key: str
permission_name: str
description: Optional[str] = None
class PermissionResponse(BaseModel):
id: str
permission_key: str
permission_name: str
description: Optional[str] = None
created_at: datetime
updated_at: datetime
@router.post(
"/update",
response_model=PermissionResponse,
operation_id="update-permission",
summary="Update Permission",
description="Update an existing permission by id. Only Admin role allowed."
)
async def update_permission(
req: UpdatePermissionRequest,
) -> PermissionResponse:
doc = await permission_service.update_permission(req.permission_id, req.permission_key, req.permission_name, req.description)
return PermissionResponse(**doc.dict())

View File

@ -0,0 +1,10 @@
from fastapi import APIRouter
from .create_role import router as create_role_router
from .update_role import router as update_role_router
from .query_role import router as query_role_router
router = APIRouter()
router.include_router(create_role_router, prefix="/role", tags=["role"])
router.include_router(update_role_router, prefix="/role", tags=["role"])
router.include_router(query_role_router, prefix="/role", tags=["role"])

View File

@ -0,0 +1,40 @@
from datetime import datetime
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
from backend.services.permission.role_service import RoleService
from common.token.token_manager import TokenManager
router = APIRouter()
token_manager = TokenManager()
role_service = RoleService()
class CreateRoleRequest(BaseModel):
role_key: str
role_name: str
role_description: Optional[str] = None
role_level: int
class RoleResponse(BaseModel):
id: str
role_key: str
role_name: str
role_description: Optional[str] = None
permission_ids: List[str]
role_level: int
created_at: datetime
updated_at: datetime
@router.post(
"/create",
response_model=RoleResponse,
operation_id="create-role",
summary="Create Role",
description="Create a new role."
)
async def create_role(
req: CreateRoleRequest,
) -> RoleResponse:
doc = await role_service.create_role(req.role_key, req.role_name, req.role_description, req.role_level)
return RoleResponse(**doc.dict())

View File

@ -0,0 +1,52 @@
from datetime import datetime
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
from backend.services.permission.role_service import RoleService
from common.token.token_manager import TokenManager
router = APIRouter()
token_manager = TokenManager()
role_service = RoleService()
class QueryRoleRequest(BaseModel):
role_key: Optional[str] = None
role_name: Optional[str] = None
page: int = 1
page_size: int = 10
class RoleResponse(BaseModel):
id: str
role_key: str
role_name: str
role_description: Optional[str] = None
permission_ids: List[str]
role_level: int
created_at: datetime
updated_at: datetime
class QueryRoleResponse(BaseModel):
items: List[RoleResponse]
total: int
page: int
page_size: int
@router.post(
"/query",
response_model=QueryRoleResponse,
operation_id="query-role",
summary="Query Roles (paginated)",
description="Query roles with pagination and fuzzy search. Only Admin role allowed."
)
async def query_roles(
req: QueryRoleRequest,
) -> QueryRoleResponse:
result = await role_service.query_roles(req.role_key, req.role_name, req.page, req.page_size)
items = [RoleResponse(**item) for item in result["items"]]
return QueryRoleResponse(
items=items,
total=result["total"],
page=result["page"],
page_size=result["page_size"]
)

View File

@ -0,0 +1,41 @@
from datetime import datetime
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
from backend.services.permission.role_service import RoleService
from common.token.token_manager import TokenManager
router = APIRouter()
token_manager = TokenManager()
role_service = RoleService()
class UpdateRoleRequest(BaseModel):
role_id: str
role_key: str
role_name: str
role_description: Optional[str] = None
role_level: int
class RoleResponse(BaseModel):
id: str
role_key: str
role_name: str
role_description: Optional[str] = None
permission_ids: List[str]
role_level: int
created_at: datetime
updated_at: datetime
@router.post(
"/update",
response_model=RoleResponse,
operation_id="update-role",
summary="Update Role",
description="Update an existing role by id. Only Admin role allowed."
)
async def update_role(
req: UpdateRoleRequest,
) -> RoleResponse:
doc = await role_service.update_role(req.role_id, req.role_key, req.role_name, req.role_description, req.role_level)
return RoleResponse(**doc.dict())

View File

@ -0,0 +1 @@
export RABBITMQ_HOST=localhost