fix: high-severity security hardening (Phase B permissions audit)

Phase B addresses 7 high-severity gaps from the permissions audit:

- B1: Enforce tree access check on session start via can_access_tree
- B2: Replace all inline permission helpers with centralized permissions.py
- B3: Fix require_engineer_or_admin to check is_team_admin before role
- B4: Add is_active field on User with enforcement in get_current_active_user
- B5: Add admin user management endpoints (list, get, role, team-admin, deactivate, activate)
- B6: Add rate limiting on auth/invite endpoints via slowapi (disabled in DEBUG)
- B7: Implement refresh token rotation with JTI-based revocation and meaningful logout

Also reduces access token TTL from 15 to 5 minutes and updates CLAUDE.md
with SaaS/MSP context for future planning sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-05 22:44:05 -05:00
parent 3e0fb92012
commit 71ba0b95a5
27 changed files with 743 additions and 229 deletions

View File

@@ -7,7 +7,8 @@ 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, require_engineer_or_admin
from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_view_step, can_edit_step
from app.models.user import User
from app.models.step_library import StepLibrary, StepRating
from app.models.step_category import StepCategory
@@ -25,27 +26,6 @@ from app.schemas.step_library import (
router = APIRouter(prefix="/steps", tags=["steps"])
# Permission helpers
def can_view_step(user: User, step: StepLibrary) -> bool:
"""Check if user can view a step based on visibility."""
if step.visibility == 'public':
return True
if step.visibility == 'private':
return step.created_by == user.id
if step.visibility == 'team':
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.is_super_admin:
return True
if user.role == 'viewer':
return False
return step.created_by == user.id
async def get_step_or_404(
step_id: UUID,
db: AsyncSession,
@@ -99,7 +79,7 @@ async def list_steps(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""List steps with filters and pagination."""
query = select(StepLibrary).where(
@@ -177,7 +157,7 @@ async def search_steps(
q: str = Query(..., min_length=1),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Full-text search for steps."""
# Use PostgreSQL full-text search
@@ -229,7 +209,7 @@ async def search_steps(
async def get_popular_tags(
limit: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Get popular tags with usage counts."""
# Use unnest to expand arrays and count occurrences
@@ -255,7 +235,7 @@ async def get_popular_tags(
async def get_step(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Get a step by ID."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
@@ -374,7 +354,7 @@ async def update_step(
step_id: UUID,
step_data: StepLibraryUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Update a step (owner or admin only)."""
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
@@ -444,7 +424,7 @@ async def update_step(
async def delete_step(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Soft delete a step (owner or admin only)."""
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
@@ -462,7 +442,7 @@ async def rate_step(
step_id: UUID,
rating_data: StepRatingCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Rate a step (1-5 stars with optional review)."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
@@ -516,7 +496,7 @@ async def update_rating(
step_id: UUID,
rating_data: StepRatingUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Update your rating for a step."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
@@ -563,7 +543,7 @@ async def update_rating(
async def delete_rating(
step_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Delete your rating for a step."""
step = await get_step_or_404(step_id, db, current_user, check_view=True)
@@ -593,7 +573,7 @@ async def get_reviews(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_active_user)
):
"""Get reviews for a step."""
await get_step_or_404(step_id, db, current_user, check_view=True)