feat: implement RBAC permissions system

Add role-based access control with hierarchy: super_admin > team_admin >
engineer > viewer. Adds is_super_admin boolean to User model (migration 010),
centralized backend permissions module, frontend usePermissions hook, and
UI enforcement (conditional Create/Edit buttons, editor redirect for viewers,
role badge in header). All endpoint admin checks updated from role=="admin"
to is_super_admin.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-05 02:42:44 -05:00
parent d7c5c8c9ce
commit 34daa26a67
20 changed files with 428 additions and 65 deletions

View File

@@ -25,7 +25,7 @@ def slugify(name: str) -> str:
def can_manage_category(user: User, category: TreeCategory) -> bool:
"""Check if user can manage (edit/delete) a category."""
# Global admins can manage any category
if user.role == "admin":
if user.is_super_admin:
return True
# Team admins can manage their team's categories
if user.is_team_admin and category.team_id == user.team_id:
@@ -36,7 +36,7 @@ def can_manage_category(user: User, category: TreeCategory) -> bool:
def can_create_category(user: User, team_id: Optional[UUID]) -> bool:
"""Check if user can create a category for the given team."""
# Global admins can create global categories (team_id=None) or any team's categories
if user.role == "admin":
if user.is_super_admin:
return True
# Team admins can only create categories for their own team
if user.is_team_admin and team_id == user.team_id:
@@ -123,7 +123,7 @@ async def get_category(
)
# Check access: global categories visible to all, team categories only to team members
if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin":
if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this category"

View File

@@ -78,7 +78,7 @@ def can_access_tree(user: User, tree: Tree) -> bool:
return True
if tree.team_id == user.team_id and user.team_id is not None:
return True
if user.role == "admin":
if user.is_super_admin:
return True
return False

View File

@@ -22,7 +22,7 @@ router = APIRouter(prefix="/step-categories", tags=["step-categories"])
def can_manage_step_category(user: User, category: StepCategory) -> bool:
"""Check if user can manage (edit/delete) a step category."""
# Global admins can manage any category
if user.role == "admin":
if user.is_super_admin:
return True
# Team admins can manage their team's categories
if user.is_team_admin and category.team_id == user.team_id:
@@ -33,7 +33,7 @@ def can_manage_step_category(user: User, category: StepCategory) -> bool:
def can_create_step_category(user: User, team_id: Optional[UUID]) -> bool:
"""Check if user can create a step category for the given team."""
# Global admins can create global categories (team_id=None) or any team's categories
if user.role == "admin":
if user.is_super_admin:
return True
# Team admins can only create categories for their own team
if user.is_team_admin and team_id == user.team_id:
@@ -113,7 +113,7 @@ async def get_step_category(
)
# Check access: global categories visible to all, team categories only to team members
if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin":
if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this step category"

View File

@@ -7,7 +7,7 @@ from sqlalchemy import select, or_, and_, func, desc, Integer, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_user
from app.api.deps import get_current_user, require_engineer_or_admin
from app.models.user import User
from app.models.step_library import StepLibrary, StepRating
from app.models.step_category import StepCategory
@@ -33,14 +33,16 @@ def can_view_step(user: User, step: StepLibrary) -> bool:
if step.visibility == 'private':
return step.created_by == user.id
if step.visibility == 'team':
return step.team_id == user.team_id or user.role == 'admin'
return step.team_id == user.team_id or user.is_super_admin
return False
def can_edit_step(user: User, step: StepLibrary) -> bool:
"""Check if user can edit/delete a step."""
if user.role == 'admin':
if user.is_super_admin:
return True
if user.role == 'viewer':
return False
return step.created_by == user.id
@@ -300,9 +302,9 @@ async def get_step(
async def create_step(
step_data: StepLibraryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(require_engineer_or_admin)
):
"""Create a new step."""
"""Create a new step (engineers and admins only, not viewers)."""
# Validate category if provided
if step_data.category_id:
cat_result = await db.execute(
@@ -316,7 +318,7 @@ async def create_step(
# Team validation: can only set team_id to own team
team_id = step_data.team_id
if team_id and team_id != current_user.team_id and current_user.role != 'admin':
if team_id and team_id != current_user.team_id and not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="Cannot create step for another team")
step = StepLibrary(

View File

@@ -19,13 +19,15 @@ def can_manage_tree_tags(user: User, tree: Tree) -> bool:
"""Check if user can manage tags on a tree.
Allowed:
- Tree author
- Global admins
- Tree author (engineer+)
- Super admins
- Team admins for their team's trees
"""
if user.id == tree.author_id:
if user.is_super_admin:
return True
if user.role == "admin":
if user.role == "viewer":
return False
if user.id == tree.author_id:
return True
if user.is_team_admin and tree.team_id == user.team_id:
return True
@@ -35,12 +37,15 @@ def can_manage_tree_tags(user: User, tree: Tree) -> bool:
def can_create_tag(user: User, team_id: Optional[UUID]) -> bool:
"""Check if user can create a tag for the given scope.
- Global admins can create global tags (team_id=None)
- Team admins and global admins can create team-specific tags
- Regular users can create team tags for their own team
- Super admins can create global tags (team_id=None)
- Team admins and super admins can create team-specific tags
- Engineers can create team tags for their own team
- Viewers cannot create tags
"""
if user.role == "admin":
if user.is_super_admin:
return True
if user.role == "viewer":
return False
# For team-specific tags, user must belong to that team
if team_id is not None and team_id == user.team_id:
return True
@@ -133,7 +138,7 @@ async def get_tag(
)
# Check access: global tags visible to all, team tags only to team members
if tag.team_id and tag.team_id != current_user.team_id and current_user.role != "admin":
if tag.team_id and tag.team_id != current_user.team_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tag"
@@ -231,7 +236,7 @@ async def add_tags_to_tree(
# Try to find existing tag
# Determine scope: use tree's team, or global for admin-owned trees
tag_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None)
tag_team_id = tree.team_id or (current_user.team_id if not current_user.is_super_admin else None)
tag_query = select(TreeTag).where(
TreeTag.slug == slug,
@@ -361,7 +366,7 @@ async def replace_tree_tags(
tree.tags.clear()
# Add new tags
tag_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None)
tag_team_id = tree.team_id or (current_user.team_id if not current_user.is_super_admin else None)
for tag_name in tag_data.tags:
slug = TreeTag.slugify(tag_name)
@@ -428,7 +433,7 @@ async def get_tree_tags(
if not tree.is_public:
if tree.author_id != current_user.id:
if tree.team_id != current_user.team_id:
if current_user.role != "admin":
if not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"

View File

@@ -53,6 +53,7 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
category_info=category_info,
tags=tree.tag_names,
author_id=tree.author_id,
team_id=tree.team_id,
is_active=tree.is_active,
is_public=tree.is_public,
is_default=tree.is_default,
@@ -250,7 +251,7 @@ async def get_tree(
tree.is_public or
tree.author_id == current_user.id or
(tree.team_id == current_user.team_id and current_user.team_id is not None) or
current_user.role == "admin"
current_user.is_super_admin
)
if not tree.is_active or not can_access:
raise HTTPException(
@@ -274,7 +275,7 @@ async def create_tree(
- tags: List of tag names to assign (creates new tags if needed)
"""
# Only admins can create default/system trees
is_default = tree_data.is_default and current_user.role == "admin"
is_default = tree_data.is_default and current_user.is_super_admin
# Verify category exists if provided
if tree_data.category_id:
@@ -288,7 +289,7 @@ async def create_tree(
detail="Category not found"
)
# Check category access
if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin":
if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this category"
@@ -310,7 +311,7 @@ async def create_tree(
# Handle tags
if tree_data.tags:
tree_team_id = new_tree.team_id or (current_user.team_id if current_user.role != "admin" else None)
tree_team_id = new_tree.team_id or (current_user.team_id if not current_user.is_super_admin else None)
# Collect tags to add
tags_to_add = []
@@ -401,7 +402,7 @@ async def update_tree(
# Check if user can edit: must be author, team admin for team trees, or global admin
can_edit = (
tree.author_id == current_user.id or
current_user.role == "admin" or
current_user.is_super_admin or
(current_user.is_team_admin and tree.team_id == current_user.team_id)
)
if not can_edit:
@@ -425,7 +426,7 @@ async def update_tree(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
if category.team_id and category.team_id != current_user.team_id and current_user.role != "admin":
if category.team_id and category.team_id != current_user.team_id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this category"
@@ -455,7 +456,7 @@ async def update_tree(
)
# Add new tags
tree_team_id = tree.team_id or (current_user.team_id if current_user.role != "admin" else None)
tree_team_id = tree.team_id or (current_user.team_id if not current_user.is_super_admin else None)
added_tag_ids = set()
for tag_name in tags_data: