From 34daa26a6756bf670131f6a779a3ce02b8d529af Mon Sep 17 00:00:00 2001
From: chihlasm
Date: Thu, 5 Feb 2026 02:42:44 -0500
Subject: [PATCH] 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
---
CLAUDE.md | 24 ++++-
.../versions/010_add_is_super_admin.py | 33 +++++++
backend/app/api/deps.py | 12 ++-
backend/app/api/endpoints/categories.py | 6 +-
backend/app/api/endpoints/folders.py | 2 +-
backend/app/api/endpoints/step_categories.py | 6 +-
backend/app/api/endpoints/steps.py | 14 +--
backend/app/api/endpoints/tags.py | 29 +++---
backend/app/api/endpoints/trees.py | 15 +--
backend/app/core/permissions.py | 94 +++++++++++++++++++
backend/app/models/user.py | 9 +-
backend/app/schemas/tree.py | 1 +
backend/app/schemas/user.py | 3 +-
docs/RBAC-IMPLEMENTATION-QUESTIONS.md | 91 ++++++++++++++++++
frontend/src/components/layout/AppLayout.tsx | 16 ++++
frontend/src/hooks/usePermissions.ts | 75 +++++++++++++++
frontend/src/pages/TreeEditorPage.tsx | 13 ++-
frontend/src/pages/TreeLibraryPage.tsx | 46 +++++----
frontend/src/types/tree.ts | 1 +
frontend/src/types/user.ts | 3 +-
20 files changed, 428 insertions(+), 65 deletions(-)
create mode 100644 backend/alembic/versions/010_add_is_super_admin.py
create mode 100644 backend/app/core/permissions.py
create mode 100644 docs/RBAC-IMPLEMENTATION-QUESTIONS.md
create mode 100644 frontend/src/hooks/usePermissions.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 10b246c0..a65e61d6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,6 +96,15 @@ When adding new frontend pages or components, use "ResolutionFlow" for any user-
- Backend `get_refresh_token_payload` dependency extracts refresh token from Authorization header
- Frontend Axios interceptor queues failed requests during refresh, retries after success
- Auth store synced after silent refresh via `setTokens` action
+- **RBAC & Permissions:**
+ - `is_super_admin` boolean on User model (migration 010)
+ - Role hierarchy: super_admin > team_admin > engineer > viewer
+ - `role` field values: 'engineer' | 'viewer' (no more 'admin')
+ - Team Admin = `role='engineer'` + `is_team_admin=True` + valid `team_id`
+ - Backend centralized: `backend/app/core/permissions.py`
+ - Frontend hook: `frontend/src/hooks/usePermissions.ts`
+ - Viewers CAN: browse trees, start sessions, rate steps
+ - Viewers CANNOT: create/edit trees, steps, tags, categories
- **Session Scratchpad (Floating Overlay):**
- Fixed-position overlay panel (420px wide, 55vh tall) on right edge
- Floating button when collapsed, slide-in panel when expanded
@@ -158,6 +167,7 @@ patherly/
│ │ ├── core/
│ │ │ ├── config.py # Settings (pydantic-settings)
│ │ │ ├── database.py # Async SQLAlchemy
+│ │ │ ├── permissions.py # Centralized RBAC (role checks, content guards)
│ │ │ ├── security.py # JWT + password hashing
│ │ │ ├── logging_config.py # Structured logging
│ │ │ └── middleware.py # Request logging
@@ -198,7 +208,7 @@ patherly/
│ │ │ ├── auth.ts
│ │ │ ├── trees.ts
│ │ │ └── sessions.ts
-│ │ ├── hooks/ # Custom React hooks (useKeyboardShortcuts)
+│ │ ├── hooks/ # Custom React hooks (useKeyboardShortcuts, usePermissions)
│ │ ├── store/
│ │ │ ├── authStore.ts # Zustand auth state
│ │ │ ├── themeStore.ts # Dark/light theme
@@ -496,6 +506,17 @@ if settings.ALLOW_RAILWAY_ORIGINS:
```
When using `allow_origin_regex` for wildcard patterns, also include `allow_origins` for explicit custom domains. The regex alone won't match custom domains like `resolutionflow.com`.
+### RBAC Permission Checks
+
+- Backend auth deps: `get_current_user` (any logged-in), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only)
+- Backend: `is_super_admin` replaces all `role == "admin"` checks. Never use `role == "admin"`.
+- Frontend: use `usePermissions()` hook for all role/permission checks
+- `TreeListItem` includes `team_id` for frontend permission checks (`author_id` and `team_id` are nullable)
+
+### Alembic Migrations: Test Data State Before Writing WHERE Clauses
+
+Migration 010 had `WHERE role = 'admin'` but the only user already had `role = 'engineer'` (changed by earlier work), so the UPDATE matched zero rows. Always verify actual data values before writing conditional migrations, or use broader conditions.
+
### findNode Requires Tree Structure Parameter
```tsx
@@ -792,6 +813,7 @@ Position overlay at `right-2` (not `right-0`) so it sits inside the page scrollb
1. Create test database: `docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"`
2. Install dev deps: `pip install -r requirements-dev.txt`
3. Ensure pytest-asyncio version: `pip install pytest-asyncio==0.24.0`
+4. If `unrecognized arguments: --cov` error: run with `--override-ini="addopts="` or install `pytest-cov`
### API 500 errors
diff --git a/backend/alembic/versions/010_add_is_super_admin.py b/backend/alembic/versions/010_add_is_super_admin.py
new file mode 100644
index 00000000..27fb2a2f
--- /dev/null
+++ b/backend/alembic/versions/010_add_is_super_admin.py
@@ -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')
diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index ee738c77..016fd006 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -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"
diff --git a/backend/app/api/endpoints/categories.py b/backend/app/api/endpoints/categories.py
index c63d2db5..b9ef3a6b 100644
--- a/backend/app/api/endpoints/categories.py
+++ b/backend/app/api/endpoints/categories.py
@@ -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"
diff --git a/backend/app/api/endpoints/folders.py b/backend/app/api/endpoints/folders.py
index f2358eb8..918bc543 100644
--- a/backend/app/api/endpoints/folders.py
+++ b/backend/app/api/endpoints/folders.py
@@ -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
diff --git a/backend/app/api/endpoints/step_categories.py b/backend/app/api/endpoints/step_categories.py
index c21ab917..6c06a8c6 100644
--- a/backend/app/api/endpoints/step_categories.py
+++ b/backend/app/api/endpoints/step_categories.py
@@ -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"
diff --git a/backend/app/api/endpoints/steps.py b/backend/app/api/endpoints/steps.py
index 48291639..ce1d67e0 100644
--- a/backend/app/api/endpoints/steps.py
+++ b/backend/app/api/endpoints/steps.py
@@ -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(
diff --git a/backend/app/api/endpoints/tags.py b/backend/app/api/endpoints/tags.py
index 27492372..9799f8d7 100644
--- a/backend/app/api/endpoints/tags.py
+++ b/backend/app/api/endpoints/tags.py
@@ -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"
diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py
index d9e20691..d1f2c771 100644
--- a/backend/app/api/endpoints/trees.py
+++ b/backend/app/api/endpoints/trees.py
@@ -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:
diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py
new file mode 100644
index 00000000..2276f02a
--- /dev/null
+++ b/backend/app/core/permissions.py
@@ -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
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index f824470b..04b92c93 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -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)
diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py
index de9b3b25..8810f0d5 100644
--- a/backend/app/schemas/tree.py
+++ b/backend/app/schemas/tree.py
@@ -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
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 888c73b1..02c9aff1 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -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
diff --git a/docs/RBAC-IMPLEMENTATION-QUESTIONS.md b/docs/RBAC-IMPLEMENTATION-QUESTIONS.md
new file mode 100644
index 00000000..88b48d76
--- /dev/null
+++ b/docs/RBAC-IMPLEMENTATION-QUESTIONS.md
@@ -0,0 +1,91 @@
+# RBAC Implementation - Flagged Questions & Context
+
+## Purpose
+This document supplements Claude Code's RBAC implementation plan with questions and considerations for ResolutionFlow's multi-tenant SaaS architecture.
+
+---
+
+## Multi-Tenant Model Overview
+
+### Account Tiers
+- **Free/Individual** — Single user, access to public trees only
+- **Team Package** — Team owner + invited members, private team trees + public trees
+
+### Content Visibility
+- **Public Trees** — ResolutionFlow official content, visible to all authenticated users
+- **Team Trees** — Created by team members, visible only to that team
+
+### Role Hierarchy
+
+| Role | Scope | Capabilities |
+|------|-------|--------------|
+| **Super Admin** | System-wide | Manage public trees, all teams, all users |
+| **Team Owner** | Their team | Everything Team Admin can do + billing management |
+| **Team Admin** | Their team | Invite/remove members, assign roles, manage team trees |
+| **Engineer** | Their team | Create/edit team trees & steps, fork public trees |
+| **Viewer** | Their team | Read-only access to team + public trees, run sessions |
+
+### Key Architectural Decisions
+- Database should support multi-team membership (junction table), but UI enforces single team for now
+- Team Owner is functionally a Team Admin with billing powers (no separate role needed initially)
+
+---
+
+## Flagged Questions for Implementation
+
+### 1. Step Library Permissions (Phase 2.5)
+- Can viewers rate/review steps, or is that engineer+ only?
+- How do ratings work across teams — are they global or team-scoped?
+
+### 2. Session Permissions
+- Can viewers *create* new troubleshooting sessions, or only view past session history?
+- Clarify the intended behavior before implementing.
+
+### 3. Personal Tree Branching (Phase 2.5)
+- When a user forks a public tree into their team's space, who owns it?
+- Does the original author get attribution, or is it a clean copy?
+
+### 4. Team Content Isolation
+- The current plan references `team_id` but doesn't explicitly enforce that team trees are invisible to other teams.
+- Ensure `build_tree_access_filter()` and `build_visibility_filter()` account for:
+ - User's team membership
+ - Public vs team-private trees
+ - Cross-team isolation
+
+### 5. Super Admin Role
+- The plan has "admin" but doesn't distinguish between "ResolutionFlow super admin" (system-wide) and "Team Admin" (team-scoped).
+- Consider: Should the database have an `is_super_admin` flag, or is this handled by a specific team/user ID?
+
+### 6. Future Multi-Team Membership
+- The database should support users belonging to multiple teams (junction table) even if the UI enforces single-team for now.
+- Confirm the schema supports this or flag what migration would be needed later.
+
+### 7. Subscription Lapse Handling (Low Priority - Not MVP)
+- What happens to team trees if a team subscription expires?
+- Options to consider:
+ - Team downgraded to viewer-only (can see but not edit)
+ - Grace period before content becomes inaccessible
+ - Export option before lockout
+- Not needed for MVP, but note where this logic would hook in.
+
+---
+
+## Implementation Checklist
+
+Before starting RBAC implementation, confirm answers to:
+
+- [ ] Session permissions: Can viewers create sessions or only view history?
+- [ ] Step ratings: Viewer participation yes/no?
+- [ ] Super admin flag: New DB field or handled differently?
+- [ ] Multi-team schema: Current schema supports this or needs migration?
+
+---
+
+## Related Documentation
+- Claude Code's original RBAC plan (see project knowledge)
+- Phase 2.5 roadmap (Step Library, Personal Branching)
+- CURRENT-STATE.md
+
+---
+
+*Last updated: February 2025*
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
index b520d733..8b83b1bd 100644
--- a/frontend/src/components/layout/AppLayout.tsx
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -1,5 +1,6 @@
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
+import { usePermissions } from '@/hooks/usePermissions'
import { ThemeToggle } from '@/components/common/ThemeToggle'
import { BrandLogo } from '@/components/common/BrandLogo'
import { BrandWordmark } from '@/components/common/BrandWordmark'
@@ -9,6 +10,7 @@ export function AppLayout() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
+ const { effectiveRole } = usePermissions()
const handleLogout = async () => {
await logout()
@@ -53,6 +55,20 @@ export function AppLayout() {
{user?.name || user?.email}
+ {effectiveRole && effectiveRole !== 'engineer' && (
+
+ {effectiveRole === 'super_admin' ? 'Super Admin' :
+ effectiveRole === 'team_admin' ? 'Team Admin' :
+ 'Viewer'}
+
+ )}
-
-
- Create Tree
-
+ {canCreateTrees && (
+
+
+ Create Tree
+
+ )}
{/* Search and Filter */}
@@ -306,16 +310,18 @@ export function TreeLibraryPage() {