Skip to content

Commit bd42ee8

Browse files
authored
Merge pull request #6 from farhoud/authz
Authz
2 parents 85e5894 + 75aa2a1 commit bd42ee8

13 files changed

Lines changed: 704 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies = [
1818
"neo4j-graphrag[openai,sentence-transformers]",
1919
"pydantic",
2020
"pydantic-settings",
21+
"PyJWT",
2122
]
2223

2324
[project.optional-dependencies]

src/scouter/auth/__init__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Multi-tenant RBAC authorization system."""
2+
3+
from scouter.auth.dependencies import (
4+
get_identity,
5+
require_all_permissions,
6+
require_any_permission,
7+
require_permission,
8+
)
9+
from scouter.auth.exceptions import (
10+
AuthorizationError,
11+
InvalidTokenError,
12+
PermissionDeniedError,
13+
TenantMembershipError,
14+
TenantNotFoundError,
15+
UserNotFoundError,
16+
)
17+
from scouter.auth.middleware import AuthorizationMiddleware
18+
from scouter.auth.rbac import has_all_permissions, has_any_permission, has_permission
19+
from scouter.auth.types import IdentityContext
20+
from scouter.db.auth import (
21+
build_identity_context,
22+
create_rbac_constraints,
23+
get_user_permissions,
24+
get_user_roles,
25+
resolve_user_from_oauth,
26+
verify_tenant_membership,
27+
)
28+
29+
__all__ = [
30+
"AuthorizationError",
31+
"AuthorizationMiddleware",
32+
"IdentityContext",
33+
"InvalidTokenError",
34+
"PermissionDeniedError",
35+
"TenantMembershipError",
36+
"TenantNotFoundError",
37+
"UserNotFoundError",
38+
"build_identity_context",
39+
"create_rbac_constraints",
40+
"get_identity",
41+
"get_user_permissions",
42+
"get_user_roles",
43+
"has_all_permissions",
44+
"has_any_permission",
45+
"has_permission",
46+
"require_all_permissions",
47+
"require_any_permission",
48+
"require_permission",
49+
"resolve_user_from_oauth",
50+
"verify_tenant_membership",
51+
]

src/scouter/auth/dependencies.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""FastAPI dependencies for authorization."""
2+
3+
from fastapi import Depends, HTTPException, Request
4+
5+
from scouter.auth.rbac import has_permission
6+
from scouter.auth.types import IdentityContext
7+
8+
9+
def get_identity(request: Request) -> IdentityContext:
10+
"""Dependency to get identity context from request state.
11+
12+
Args:
13+
request: FastAPI request object
14+
15+
Returns:
16+
IdentityContext dict
17+
18+
Raises:
19+
HTTPException: If identity is not found in request state
20+
"""
21+
identity = getattr(request.state, "identity", None)
22+
if not identity:
23+
raise HTTPException(
24+
status_code=401,
25+
detail="Identity context not found. Ensure authorization middleware is configured.",
26+
)
27+
return identity
28+
29+
30+
def require_permission(required: str):
31+
"""Create a dependency that requires a specific permission.
32+
33+
Args:
34+
required: Permission string that must be granted
35+
36+
Returns:
37+
Dependency function that checks permission
38+
"""
39+
40+
def dependency(
41+
identity: IdentityContext = Depends(get_identity),
42+
) -> IdentityContext:
43+
permissions = identity.get("permissions", set())
44+
if not has_permission(permissions, required):
45+
raise HTTPException(
46+
status_code=403,
47+
detail=f"Permission denied: {required}",
48+
)
49+
return identity
50+
51+
return dependency
52+
53+
54+
def require_any_permission(required: set[str]):
55+
"""Create a dependency that requires any of the specified permissions.
56+
57+
Args:
58+
required: Set of permission strings, at least one must be granted
59+
60+
Returns:
61+
Dependency function that checks permissions
62+
"""
63+
64+
def dependency(
65+
identity: IdentityContext = Depends(get_identity),
66+
) -> IdentityContext:
67+
permissions = identity.get("permissions", set())
68+
if not any(has_permission(permissions, perm) for perm in required):
69+
raise HTTPException(
70+
status_code=403,
71+
detail=f"Permission denied: requires one of {required}",
72+
)
73+
return identity
74+
75+
return dependency
76+
77+
78+
def require_all_permissions(required: set[str]):
79+
"""Create a dependency that requires all specified permissions.
80+
81+
Args:
82+
required: Set of permission strings, all must be granted
83+
84+
Returns:
85+
Dependency function that checks permissions
86+
"""
87+
88+
def dependency(
89+
identity: IdentityContext = Depends(get_identity),
90+
) -> IdentityContext:
91+
permissions = identity.get("permissions", set())
92+
if not all(has_permission(permissions, perm) for perm in required):
93+
raise HTTPException(
94+
status_code=403,
95+
detail=f"Permission denied: requires all of {required}",
96+
)
97+
return identity
98+
99+
return dependency

src/scouter/auth/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Authorization-specific exceptions."""
2+
3+
4+
class AuthorizationError(Exception):
5+
"""Base exception for authorization errors."""
6+
7+
8+
class InvalidTokenError(AuthorizationError):
9+
"""Raised when OAuth token is invalid or expired."""
10+
11+
12+
class UserNotFoundError(AuthorizationError):
13+
"""Raised when user cannot be resolved from OAuth identity."""
14+
15+
16+
class TenantNotFoundError(AuthorizationError):
17+
"""Raised when tenant is not specified or invalid."""
18+
19+
20+
class PermissionDeniedError(AuthorizationError):
21+
"""Raised when user lacks required permissions."""
22+
23+
24+
class TenantMembershipError(AuthorizationError):
25+
"""Raised when user is not a member of the required tenant."""

src/scouter/auth/fastmcp.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""FastMCP integration examples for authorization.
2+
3+
Since FastMCP doesn't have built-in middleware, identity context must be
4+
built explicitly by the executor and passed to tools.
5+
"""
6+
7+
from scouter.auth.exceptions import PermissionDeniedError, UserNotFoundError
8+
from scouter.auth.rbac import has_permission
9+
from scouter.auth.types import IdentityContext
10+
from scouter.db.auth import build_identity_context, resolve_user_from_oauth
11+
from scouter.db.neo4j import get_neo4j_driver
12+
13+
14+
def build_identity_from_token(
15+
token_payload: dict,
16+
tenant_id: str,
17+
) -> IdentityContext:
18+
"""Build identity context from decoded JWT payload for FastMCP.
19+
20+
This function should be called by the FastMCP executor after verifying
21+
the OAuth token externally.
22+
23+
Args:
24+
token_payload: Decoded JWT payload
25+
tenant_id: Tenant identifier (must be provided explicitly)
26+
27+
Returns:
28+
IdentityContext dict
29+
30+
Raises:
31+
ValueError: If user cannot be resolved or is not in tenant
32+
"""
33+
provider = token_payload.get("iss")
34+
sub = token_payload.get("sub")
35+
if not provider or not sub:
36+
msg = "Missing provider or sub in token"
37+
raise ValueError(msg)
38+
39+
driver = get_neo4j_driver()
40+
user_id = resolve_user_from_oauth(driver, provider, sub)
41+
if not user_id:
42+
msg = "User not found for OAuth identity"
43+
raise UserNotFoundError(msg)
44+
45+
return build_identity_context(driver, user_id, tenant_id, token_payload)
46+
47+
48+
def create_user_tool(identity: IdentityContext, payload: dict) -> dict:
49+
"""Example tool that requires user:write permission.
50+
51+
Args:
52+
identity: Identity context from build_identity_from_token
53+
payload: Tool payload
54+
55+
Returns:
56+
Tool result
57+
58+
Raises:
59+
PermissionError: If permission is denied
60+
"""
61+
if not has_permission(identity["permissions"], "user:write"):
62+
msg = "Permission denied: user:write"
63+
raise PermissionDeniedError(msg)
64+
65+
# Tool implementation here
66+
return {"status": "user created", "user_id": payload.get("user_id")}
67+
68+
69+
def list_users_tool(identity: IdentityContext, payload: dict) -> dict:
70+
"""Example tool that requires user:read permission.
71+
72+
Args:
73+
identity: Identity context from build_identity_from_token
74+
payload: Tool payload
75+
76+
Returns:
77+
Tool result
78+
79+
Raises:
80+
PermissionError: If permission is denied
81+
"""
82+
if not has_permission(identity["permissions"], "user:read"):
83+
msg = "Permission denied: user:read"
84+
raise PermissionDeniedError(msg)
85+
86+
# Tool implementation here
87+
return {"users": []}

0 commit comments

Comments
 (0)