Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"neo4j-graphrag[openai,sentence-transformers]",
"pydantic",
"pydantic-settings",
"PyJWT",
]

[project.optional-dependencies]
Expand Down
51 changes: 51 additions & 0 deletions src/scouter/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Multi-tenant RBAC authorization system."""

from scouter.auth.dependencies import (
get_identity,
require_all_permissions,
require_any_permission,
require_permission,
)
from scouter.auth.exceptions import (
AuthorizationError,
InvalidTokenError,
PermissionDeniedError,
TenantMembershipError,
TenantNotFoundError,
UserNotFoundError,
)
from scouter.auth.middleware import AuthorizationMiddleware
from scouter.auth.rbac import has_all_permissions, has_any_permission, has_permission
from scouter.auth.types import IdentityContext
from scouter.db.auth import (
build_identity_context,
create_rbac_constraints,
get_user_permissions,
get_user_roles,
resolve_user_from_oauth,
verify_tenant_membership,
)

__all__ = [
"AuthorizationError",
"AuthorizationMiddleware",
"IdentityContext",
"InvalidTokenError",
"PermissionDeniedError",
"TenantMembershipError",
"TenantNotFoundError",
"UserNotFoundError",
"build_identity_context",
"create_rbac_constraints",
"get_identity",
"get_user_permissions",
"get_user_roles",
"has_all_permissions",
"has_any_permission",
"has_permission",
"require_all_permissions",
"require_any_permission",
"require_permission",
"resolve_user_from_oauth",
"verify_tenant_membership",
]
99 changes: 99 additions & 0 deletions src/scouter/auth/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""FastAPI dependencies for authorization."""

from fastapi import Depends, HTTPException, Request

from scouter.auth.rbac import has_permission
from scouter.auth.types import IdentityContext


def get_identity(request: Request) -> IdentityContext:
"""Dependency to get identity context from request state.

Args:
request: FastAPI request object

Returns:
IdentityContext dict

Raises:
HTTPException: If identity is not found in request state
"""
identity = getattr(request.state, "identity", None)
if not identity:
raise HTTPException(
status_code=401,
detail="Identity context not found. Ensure authorization middleware is configured.",
)
return identity


def require_permission(required: str):
"""Create a dependency that requires a specific permission.

Args:
required: Permission string that must be granted

Returns:
Dependency function that checks permission
"""

def dependency(
identity: IdentityContext = Depends(get_identity),
) -> IdentityContext:
permissions = identity.get("permissions", set())
if not has_permission(permissions, required):
raise HTTPException(
status_code=403,
detail=f"Permission denied: {required}",
)
return identity

return dependency


def require_any_permission(required: set[str]):
"""Create a dependency that requires any of the specified permissions.

Args:
required: Set of permission strings, at least one must be granted

Returns:
Dependency function that checks permissions
"""

def dependency(
identity: IdentityContext = Depends(get_identity),
) -> IdentityContext:
permissions = identity.get("permissions", set())
if not any(has_permission(permissions, perm) for perm in required):
raise HTTPException(
status_code=403,
detail=f"Permission denied: requires one of {required}",
)
return identity

return dependency


def require_all_permissions(required: set[str]):
"""Create a dependency that requires all specified permissions.

Args:
required: Set of permission strings, all must be granted

Returns:
Dependency function that checks permissions
"""

def dependency(
identity: IdentityContext = Depends(get_identity),
) -> IdentityContext:
permissions = identity.get("permissions", set())
if not all(has_permission(permissions, perm) for perm in required):
raise HTTPException(
status_code=403,
detail=f"Permission denied: requires all of {required}",
)
return identity

return dependency
25 changes: 25 additions & 0 deletions src/scouter/auth/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Authorization-specific exceptions."""


class AuthorizationError(Exception):
"""Base exception for authorization errors."""


class InvalidTokenError(AuthorizationError):
"""Raised when OAuth token is invalid or expired."""


class UserNotFoundError(AuthorizationError):
"""Raised when user cannot be resolved from OAuth identity."""


class TenantNotFoundError(AuthorizationError):
"""Raised when tenant is not specified or invalid."""


class PermissionDeniedError(AuthorizationError):
"""Raised when user lacks required permissions."""


class TenantMembershipError(AuthorizationError):
"""Raised when user is not a member of the required tenant."""
87 changes: 87 additions & 0 deletions src/scouter/auth/fastmcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""FastMCP integration examples for authorization.

Since FastMCP doesn't have built-in middleware, identity context must be
built explicitly by the executor and passed to tools.
"""

from scouter.auth.exceptions import PermissionDeniedError, UserNotFoundError
from scouter.auth.rbac import has_permission
from scouter.auth.types import IdentityContext
from scouter.db.auth import build_identity_context, resolve_user_from_oauth
from scouter.db.neo4j import get_neo4j_driver


def build_identity_from_token(
token_payload: dict,
tenant_id: str,
) -> IdentityContext:
"""Build identity context from decoded JWT payload for FastMCP.

This function should be called by the FastMCP executor after verifying
the OAuth token externally.

Args:
token_payload: Decoded JWT payload
tenant_id: Tenant identifier (must be provided explicitly)

Returns:
IdentityContext dict

Raises:
ValueError: If user cannot be resolved or is not in tenant
"""
provider = token_payload.get("iss")
sub = token_payload.get("sub")
if not provider or not sub:
msg = "Missing provider or sub in token"
raise ValueError(msg)

driver = get_neo4j_driver()
user_id = resolve_user_from_oauth(driver, provider, sub)
if not user_id:
msg = "User not found for OAuth identity"
raise UserNotFoundError(msg)

return build_identity_context(driver, user_id, tenant_id, token_payload)


def create_user_tool(identity: IdentityContext, payload: dict) -> dict:
"""Example tool that requires user:write permission.

Args:
identity: Identity context from build_identity_from_token
payload: Tool payload

Returns:
Tool result

Raises:
PermissionError: If permission is denied
"""
if not has_permission(identity["permissions"], "user:write"):
msg = "Permission denied: user:write"
raise PermissionDeniedError(msg)

# Tool implementation here
return {"status": "user created", "user_id": payload.get("user_id")}


def list_users_tool(identity: IdentityContext, payload: dict) -> dict:
"""Example tool that requires user:read permission.

Args:
identity: Identity context from build_identity_from_token
payload: Tool payload

Returns:
Tool result

Raises:
PermissionError: If permission is denied
"""
if not has_permission(identity["permissions"], "user:read"):
msg = "Permission denied: user:read"
raise PermissionDeniedError(msg)

# Tool implementation here
return {"users": []}
Loading
Loading