From 5906b81d998815ccb34224b2bb47b188239990fa Mon Sep 17 00:00:00 2001 From: farhoud Date: Thu, 18 Dec 2025 13:07:19 +0330 Subject: [PATCH 1/2] feat: add PyJWT dependency for OAuth token verification --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0ab3e42..b7ac938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "neo4j-graphrag[openai,sentence-transformers]", "pydantic", "pydantic-settings", + "PyJWT", ] [project.optional-dependencies] From 75aa2a10a6e2988cc61b7a3ddb83ba2201a70be1 Mon Sep 17 00:00:00 2001 From: farhoud Date: Thu, 18 Dec 2025 13:08:22 +0330 Subject: [PATCH 2/2] feat: implement multi-tenant RBAC authorization system - Add IdentityContext for request-scoped user data - Implement pure RBAC permission checking functions - Add FastAPI middleware for OAuth token verification - Add FastAPI dependencies for permission enforcement - Provide FastMCP integration examples - Define authorization-specific exceptions - Add comprehensive unit tests refactor: reorganize database module structure - Move authorization queries to dedicated db/auth.py module - Rename persistence.py to agent_runs.py for clarity - Update imports to reflect new module organization - Separate concerns: connections vs business logic vs auth chore: update test imports and lock file - Fix test imports to use new db/auth.py module - Update uv.lock with new PyJWT dependency --- src/scouter/auth/__init__.py | 51 ++++++ src/scouter/auth/dependencies.py | 99 ++++++++++++ src/scouter/auth/exceptions.py | 25 +++ src/scouter/auth/fastmcp.py | 87 ++++++++++ src/scouter/auth/middleware.py | 126 +++++++++++++++ src/scouter/auth/rbac.py | 54 +++++++ src/scouter/auth/types.py | 14 ++ src/scouter/db/__init__.py | 6 +- .../db/{persistence.py => agent_runs.py} | 0 src/scouter/db/auth.py | 153 ++++++++++++++++++ tests/test_auth.py | 89 ++++++++++ uv.lock | 2 + 12 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 src/scouter/auth/__init__.py create mode 100644 src/scouter/auth/dependencies.py create mode 100644 src/scouter/auth/exceptions.py create mode 100644 src/scouter/auth/fastmcp.py create mode 100644 src/scouter/auth/middleware.py create mode 100644 src/scouter/auth/rbac.py create mode 100644 src/scouter/auth/types.py rename src/scouter/db/{persistence.py => agent_runs.py} (100%) create mode 100644 src/scouter/db/auth.py create mode 100644 tests/test_auth.py diff --git a/src/scouter/auth/__init__.py b/src/scouter/auth/__init__.py new file mode 100644 index 0000000..da02705 --- /dev/null +++ b/src/scouter/auth/__init__.py @@ -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", +] diff --git a/src/scouter/auth/dependencies.py b/src/scouter/auth/dependencies.py new file mode 100644 index 0000000..b239839 --- /dev/null +++ b/src/scouter/auth/dependencies.py @@ -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 diff --git a/src/scouter/auth/exceptions.py b/src/scouter/auth/exceptions.py new file mode 100644 index 0000000..093747a --- /dev/null +++ b/src/scouter/auth/exceptions.py @@ -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.""" diff --git a/src/scouter/auth/fastmcp.py b/src/scouter/auth/fastmcp.py new file mode 100644 index 0000000..bed2142 --- /dev/null +++ b/src/scouter/auth/fastmcp.py @@ -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": []} diff --git a/src/scouter/auth/middleware.py b/src/scouter/auth/middleware.py new file mode 100644 index 0000000..499c371 --- /dev/null +++ b/src/scouter/auth/middleware.py @@ -0,0 +1,126 @@ +"""FastAPI middleware for identity resolution and authorization.""" + +import jwt +from fastapi import Request, Response +from fastapi.responses import JSONResponse + +from scouter.db.auth import build_identity_context, resolve_user_from_oauth +from scouter.db.neo4j import get_neo4j_driver + + +class AuthorizationMiddleware: + """Middleware for building identity context from OAuth tokens.""" + + def __init__(self, jwks_url: str, issuer: str, audience: str): + """Initialize middleware with OAuth configuration. + + Args: + jwks_url: URL to fetch JWKS for token verification + issuer: Expected token issuer + audience: Expected token audience + """ + self.jwks_url = jwks_url + self.issuer = issuer + self.audience = audience + self.jwks_client = jwt.PyJWKClient(jwks_url) + + async def __call__(self, request: Request, call_next) -> Response: + """Process request and build identity context.""" + # Extract bearer token + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return JSONResponse( + status_code=401, + content={"detail": "Missing or invalid authorization header"}, + ) + + token = auth_header[7:] # Remove "Bearer " + + try: + # Verify token + signing_key = self.jwks_client.get_signing_key_from_jwt(token) + payload = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + issuer=self.issuer, + audience=self.audience, + ) + + # Extract user identity + provider = payload.get("iss") # Assuming issuer indicates provider + sub = payload.get("sub") + if not provider or not sub: + msg = "Missing provider or sub in token" + raise ValueError(msg) + + # Resolve user from OAuth identity + 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 ValueError(msg) + + # Resolve tenant + tenant_id = self._resolve_tenant_id(request, payload) + if not tenant_id: + return JSONResponse( + status_code=400, + content={"detail": "Tenant not specified or invalid"}, + ) + + # Build identity context + identity = build_identity_context(driver, user_id, tenant_id, payload) + + # Attach to request state + request.state.identity = identity + + except jwt.ExpiredSignatureError: + return JSONResponse( + status_code=401, + content={"detail": "Token has expired"}, + ) + except jwt.InvalidTokenError as e: + return JSONResponse( + status_code=401, + content={"detail": f"Invalid token: {e!s}"}, + ) + except ValueError as e: + return JSONResponse( + status_code=403, + content={"detail": str(e)}, + ) + except Exception as e: + return JSONResponse( + status_code=500, + content={"detail": f"Authorization error: {e!s}"}, + ) + + # Continue with request + return await call_next(request) + + def _resolve_tenant_id(self, request: Request, payload: dict) -> str | None: + """Resolve tenant ID from request or token. + + Priority: + 1. JWT claim (tenant_id) + 2. Request header (X-Tenant-ID) + + Args: + request: FastAPI request object + payload: Decoded JWT payload + + Returns: + Tenant ID if found, None otherwise + """ + # Check JWT claim first + tenant_id = payload.get("tenant_id") + if tenant_id: + return tenant_id + + # Check header + tenant_id = request.headers.get("X-Tenant-ID") + if tenant_id: + return tenant_id + + return None diff --git a/src/scouter/auth/rbac.py b/src/scouter/auth/rbac.py new file mode 100644 index 0000000..2b508cd --- /dev/null +++ b/src/scouter/auth/rbac.py @@ -0,0 +1,54 @@ +"""Pure RBAC authorization functions.""" + +import fnmatch + + +def has_permission(permissions: set[str], required: str) -> bool: + """Check if the given permissions set grants the required permission. + + Rules: + - '*' grants everything + - 'resource:*' grants all actions on resource + - Exact match grants permission + + Args: + permissions: Set of permission strings + required: Required permission string + + Returns: + True if permission is granted, False otherwise + """ + if "*" in permissions: + return True + + if required in permissions: + return True + + # Check wildcard patterns + return any(fnmatch.fnmatch(required, perm) for perm in permissions) + + +def has_any_permission(permissions: set[str], required: set[str]) -> bool: + """Check if any of the required permissions are granted. + + Args: + permissions: Set of permission strings + required: Set of required permission strings + + Returns: + True if at least one permission is granted, False otherwise + """ + return any(has_permission(permissions, req) for req in required) + + +def has_all_permissions(permissions: set[str], required: set[str]) -> bool: + """Check if all required permissions are granted. + + Args: + permissions: Set of permission strings + required: Set of required permission strings + + Returns: + True if all permissions are granted, False otherwise + """ + return all(has_permission(permissions, req) for req in required) diff --git a/src/scouter/auth/types.py b/src/scouter/auth/types.py new file mode 100644 index 0000000..c1ffb6b --- /dev/null +++ b/src/scouter/auth/types.py @@ -0,0 +1,14 @@ +"""Authorization types and data structures.""" + +from typing import Any + +IdentityContext = dict[str, Any] +"""Request-scoped identity context containing user and authorization information. + +Keys: + user_id: str - Unique user identifier + tenant_id: str - Tenant identifier for multi-tenancy + roles: set[str] - Set of role names assigned to the user in the tenant + permissions: set[str] - Set of permission keys granted to the user + token_claims: dict - Raw JWT token claims for additional context +""" diff --git a/src/scouter/db/__init__.py b/src/scouter/db/__init__.py index 481acd0..38be4df 100644 --- a/src/scouter/db/__init__.py +++ b/src/scouter/db/__init__.py @@ -4,13 +4,13 @@ primarily focused on Neo4j for graph-based storage and retrieval. """ -from .models import AgentRunRepository -from .neo4j import get_neo4j_driver, get_neo4j_embedder, get_neo4j_llm -from .persistence import ( +from .agent_runs import ( load_agent_run_from_neo4j, neo4j_persistence, neo4j_trace_function, ) +from .models import AgentRunRepository +from .neo4j import get_neo4j_driver, get_neo4j_embedder, get_neo4j_llm __all__ = [ "AgentRunRepository", diff --git a/src/scouter/db/persistence.py b/src/scouter/db/agent_runs.py similarity index 100% rename from src/scouter/db/persistence.py rename to src/scouter/db/agent_runs.py diff --git a/src/scouter/db/auth.py b/src/scouter/db/auth.py new file mode 100644 index 0000000..e69babe --- /dev/null +++ b/src/scouter/db/auth.py @@ -0,0 +1,153 @@ +"""Neo4j operations for authorization and RBAC.""" + +from typing import Any + +import neo4j + + +def create_rbac_constraints(driver: neo4j.Driver) -> None: + """Create Neo4j constraints and indexes for RBAC model. + + This should be run once during database setup. + + Args: + driver: Neo4j driver instance + """ + constraints = [ + "CREATE CONSTRAINT tenant_id_unique IF NOT EXISTS FOR (t:Tenant) REQUIRE t.id IS UNIQUE", + "CREATE CONSTRAINT user_id_unique IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE", + "CREATE CONSTRAINT oauth_identity_unique IF NOT EXISTS FOR (oi:OAuthIdentity) REQUIRE (oi.provider, oi.sub) IS UNIQUE", + "CREATE CONSTRAINT role_id_unique IF NOT EXISTS FOR (r:Role) REQUIRE r.id IS UNIQUE", + "CREATE CONSTRAINT permission_key_unique IF NOT EXISTS FOR (p:Permission) REQUIRE p.key IS UNIQUE", + ] + + with driver.session() as session: + for constraint in constraints: + session.run(constraint) + + +def get_user_permissions( + driver: neo4j.Driver, user_id: str, tenant_id: str +) -> set[str]: + """Get all permissions for a user within a specific tenant. + + Args: + driver: Neo4j driver instance + user_id: User identifier + tenant_id: Tenant identifier + + Returns: + Set of permission keys granted to the user in the tenant + """ + query = """ + MATCH (u:User {id: $user_id})-[:MEMBER_OF]->(t:Tenant {id: $tenant_id}) + MATCH (u)-[:HAS_ROLE]->(r:Role)-[:ROLE_IN]->(t) + MATCH (r)-[:GRANTS]->(p:Permission) + RETURN DISTINCT p.key as permission + """ + with driver.session() as session: + result = session.run(query, user_id=user_id, tenant_id=tenant_id) + return {record["permission"] for record in result} + + +def get_user_roles(driver: neo4j.Driver, user_id: str, tenant_id: str) -> set[str]: + """Get all roles for a user within a specific tenant. + + Args: + driver: Neo4j driver instance + user_id: User identifier + tenant_id: Tenant identifier + + Returns: + Set of role names assigned to the user in the tenant + """ + query = """ + MATCH (u:User {id: $user_id})-[:MEMBER_OF]->(t:Tenant {id: $tenant_id}) + MATCH (u)-[:HAS_ROLE]->(r:Role)-[:ROLE_IN]->(t) + RETURN DISTINCT r.name as role + """ + with driver.session() as session: + result = session.run(query, user_id=user_id, tenant_id=tenant_id) + return {record["role"] for record in result} + + +def resolve_user_from_oauth( + driver: neo4j.Driver, provider: str, sub: str +) -> str | None: + """Resolve user ID from OAuth identity. + + Args: + driver: Neo4j driver instance + provider: OAuth provider name + sub: OAuth subject identifier + + Returns: + User ID if found, None otherwise + """ + query = """ + MATCH (oi:OAuthIdentity {provider: $provider, sub: $sub})-[:IDENTIFIES]->(u:User) + RETURN u.id as user_id + """ + with driver.session() as session: + result = session.run(query, provider=provider, sub=sub) + record = result.single() + return record["user_id"] if record else None + + +def verify_tenant_membership( + driver: neo4j.Driver, user_id: str, tenant_id: str +) -> bool: + """Verify that a user is a member of a tenant. + + Args: + driver: Neo4j driver instance + user_id: User identifier + tenant_id: Tenant identifier + + Returns: + True if user is member of tenant, False otherwise + """ + query = """ + MATCH (u:User {id: $user_id})-[:MEMBER_OF]->(t:Tenant {id: $tenant_id}) + RETURN count(t) > 0 as is_member + """ + with driver.session() as session: + result = session.run(query, user_id=user_id, tenant_id=tenant_id) + record = result.single() + return record["is_member"] if record else False + + +def build_identity_context( + driver: neo4j.Driver, + user_id: str, + tenant_id: str, + token_claims: dict[str, Any], +) -> dict[str, Any]: + """Build complete identity context for a user and tenant. + + Args: + driver: Neo4j driver instance + user_id: User identifier + tenant_id: Tenant identifier + token_claims: Raw JWT token claims + + Returns: + Complete IdentityContext dict + + Raises: + ValueError: If user is not a member of the tenant + """ + if not verify_tenant_membership(driver, user_id, tenant_id): + msg = f"User {user_id} is not a member of tenant {tenant_id}" + raise ValueError(msg) + + roles = get_user_roles(driver, user_id, tenant_id) + permissions = get_user_permissions(driver, user_id, tenant_id) + + return { + "user_id": user_id, + "tenant_id": tenant_id, + "roles": roles, + "permissions": permissions, + "token_claims": token_claims, + } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..66430a6 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,89 @@ +"""Tests for RBAC authorization system.""" + +from unittest.mock import Mock + +import pytest + +from scouter.auth.rbac import has_all_permissions, has_any_permission, has_permission + + +class TestRBAC: + """Test RBAC permission checking functions.""" + + def test_has_permission_exact_match(self): + """Test exact permission match.""" + permissions = {"user:read", "user:write"} + assert has_permission(permissions, "user:read") is True + assert has_permission(permissions, "user:delete") is False + + def test_has_permission_wildcard_all(self): + """Test wildcard '*' grants everything.""" + permissions = {"*"} + assert has_permission(permissions, "user:read") is True + assert has_permission(permissions, "billing:invoice:create") is True + + def test_has_permission_resource_wildcard(self): + """Test resource:* grants all actions on resource.""" + permissions = {"user:*", "billing:read"} + assert has_permission(permissions, "user:read") is True + assert has_permission(permissions, "user:write") is True + assert has_permission(permissions, "billing:read") is True + assert has_permission(permissions, "billing:write") is False + + def test_has_any_permission(self): + """Test has_any_permission function.""" + permissions = {"user:read", "billing:write"} + assert has_any_permission(permissions, {"user:read", "user:write"}) is True + assert has_any_permission(permissions, {"user:write", "billing:read"}) is False + + def test_has_all_permissions(self): + """Test has_all_permissions function.""" + permissions = {"user:read", "user:write", "billing:read"} + assert has_all_permissions(permissions, {"user:read", "billing:read"}) is True + assert has_all_permissions(permissions, {"user:read", "billing:write"}) is False + + +class TestNeo4jQueries: + """Test Neo4j query functions.""" + + @pytest.fixture + def mock_driver(self): + """Mock Neo4j driver.""" + return Mock() + + @pytest.fixture + def mock_session(self, mock_driver): + """Mock Neo4j session.""" + session = Mock() + mock_driver.session.return_value.__enter__.return_value = session + return session + + def test_get_user_permissions(self, mock_driver, mock_session): + """Test getting user permissions.""" + from scouter.db.auth import get_user_permissions + + mock_session.run.return_value = [ + Mock(data=lambda: {"permission": "user:read"}), + Mock(data=lambda: {"permission": "user:write"}), + ] + + result = get_user_permissions(mock_driver, "user1", "tenant1") + assert result == {"user:read", "user:write"} + + def test_resolve_user_from_oauth(self, mock_driver, mock_session): + """Test resolving user from OAuth identity.""" + from scouter.db.auth import resolve_user_from_oauth + + mock_session.run.return_value.single.return_value = {"user_id": "user1"} + + result = resolve_user_from_oauth(mock_driver, "auth0", "sub123") + assert result == "user1" + + def test_verify_tenant_membership(self, mock_driver, mock_session): + """Test verifying tenant membership.""" + from scouter.db.auth import verify_tenant_membership + + mock_session.run.return_value.single.return_value = {"is_member": True} + + result = verify_tenant_membership(mock_driver, "user1", "tenant1") + assert result is True diff --git a/uv.lock b/uv.lock index 0d3b235..3102ec0 100644 --- a/uv.lock +++ b/uv.lock @@ -3839,6 +3839,7 @@ dependencies = [ { name = "neo4j-graphrag", extra = ["openai", "sentence-transformers"] }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "uvicorn" }, ] @@ -3873,6 +3874,7 @@ requires-dist = [ { name = "pdfplumber", marker = "extra == 'dev'" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-watcher", marker = "extra == 'dev'" }, { name = "requests", marker = "extra == 'dev'" },