feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
5e0615f36d
commit
e24d3e2a65
123 files changed
+25293
No files matched your search
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import (
|
||||
check_permissions,
|
||||
require_any_permission,
|
||||
require_grantable,
|
||||
require_permissions,
|
||||
)
|
||||
from app.repositories.role import RoleRepository
|
||||
from app.models.permission import Permission
|
||||
from app.core.params import DbIntId
|
||||
from app.models.schemas.role import (
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
RoleResponse,
|
||||
PermissionResponse
|
||||
)
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Reading the role catalogue is part of two surfaces: the Roles editor
|
||||
# (manage_roles) and the invite form, which needs the role dropdown
|
||||
# (manage_users). Gating reads on manage_roles alone breaks user invite.
|
||||
ROLE_READ_PERMISSIONS = ("manage_roles", "manage_users")
|
||||
|
||||
|
||||
@router.post("", response_model=RoleResponse)
|
||||
async def create_role(
|
||||
role_data: RoleCreate,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new role"""
|
||||
role_repo = RoleRepository(db)
|
||||
|
||||
# Check if default role already exists for org
|
||||
if role_data.is_default:
|
||||
existing_default = role_repo.get_default_role(current_user.organization_id)
|
||||
if existing_default:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Organization already has a default role"
|
||||
)
|
||||
|
||||
# Validate permissions exist
|
||||
if role_data.permissions:
|
||||
permission_ids = [p.id for p in role_data.permissions]
|
||||
existing_permissions = db.query(Permission)\
|
||||
.filter(Permission.id.in_(permission_ids))\
|
||||
.all()
|
||||
if len(existing_permissions) != len(permission_ids):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid permission"
|
||||
)
|
||||
# A new role is a grant too — otherwise the rule is one "create role,
|
||||
# then assign myself to it" away from being bypassed.
|
||||
require_grantable(current_user, {p.name for p in existing_permissions})
|
||||
|
||||
try:
|
||||
role = role_repo.create_role(
|
||||
name=role_data.name,
|
||||
description=role_data.description,
|
||||
organization_id=current_user.organization_id,
|
||||
is_default=role_data.is_default,
|
||||
permission_ids=[permission.id for permission in role_data.permissions]
|
||||
)
|
||||
return role
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Role with this name already exists"
|
||||
)
|
||||
|
||||
@router.get("", response_model=List[RoleResponse])
|
||||
async def list_roles(
|
||||
current_user: User = Depends(require_any_permission(*ROLE_READ_PERMISSIONS)),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List all roles in the organization"""
|
||||
role_repo = RoleRepository(db)
|
||||
return role_repo.get_roles_by_organization(current_user.organization_id)
|
||||
|
||||
@router.get("/{role_id}", response_model=RoleResponse)
|
||||
async def get_role(
|
||||
role_id: DbIntId,
|
||||
current_user: User = Depends(require_any_permission(*ROLE_READ_PERMISSIONS)),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get role details including permissions"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
return role
|
||||
|
||||
@router.put("/{role_id}", response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: DbIntId,
|
||||
role_data: RoleUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update role details"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
# Prevent editing default role
|
||||
if role.is_default:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot modify default role"
|
||||
)
|
||||
|
||||
# Check default role constraints
|
||||
if role_data.is_default:
|
||||
existing_default = role_repo.get_default_role(current_user.organization_id)
|
||||
if existing_default and existing_default.id != role_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Organization already has a default role"
|
||||
)
|
||||
|
||||
# Validate permissions if provided
|
||||
if role_data.permissions:
|
||||
permission_ids = [p.id for p in role_data.permissions]
|
||||
existing_permissions = db.query(Permission)\
|
||||
.filter(Permission.id.in_(permission_ids))\
|
||||
.all()
|
||||
if len(existing_permissions) != len(permission_ids):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid permission"
|
||||
)
|
||||
# Only the diff: re-saving a role that already holds a permission the
|
||||
# caller lacks is legitimate; adding one is the escalation.
|
||||
already_held = {p.name for p in role.permissions}
|
||||
require_grantable(
|
||||
current_user,
|
||||
{p.name for p in existing_permissions} - already_held,
|
||||
)
|
||||
|
||||
try:
|
||||
updated_role = role_repo.update_role(
|
||||
role_id,
|
||||
**role_data.model_dump(exclude_unset=True) # Changed from dict() to model_dump()
|
||||
)
|
||||
return updated_role
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Role with this name already exists"
|
||||
)
|
||||
|
||||
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_role(
|
||||
role_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a role"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
if role.is_default:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete default role"
|
||||
)
|
||||
|
||||
if role_repo.is_role_in_use(role_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete role that is assigned to users"
|
||||
)
|
||||
|
||||
role_repo.delete_role(role_id)
|
||||
|
||||
@router.post("/{role_id}/permissions/{permission}")
|
||||
async def add_role_permission(
|
||||
role_id: DbIntId,
|
||||
permission: str,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a permission to a role"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
# Resolve the name before the grant check so an unknown permission still
|
||||
# reads as 404 rather than "you may not grant that".
|
||||
if not db.query(Permission).filter(Permission.name == permission).first():
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
require_grantable(current_user, [permission])
|
||||
|
||||
success = role_repo.add_permission(role_id, permission)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
return {"message": "Permission added to role"}
|
||||
|
||||
@router.delete("/{role_id}/permissions/{permission}")
|
||||
async def remove_role_permission(
|
||||
role_id: DbIntId,
|
||||
permission: str,
|
||||
current_user: User = Depends(require_permissions("manage_roles")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove a permission from a role"""
|
||||
role_repo = RoleRepository(db)
|
||||
role = role_repo.get_role(role_id)
|
||||
|
||||
if not role or role.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
success = role_repo.remove_permission(role_id, permission)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
return {"message": "Permission removed from role"}
|
||||
|
||||
@router.get("/permissions/all", response_model=List[PermissionResponse])
|
||||
async def list_permissions(
|
||||
current_user: User = Depends(require_any_permission(*ROLE_READ_PERMISSIONS)),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List all available permissions"""
|
||||
return db.query(Permission).all()
|
||||
Reference in new issue
Block a user