ruvnet/RuView · error · HTTPException

Not enough permissions. Required scope: {scope}

Error message

Not enough permissions. Required scope: {scope}

What it means

HTTP 403 raised by the scope_checker returned from require_scopes when the verified token's 'scopes' list is missing at least one required scope. Authentication already succeeded (verify_token ran via Depends); this is purely an authorization failure, which is why the status is 403 rather than 401.

Source

Thrown at plans/phase2-architecture/api-architecture.md:1599

                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Token has expired",
                headers={"WWW-Authenticate": "Bearer"},
            )
        except jwt.JWTError:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid token",
                headers={"WWW-Authenticate": "Bearer"},
            )
    
    def require_scopes(self, required_scopes: List[str]):
        """Decorator to require specific scopes"""
        async def scope_checker(token_data: dict = Depends(self.verify_token)):
            user_scopes = token_data.get('scopes', [])
            
            for scope in required_scopes:
                if scope not in user_scopes:
                    raise HTTPException(
                        status_code=status.HTTP_403_FORBIDDEN,
                        detail=f"Not enough permissions. Required scope: {scope}"
                    )
            
            return token_data
        
        return scope_checker

# API Scopes
class APIScopes:
    # Read scopes
    POSE_READ = "pose:read"
    ANALYTICS_READ = "analytics:read"
    SYSTEM_READ = "system:read"
    
    # Write scopes
    CONFIG_WRITE = "config:write"
    ZONE_WRITE = "zone:write"

View on GitHub (pinned to 4685618388)

Solutions

  1. Request a new token that includes the exact scope named in the error detail (compare against the APIScopes constants, e.g. 'pose:read')
  2. Check the token's decoded 'scopes' claim to see what was actually granted
  3. Fix scope issuance at the /token endpoint so the user's permissions map to the canonical APIScopes strings
  4. If 403 is unexpected, confirm the route uses the decorator with the intended scope list (typos propagate silently into 403s)

Example fix

# before
@app.post('/pose/streams', dependencies=[Depends(auth.require_scopes([APIScopes.POSE_READ]))])
async def create_stream(...): ...  # clients with read-only tokens get 403

# after
@app.post('/pose/streams', dependencies=[Depends(auth.require_scopes([APIScopes.POSE_WRITE]))])
async def create_stream(...): ...  # scope now matches the write intent; issue POSE_WRITE tokens to callers
Defensive patterns

Strategy: validation

Validate before calling

import jwt
unverified = jwt.decode(token, options={'verify_signature': False})
required = {'pose:read'}
missing = required - set(unverified.get('scopes', []))
if missing:
    token = await request_token_with_scopes(missing)  # avoid the guaranteed 403

Type guard

def token_has_scopes(token_unverified: dict, required: list[str]) -> bool:
    return all(s in token_unverified.get('scopes', []) for s in required)

Try / catch

try:
    await client.post('/api/pose/streams', ...)
except HTTPException as e:
    if e.status_code == 403:
        raise PermissionError(e.detail)  # surface required scope to the operator
    raise

Prevention

When it happens

Trigger: Calling an endpoint decorated with @require_scopes([APIScopes.POSE_WRITE]) while the token was issued only pose:read; scope-string mismatch ('pose:read' vs 'poses:read'); token minted for a lower-privilege client used against an admin route.

Common situations: Read-only API keys used on write endpoints; scope taxonomy renamed in APIScopes but stale tokens still circulating; granting scopes at /token time from a user-permissions table that lacks the new permission.

Related errors


AI-assisted analysis of ruvnet/RuView@4685618388 (2026-08-16). Data as JSON: /api/errors/842a4a5d75bbe04b. Report an issue: GitHub.