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:
33
backend/alembic/versions/010_add_is_super_admin.py
Normal file
33
backend/alembic/versions/010_add_is_super_admin.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""add is_super_admin to users
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-02-05
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '010'
|
||||
down_revision: Union[str, None] = '009'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add is_super_admin flag
|
||||
op.add_column('users',
|
||||
sa.Column('is_super_admin', sa.Boolean(), nullable=False, server_default=sa.text('false'))
|
||||
)
|
||||
# Migrate existing admin users: promote to super admin AND normalize role
|
||||
op.execute("UPDATE users SET is_super_admin = TRUE, role = 'engineer' WHERE role = 'admin'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore admin role for super admins before dropping column
|
||||
op.execute("UPDATE users SET role = 'admin' WHERE is_super_admin = TRUE")
|
||||
op.drop_column('users', 'is_super_admin')
|
||||
@@ -75,11 +75,11 @@ async def get_current_active_user(
|
||||
async def require_admin(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""Require admin role."""
|
||||
if current_user.role != "admin":
|
||||
"""Require super admin access."""
|
||||
if not current_user.is_super_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
detail="Super admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
@@ -87,8 +87,10 @@ async def require_admin(
|
||||
async def require_engineer_or_admin(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""Require engineer or admin role."""
|
||||
if current_user.role not in ("admin", "engineer"):
|
||||
"""Require engineer, team admin, or super admin role (blocks viewers)."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
if current_user.role not in ("engineer",):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Engineer or admin access required"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
94
backend/app/core/permissions.py
Normal file
94
backend/app/core/permissions.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Centralized permission checks for ResolutionFlow.
|
||||
|
||||
Role hierarchy: super_admin > team_admin > engineer > viewer
|
||||
|
||||
- super_admin: is_super_admin=True, full system access
|
||||
- team_admin: is_team_admin=True + valid team_id, manage team resources
|
||||
- engineer: role='engineer' (default), CRUD own trees/steps
|
||||
- viewer: role='viewer', read-only (can browse, run sessions, rate steps)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.step_library import StepLibrary
|
||||
from app.models.category import TreeCategory
|
||||
|
||||
ROLE_HIERARCHY = {
|
||||
"super_admin": 4,
|
||||
"team_admin": 3,
|
||||
"engineer": 2,
|
||||
"viewer": 1,
|
||||
}
|
||||
|
||||
|
||||
def get_effective_role(user: User) -> str:
|
||||
"""Get the effective role considering is_super_admin and is_team_admin flags."""
|
||||
if user.is_super_admin:
|
||||
return "super_admin"
|
||||
if user.is_team_admin and user.team_id is not None:
|
||||
return "team_admin"
|
||||
return user.role # "engineer" or "viewer"
|
||||
|
||||
|
||||
def has_minimum_role(user: User, minimum_role: str) -> bool:
|
||||
"""Check if user has at least the specified role level."""
|
||||
effective = get_effective_role(user)
|
||||
return ROLE_HIERARCHY.get(effective, 0) >= ROLE_HIERARCHY.get(minimum_role, 0)
|
||||
|
||||
|
||||
def can_create_content(user: User) -> bool:
|
||||
"""Can the user create trees, steps, or other content? Viewers cannot."""
|
||||
return has_minimum_role(user, "engineer")
|
||||
|
||||
|
||||
def can_edit_tree(user: User, tree: Tree) -> bool:
|
||||
"""Can the user edit this specific tree?"""
|
||||
if user.is_super_admin:
|
||||
return True
|
||||
if not can_create_content(user):
|
||||
return False
|
||||
if tree.author_id == user.id:
|
||||
return True
|
||||
if user.is_team_admin and tree.team_id == user.team_id and user.team_id is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_delete_tree(user: User, tree: Tree) -> bool:
|
||||
"""Can the user delete this tree? Super admin only."""
|
||||
return user.is_super_admin
|
||||
|
||||
|
||||
def can_edit_step(user: User, step: StepLibrary) -> bool:
|
||||
"""Can the user edit/delete this step?"""
|
||||
if user.is_super_admin:
|
||||
return True
|
||||
if not can_create_content(user):
|
||||
return False
|
||||
return step.created_by == user.id
|
||||
|
||||
|
||||
def can_manage_category(user: User, category: TreeCategory) -> bool:
|
||||
"""Can the user edit/delete this category?"""
|
||||
if user.is_super_admin:
|
||||
return True
|
||||
if user.is_team_admin and category.team_id == user.team_id and user.team_id is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_manage_tree_tags(user: User, tree: Tree) -> bool:
|
||||
"""Can the user add/remove tags on this tree?"""
|
||||
if user.is_super_admin:
|
||||
return True
|
||||
if not can_create_content(user):
|
||||
return False
|
||||
if tree.author_id == user.id:
|
||||
return True
|
||||
if user.is_team_admin and tree.team_id == user.team_id and user.team_id is not None:
|
||||
return True
|
||||
return False
|
||||
@@ -25,6 +25,7 @@ class User(Base):
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
@@ -50,10 +51,10 @@ class User(Base):
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Returns True if user is a global (ResolutionFlow) admin."""
|
||||
return self.role == "admin"
|
||||
"""Returns True if user is a super admin (system-wide access)."""
|
||||
return self.is_super_admin
|
||||
|
||||
@property
|
||||
def can_manage_team(self) -> bool:
|
||||
"""Returns True if user can manage their team (team admin or global admin)."""
|
||||
return self.is_admin or (self.is_team_admin and self.team_id is not None)
|
||||
"""Returns True if user can manage their team (team admin or super admin)."""
|
||||
return self.is_super_admin or (self.is_team_admin and self.team_id is not None)
|
||||
|
||||
@@ -69,6 +69,7 @@ class TreeListResponse(BaseModel):
|
||||
category_info: Optional[CategoryInfo] = None
|
||||
tags: list[str] = [] # List of tag names
|
||||
author_id: Optional[UUID] = None
|
||||
team_id: Optional[UUID] = None
|
||||
is_active: bool
|
||||
is_public: bool
|
||||
is_default: bool
|
||||
|
||||
@@ -11,7 +11,7 @@ class UserBase(BaseModel):
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
|
||||
role: str = Field(default="engineer", description="User role: admin, engineer, or viewer")
|
||||
role: str = Field(default="engineer", description="User role: engineer or viewer")
|
||||
invite_code: Optional[str] = Field(None, description="Invite code for registration (required when invite system is enabled)")
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class UserLogin(BaseModel):
|
||||
class UserResponse(UserBase):
|
||||
id: UUID
|
||||
role: str
|
||||
is_super_admin: bool = False
|
||||
is_team_admin: bool = False
|
||||
team_id: Optional[UUID] = None
|
||||
created_at: datetime
|
||||
|
||||
Reference in New Issue
Block a user