{"record":{"id":"842a4a5d75bbe04b","repo":"ruvnet/RuView","slug":"not-enough-permissions-required-scope-scope","errorCode":null,"errorMessage":"Not enough permissions. Required scope: {scope}","messagePattern":"Not enough permissions\\. Required scope: (.+?)","errorType":"http","errorClass":"HTTPException","httpStatus":403,"severity":"error","filePath":"plans/phase2-architecture/api-architecture.md","lineNumber":1599,"sourceCode":"                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Token has expired\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n        except jwt.JWTError:\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Invalid token\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n    \n    def require_scopes(self, required_scopes: List[str]):\n        \"\"\"Decorator to require specific scopes\"\"\"\n        async def scope_checker(token_data: dict = Depends(self.verify_token)):\n            user_scopes = token_data.get('scopes', [])\n            \n            for scope in required_scopes:\n                if scope not in user_scopes:\n                    raise HTTPException(\n                        status_code=status.HTTP_403_FORBIDDEN,\n                        detail=f\"Not enough permissions. Required scope: {scope}\"\n                    )\n            \n            return token_data\n        \n        return scope_checker\n\n# API Scopes\nclass APIScopes:\n    # Read scopes\n    POSE_READ = \"pose:read\"\n    ANALYTICS_READ = \"analytics:read\"\n    SYSTEM_READ = \"system:read\"\n    \n    # Write scopes\n    CONFIG_WRITE = \"config:write\"\n    ZONE_WRITE = \"zone:write\"","sourceCodeStart":1581,"sourceCodeEnd":1617,"githubUrl":"https://github.com/ruvnet/RuView/blob/4685618388a5e49fad5b3005806f3bdd6a7c25c3/plans/phase2-architecture/api-architecture.md#L1581-L1617","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Request a new token that includes the exact scope named in the error detail (compare against the APIScopes constants, e.g. 'pose:read')","Check the token's decoded 'scopes' claim to see what was actually granted","Fix scope issuance at the /token endpoint so the user's permissions map to the canonical APIScopes strings","If 403 is unexpected, confirm the route uses the decorator with the intended scope list (typos propagate silently into 403s)"],"exampleFix":"# before\n@app.post('/pose/streams', dependencies=[Depends(auth.require_scopes([APIScopes.POSE_READ]))])\nasync def create_stream(...): ...  # clients with read-only tokens get 403\n\n# after\n@app.post('/pose/streams', dependencies=[Depends(auth.require_scopes([APIScopes.POSE_WRITE]))])\nasync def create_stream(...): ...  # scope now matches the write intent; issue POSE_WRITE tokens to callers","handlingStrategy":"validation","validationCode":"import jwt\nunverified = jwt.decode(token, options={'verify_signature': False})\nrequired = {'pose:read'}\nmissing = required - set(unverified.get('scopes', []))\nif missing:\n    token = await request_token_with_scopes(missing)  # avoid the guaranteed 403","typeGuard":"def token_has_scopes(token_unverified: dict, required: list[str]) -> bool:\n    return all(s in token_unverified.get('scopes', []) for s in required)","tryCatchPattern":"try:\n    await client.post('/api/pose/streams', ...)\nexcept HTTPException as e:\n    if e.status_code == 403:\n        raise PermissionError(e.detail)  # surface required scope to the operator\n    raise","preventionTips":["Import scope strings from the shared APIScopes constants instead of retyping them","Issue tokens from a permission table so users automatically get the scopes their role implies","Check the token's decoded 'scopes' claim before calling privileged endpoints"],"tags":["python","fastapi","authorization","scopes","http-403"],"backgroundTag":null,"analyzedSha":"4685618388a5e49fad5b3005806f3bdd6a7c25c3","analyzedAt":"2026-08-16T06:09:40.886Z","schemaVersion":2},"datasetVersion":"2026-08-16T08:17:34.114Z"}