From 36b109933cab1f3198bdc5f1ef6dcd750ba9870f Mon Sep 17 00:00:00 2001 From: jakeichikawasalesforce Date: Thu, 20 Aug 2026 13:09:43 -0700 Subject: [PATCH 1/3] Add opt-in OAuth endpoint scopes for query, evaluate, and deploy --- docs/server-config.md | 34 ++- tabpy/tabpy_server/app/app.py | 12 +- tabpy/tabpy_server/app/app_parameters.py | 2 + tabpy/tabpy_server/common/default.conf | 15 +- tabpy/tabpy_server/handlers/base_handler.py | 47 ++- tabpy/tabpy_server/handlers/jwt_auth.py | 52 ++++ tabpy/tabpy_server/handlers/util.py | 1 + tests/unit/server_tests/test_config.py | 25 ++ tests/unit/server_tests/test_jwt_auth.py | 74 ++++- tests/unit/server_tests/test_oauth_handler.py | 274 +++++++++++++++++- 10 files changed, 526 insertions(+), 10 deletions(-) diff --git a/docs/server-config.md b/docs/server-config.md index 27e870c9..69b5ca4e 100755 --- a/docs/server-config.md +++ b/docs/server-config.md @@ -73,7 +73,8 @@ at [`logging.config` documentation page](https://docs.python.org/3.6/library/log section. Default value - not set. - `TABPY_OAUTH_ENABLED`, `TABPY_OAUTH_ISSUER`, `TABPY_OAUTH_JWKS_URI`, `TABPY_OAUTH_AUDIENCE`, `TABPY_OAUTH_REQUIRED_SCOPES`, - `TABPY_OAUTH_LOG_USER` - configure OAuth/JWT Bearer token authentication. + `TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES`, `TABPY_OAUTH_LOG_USER` - configure + OAuth/JWT Bearer token authentication. See [OAuth / JWT Bearer Token Authentication](#oauth--jwt-bearer-token-authentication). - `TABPY_TRANSFER_PROTOCOL` - transfer protocol. Default value - `http`. If set to `https` two additional parameters have to be specified: @@ -310,13 +311,32 @@ service (e.g. a cloud metadata endpoint). Two additional parameters are optional: ```sh -TABPY_OAUTH_REQUIRED_SCOPES = tabpy:query,tabpy:evaluate +TABPY_OAUTH_REQUIRED_SCOPES = tabpy +TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true TABPY_OAUTH_LOG_USER = true ``` - `TABPY_OAUTH_REQUIRED_SCOPES` is a comma-separated list of scopes that - must all be present in the JWT's `scope` claim for the request to be - accepted. If unset, no scope check is performed. + must all be present in the JWT's `scope` claim on **every** request, + including `/info`. If unset, no global scope check is performed. A + missing global scope is rejected with HTTP 401 (Flight: + `UNAUTHENTICATED`). Do not put `tabpy:query`, `tabpy:evaluate`, or + `tabpy:deploy` here if you want them bound to specific paths; use + `TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES` for that. +- `TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES` (default `false`) requires + well-known scopes on specific HTTP paths after the JWT itself is valid: + `/query` needs `tabpy:query`, `/evaluate` needs `tabpy:evaluate`, and + mutating management operations need `tabpy:deploy` (`POST /endpoints`, + `PUT`/`DELETE /endpoints/{name}`, and + `GET /configurations/endpoint_upload_destination`). Insufficient + endpoint scope is rejected with HTTP 403 and + `WWW-Authenticate: Bearer error="insufficient_scope"`. `/info`, + `/status`, and `GET /endpoints` are not gated by those scopes. A + `SCRIPT_*` that calls `tabpy.query()` from `/evaluate` needs **both** + `tabpy:evaluate` and `tabpy:query`, because the nested `/query` call + forwards the original token. Arrow Flight is not per-endpoint scoped; + it still uses only `TABPY_OAUTH_REQUIRED_SCOPES`. Basic Auth is + unaffected. - `TABPY_OAUTH_LOG_USER` (default `false`) sets the JWT's `sub` claim as the authenticated user for logging purposes. The `sub` claim is often a user's email or SSO ID, so leave this disabled unless that's an @@ -325,6 +345,12 @@ TABPY_OAUTH_LOG_USER = true enabled -- that's what actually logs the authenticated user, for both basic auth and OAuth. +When OAuth is enabled, `/info` advertises `tabpy:query`, `tabpy:evaluate`, +and `tabpy:deploy` under +`versions.v1.features.authentication.methods.oauth-jwt` +so an IdP or Tableau connection can request those scopes even when +endpoint enforcement is off. + To authenticate a request, send the JWT as a Bearer token: ```sh diff --git a/tabpy/tabpy_server/app/app.py b/tabpy/tabpy_server/app/app.py index c7dbd084..58bd528a 100644 --- a/tabpy/tabpy_server/app/app.py +++ b/tabpy/tabpy_server/app/app.py @@ -23,6 +23,7 @@ from tabpy.tabpy_server.handlers.basic_auth_server_middleware_factory import ( BasicAuthServerMiddlewareFactory, ) +from tabpy.tabpy_server.handlers.jwt_auth import WELL_KNOWN_ENDPOINT_SCOPES from tabpy.tabpy_server.handlers.jwt_server_middleware_factory import ( JwtAuthServerMiddlewareFactory, ) @@ -397,6 +398,8 @@ def _parse_config(self, config_file): (SettingsParameters.OAuthAudience, ConfigParameters.TABPY_OAUTH_AUDIENCE, None, None), (SettingsParameters.OAuthRequiredScopes, ConfigParameters.TABPY_OAUTH_REQUIRED_SCOPES, None, None), + (SettingsParameters.OAuthEnforceEndpointScopes, + ConfigParameters.TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES, False, parser.getboolean), (SettingsParameters.OAuthLogUser, ConfigParameters.TABPY_OAUTH_LOG_USER, False, parser.getboolean), ] @@ -638,7 +641,14 @@ def _get_features(self): if ConfigParameters.TABPY_PWD_FILE in self.settings: methods["basic-auth"] = {} if self.settings[SettingsParameters.OAuthEnabled]: - methods["oauth-jwt"] = {} + methods["oauth-jwt"] = { + "scopes": list(WELL_KNOWN_ENDPOINT_SCOPES), + "endpoint_scopes_enforced": bool( + self.settings.get( + SettingsParameters.OAuthEnforceEndpointScopes, False + ) + ), + } features["authentication"] = { "required": True, "methods": methods, diff --git a/tabpy/tabpy_server/app/app_parameters.py b/tabpy/tabpy_server/app/app_parameters.py index 2c31c62b..a4ac41db 100644 --- a/tabpy/tabpy_server/app/app_parameters.py +++ b/tabpy/tabpy_server/app/app_parameters.py @@ -28,6 +28,7 @@ class ConfigParameters: TABPY_OAUTH_JWKS_URI = "TABPY_OAUTH_JWKS_URI" TABPY_OAUTH_AUDIENCE = "TABPY_OAUTH_AUDIENCE" TABPY_OAUTH_REQUIRED_SCOPES = "TABPY_OAUTH_REQUIRED_SCOPES" + TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES" TABPY_OAUTH_LOG_USER = "TABPY_OAUTH_LOG_USER" @@ -62,4 +63,5 @@ class SettingsParameters: OAuthJwksUri = "oauth_jwks_uri" OAuthAudience = "oauth_audience" OAuthRequiredScopes = "oauth_required_scopes" + OAuthEnforceEndpointScopes = "oauth_enforce_endpoint_scopes" OAuthLogUser = "oauth_log_user" diff --git a/tabpy/tabpy_server/common/default.conf b/tabpy/tabpy_server/common/default.conf index e01c732e..abd1c3da 100644 --- a/tabpy/tabpy_server/common/default.conf +++ b/tabpy/tabpy_server/common/default.conf @@ -53,8 +53,19 @@ # TABPY_OAUTH_AUDIENCE = tabpy # Comma-separated list of scopes that must all be present in the JWT's -# `scope` claim. Leave unset to skip scope enforcement. -# TABPY_OAUTH_REQUIRED_SCOPES = tabpy:query,tabpy:evaluate +# `scope` claim on every request (including /info). Leave unset to skip +# this global check. Do not put tabpy:query / tabpy:evaluate / +# tabpy:deploy here if you want them bound to specific paths; use +# TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES for that. +# TABPY_OAUTH_REQUIRED_SCOPES = tabpy + +# When true, /query requires tabpy:query, /evaluate requires +# tabpy:evaluate, and mutating /endpoints plus the upload-destination +# path require tabpy:deploy. Default false. Independent of +# TABPY_OAUTH_REQUIRED_SCOPES. GET /endpoints stays readable. A SCRIPT_* +# that calls tabpy.query() needs both query and evaluate. Arrow Flight is +# not per-endpoint scoped; it still uses TABPY_OAUTH_REQUIRED_SCOPES only. +# TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true # Log the JWT subject as the authenticated user for OAuth requests. # TABPY_OAUTH_LOG_USER = true diff --git a/tabpy/tabpy_server/handlers/base_handler.py b/tabpy/tabpy_server/handlers/base_handler.py index afdd5d54..dc4661ae 100644 --- a/tabpy/tabpy_server/handlers/base_handler.py +++ b/tabpy/tabpy_server/handlers/base_handler.py @@ -5,7 +5,12 @@ import logging import tornado.web from tabpy.tabpy_server.app.app_parameters import SettingsParameters -from tabpy.tabpy_server.handlers.jwt_auth import JwtValidationError, validate_jwt +from tabpy.tabpy_server.handlers.jwt_auth import ( + JwtValidationError, + endpoint_scope_for_path, + token_has_scope, + validate_jwt, +) from tabpy.tabpy_server.handlers.util import hash_password from tabpy.tabpy_server.handlers.util import AuthErrorStates import uuid @@ -139,9 +144,11 @@ def initialize(self, app): self.username = None self.password = None self.jwt_token = None + self.jwt_claims = None self.auth_method = None self.eval_timeout = self.settings[SettingsParameters.EvaluateTimeout] self.max_request_size = app.max_request_size + self.subdirectory = getattr(app, "subdirectory", "") or "" self.logger = ContextLoggerWrapper(self.request) self.logger.enable_context_logging( @@ -434,6 +441,8 @@ def _validate_jwt_credentials(self) -> bool: self.logger.log(logging.ERROR, str(ex)) return False + self.jwt_claims = claims + if self.settings.get(SettingsParameters.OAuthLogUser, False): subject = claims.get("sub") if subject: @@ -511,8 +520,31 @@ def handle_authentication(self, api_version): if not self._validate_credentials(method): return AuthErrorStates.NotAuthorized + if method == "oauth-jwt": + scope_error = self._endpoint_scope_error() + if scope_error is not None: + return scope_error + return AuthErrorStates.NONE + def _endpoint_scope_error(self): + """ + After a valid JWT, optionally require well-known endpoint scopes + on the matching HTTP path and method. Returns InsufficientScope or None. + """ + if not self.settings.get(SettingsParameters.OAuthEnforceEndpointScopes, False): + return None + if self.request.method == "OPTIONS": + return None + required = endpoint_scope_for_path( + self.request.path, self.subdirectory, self.request.method + ) + if not required: + return None + if token_has_scope(self.jwt_claims or {}, required): + return None + return AuthErrorStates.InsufficientScope + def should_fail_with_auth_error(self): """ Checks if authentication is required: @@ -558,6 +590,19 @@ def fail_with_auth_error(self): info="Unauthorized request.", log_message="Invalid credentials provided.", ) + elif self.auth_error == AuthErrorStates.InsufficientScope: + self.logger.log(logging.ERROR, "Failing with 403 for insufficient scope") + self.set_status(403) + self.set_header( + "WWW-Authenticate", + f'{scheme} realm="{self.tabpy_state.name}", ' + 'error="insufficient_scope"', + ) + self.error_out( + 403, + info="Forbidden request.", + log_message="Token is missing a required endpoint scope.", + ) else: self.logger.log(logging.ERROR, "Failing with 406 for Not Acceptable") self.set_status(406) diff --git a/tabpy/tabpy_server/handlers/jwt_auth.py b/tabpy/tabpy_server/handlers/jwt_auth.py index 7fea7023..e246dc12 100644 --- a/tabpy/tabpy_server/handlers/jwt_auth.py +++ b/tabpy/tabpy_server/handlers/jwt_auth.py @@ -354,3 +354,55 @@ def _check_scopes(claims: dict, required_scopes: str) -> None: ] if missing: raise JwtValidationError(f"JWT missing required scope(s): {', '.join(missing)}") + + +# Well-known endpoint scopes. Bound to HTTP paths when +# TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES is true. Not admin-defined. +SCOPE_QUERY = "tabpy:query" +SCOPE_EVALUATE = "tabpy:evaluate" +SCOPE_DEPLOY = "tabpy:deploy" +WELL_KNOWN_ENDPOINT_SCOPES = (SCOPE_QUERY, SCOPE_EVALUATE, SCOPE_DEPLOY) + +_MUTATING_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"}) + + +def token_has_scope(claims: dict, scope: str) -> bool: + """True if `scope` is present in the token's space-separated `scope` claim.""" + granted = claims.get("scope", "") + if not isinstance(granted, str): + return False + return scope in granted.split() + + +def endpoint_scope_for_path( + path: str, subdirectory: str = "", method: str = "GET" +) -> str | None: + """ + Return the well-known scope required for `path` + HTTP `method`, or None. + + `subdirectory` is TabPy's optional URL prefix (e.g. `/tabpy`). + OPTIONS is not mapped here; callers skip CORS preflight separately. + """ + rel = path or "/" + if subdirectory: + prefix = subdirectory if subdirectory.startswith("/") else f"/{subdirectory}" + prefix = prefix.rstrip("/") + if rel == prefix: + rel = "/" + elif rel.startswith(prefix + "/"): + rel = rel[len(prefix):] + verb = (method or "GET").upper() + if rel == "/evaluate" or rel.startswith("/evaluate/"): + return SCOPE_EVALUATE + if rel == "/query" or rel.startswith("/query/"): + return SCOPE_QUERY + if ( + rel == "/configurations/endpoint_upload_destination" + or rel.startswith("/configurations/endpoint_upload_destination/") + ): + return SCOPE_DEPLOY + if rel == "/endpoints" or rel.startswith("/endpoints/"): + if verb in _MUTATING_METHODS: + return SCOPE_DEPLOY + return None + return None diff --git a/tabpy/tabpy_server/handlers/util.py b/tabpy/tabpy_server/handlers/util.py index c9fc0e43..88785a4b 100644 --- a/tabpy/tabpy_server/handlers/util.py +++ b/tabpy/tabpy_server/handlers/util.py @@ -7,6 +7,7 @@ class AuthErrorStates(Enum): NONE = auto() NotAuthorized = auto() NotRequired = auto() + InsufficientScope = auto() def hash_password(username, pwd): """ diff --git a/tests/unit/server_tests/test_config.py b/tests/unit/server_tests/test_config.py index 823319ee..8d53498e 100644 --- a/tests/unit/server_tests/test_config.py +++ b/tests/unit/server_tests/test_config.py @@ -446,6 +446,31 @@ def test_oauth_enabled_with_all_required_settings_succeeds(self, mock_getaddrinf self.assertTrue(app.settings["oauth_enabled"]) methods = app._get_features()["authentication"]["methods"] self.assertIn("oauth-jwt", methods) + oauth = methods["oauth-jwt"] + self.assertEqual(oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"]) + self.assertFalse(oauth["endpoint_scopes_enforced"]) + self.assertFalse(app.settings["oauth_enforce_endpoint_scopes"]) + + @patch( + "tabpy.tabpy_server.app.app.socket.getaddrinfo", + return_value=PUBLIC_JWKS_ADDRINFO, + ) + def test_oauth_enforce_endpoint_scopes_can_be_enabled(self, mock_getaddrinfo): + self.fp.write( + "[TabPy]\n" + "TABPY_OAUTH_ENABLED = true\n" + "TABPY_OAUTH_ISSUER = https://idp.example.com/\n" + "TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json\n" + "TABPY_OAUTH_AUDIENCE = tabpy\n" + "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true\n" + ) + self.fp.close() + + app = TabPyApp(self.fp.name) + self.assertTrue(app.settings["oauth_enforce_endpoint_scopes"]) + oauth = app._get_features()["authentication"]["methods"]["oauth-jwt"] + self.assertTrue(oauth["endpoint_scopes_enforced"]) + self.assertEqual(oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"]) def test_oauth_enabled_missing_issuer_raises(self): self.fp.write( diff --git a/tests/unit/server_tests/test_jwt_auth.py b/tests/unit/server_tests/test_jwt_auth.py index 002662bf..2fe76942 100644 --- a/tests/unit/server_tests/test_jwt_auth.py +++ b/tests/unit/server_tests/test_jwt_auth.py @@ -12,7 +12,15 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from tabpy.tabpy_server.handlers.jwt_auth import JwtValidationError, validate_jwt +from tabpy.tabpy_server.handlers.jwt_auth import ( + SCOPE_DEPLOY, + SCOPE_EVALUATE, + SCOPE_QUERY, + JwtValidationError, + endpoint_scope_for_path, + token_has_scope, + validate_jwt, +) from tests.unit.server_tests.jwt_test_helpers import ( AUDIENCE, ISSUER, @@ -832,5 +840,69 @@ def test_validation_failure_does_not_expose_raw_token(self): self.assertNotIn(token, str(error.exception)) +class TestEndpointScopeHelpers(unittest.TestCase): + def test_token_has_scope_reads_space_separated_claim(self): + claims = {"scope": "tabpy:query tabpy:evaluate"} + self.assertTrue(token_has_scope(claims, SCOPE_QUERY)) + self.assertTrue(token_has_scope(claims, SCOPE_EVALUATE)) + self.assertFalse(token_has_scope(claims, "tabpy:deploy")) + + def test_token_has_scope_fails_closed_for_non_string_claim(self): + self.assertFalse( + token_has_scope({"scope": ["tabpy:query"]}, SCOPE_QUERY) + ) + + def test_endpoint_scope_for_path_maps_query_and_evaluate(self): + self.assertEqual(endpoint_scope_for_path("/query/add"), SCOPE_QUERY) + self.assertEqual( + endpoint_scope_for_path("/query/add", method="POST"), SCOPE_QUERY + ) + self.assertEqual(endpoint_scope_for_path("/evaluate"), SCOPE_EVALUATE) + self.assertIsNone(endpoint_scope_for_path("/info")) + self.assertIsNone(endpoint_scope_for_path("/status")) + self.assertIsNone(endpoint_scope_for_path("/endpoints")) + self.assertIsNone(endpoint_scope_for_path("/endpoints/add")) + + def test_endpoint_scope_for_path_maps_mutating_management_to_deploy(self): + self.assertEqual( + endpoint_scope_for_path("/endpoints", method="POST"), SCOPE_DEPLOY + ) + self.assertEqual( + endpoint_scope_for_path("/endpoints/add", method="PUT"), SCOPE_DEPLOY + ) + self.assertEqual( + endpoint_scope_for_path("/endpoints/add", method="DELETE"), SCOPE_DEPLOY + ) + self.assertEqual( + endpoint_scope_for_path( + "/configurations/endpoint_upload_destination", method="GET" + ), + SCOPE_DEPLOY, + ) + self.assertIsNone( + endpoint_scope_for_path("/endpoints/add", method="GET") + ) + + def test_endpoint_scope_for_path_honors_subdirectory(self): + self.assertEqual( + endpoint_scope_for_path("/tabpy/query/add", "/tabpy"), SCOPE_QUERY + ) + self.assertEqual( + endpoint_scope_for_path("/tabpy/evaluate", "/tabpy"), SCOPE_EVALUATE + ) + self.assertIsNone(endpoint_scope_for_path("/tabpy/info", "/tabpy")) + self.assertEqual( + endpoint_scope_for_path( + "/tabpy/endpoints/add", "/tabpy", method="DELETE" + ), + SCOPE_DEPLOY, + ) + self.assertIsNone( + endpoint_scope_for_path( + "/tabpy/endpoints/add", "/tabpy", method="GET" + ) + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/server_tests/test_oauth_handler.py b/tests/unit/server_tests/test_oauth_handler.py index 19145efe..b115cf40 100644 --- a/tests/unit/server_tests/test_oauth_handler.py +++ b/tests/unit/server_tests/test_oauth_handler.py @@ -157,7 +157,9 @@ def test_info_advertises_oauth_jwt_method(self): response = self.fetch("/info", headers=headers) body = json.loads(response.body) features = body["versions"]["v1"]["features"] - self.assertIn("oauth-jwt", features["authentication"]["methods"]) + oauth = features["authentication"]["methods"]["oauth-jwt"] + self.assertEqual(oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"]) + self.assertFalse(oauth["endpoint_scopes_enforced"]) def test_malformed_bearer_header_is_rejected_without_logging_the_token(self): """ @@ -304,5 +306,275 @@ def test_subject_is_not_logged_by_default(self): self.assertNotIn("should-not-be-logged", logged_text) +_EVALUATE_SCRIPT = ( + '{"data":{"_arg1":[2,3],"_arg2":[3,-1]},' + '"script":"res=[]\\nfor i in range(len(_arg1)):\\n ' + 'res.append(_arg1[i] * _arg2[i])\\nreturn res"}' +) + + +class TestEndpointScopesDefaultOff(BaseTestOAuthHandler): + @classmethod + def setUpClass(cls): + cls.prefix = "__TestEndpointScopesDefaultOff_" + cls.tabpy_config = [ + "TABPY_OAUTH_ENABLED = true\n", + f"TABPY_OAUTH_ISSUER = {ISSUER}\n", + f"TABPY_OAUTH_JWKS_URI = {JWKS_URI}\n", + f"TABPY_OAUTH_AUDIENCE = {AUDIENCE}\n", + ] + super().setUpClass() + + def test_query_without_scope_claim_is_not_forbidden(self): + token = self._make_token() + headers = {"Authorization": f"Bearer {token}"} + with self._patched_jwks_client(): + response = self.fetch("/query/missing", headers=headers) + self.assertNotEqual(response.code, 403) + self.assertNotEqual(response.code, 401) + + def test_evaluate_without_scope_claim_is_accepted(self): + token = self._make_token() + headers = {"Authorization": f"Bearer {token}"} + with self._patched_jwks_client(): + response = self.fetch( + "/evaluate", method="POST", body=_EVALUATE_SCRIPT, headers=headers + ) + self.assertEqual(response.code, 200) + + +class TestEndpointScopesEnforced(BaseTestOAuthHandler): + @classmethod + def setUpClass(cls): + cls.prefix = "__TestEndpointScopesEnforced_" + cls.tabpy_config = [ + "TABPY_OAUTH_ENABLED = true\n", + f"TABPY_OAUTH_ISSUER = {ISSUER}\n", + f"TABPY_OAUTH_JWKS_URI = {JWKS_URI}\n", + f"TABPY_OAUTH_AUDIENCE = {AUDIENCE}\n", + "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true\n", + ] + super().setUpClass() + + def _bearer(self, claims_override=None): + token = self._make_token(claims_override) + return {"Authorization": f"Bearer {token}"} + + def test_query_without_tabpy_query_returns_403(self): + headers = self._bearer({"scope": "tabpy:evaluate"}) + with self._patched_jwks_client(): + response = self.fetch( + "/query/missing", method="POST", body="{}", headers=headers + ) + self.assertEqual(response.code, 403) + self.assertIn( + 'error="insufficient_scope"', response.headers.get("WWW-Authenticate", "") + ) + + def test_query_with_tabpy_query_is_authorized(self): + headers = self._bearer({"scope": "tabpy:query"}) + with self._patched_jwks_client(): + response = self.fetch( + "/query/missing", method="POST", body="{}", headers=headers + ) + self.assertNotEqual(response.code, 401) + self.assertNotEqual(response.code, 403) + + def test_evaluate_without_tabpy_evaluate_returns_403(self): + headers = self._bearer({"scope": "tabpy:query"}) + with self._patched_jwks_client(): + response = self.fetch( + "/evaluate", method="POST", body=_EVALUATE_SCRIPT, headers=headers + ) + self.assertEqual(response.code, 403) + self.assertIn( + 'error="insufficient_scope"', response.headers.get("WWW-Authenticate", "") + ) + + def test_evaluate_with_tabpy_evaluate_is_accepted(self): + headers = self._bearer({"scope": "tabpy:evaluate"}) + with self._patched_jwks_client(): + response = self.fetch( + "/evaluate", method="POST", body=_EVALUATE_SCRIPT, headers=headers + ) + self.assertEqual(response.code, 200) + + def test_get_endpoints_is_not_gated(self): + headers = self._bearer() + with self._patched_jwks_client(): + info = self.fetch("/info", headers=headers) + status = self.fetch("/status", headers=headers) + endpoints = self.fetch("/endpoints", headers=headers) + self.assertEqual(info.code, 200) + self.assertEqual(status.code, 200) + self.assertEqual(endpoints.code, 200) + oauth = json.loads(info.body)["versions"]["v1"]["features"][ + "authentication" + ]["methods"]["oauth-jwt"] + self.assertTrue(oauth["endpoint_scopes_enforced"]) + self.assertEqual( + oauth["scopes"], ["tabpy:query", "tabpy:evaluate", "tabpy:deploy"] + ) + + def _assert_management_forbidden(self, headers, method, url, **kwargs): + with self._patched_jwks_client(): + response = self.fetch(url, method=method, headers=headers, **kwargs) + self.assertEqual(response.code, 403) + self.assertIn( + 'error="insufficient_scope"', response.headers.get("WWW-Authenticate", "") + ) + + def test_query_only_token_cannot_mutate_endpoints(self): + headers = self._bearer({"scope": "tabpy:query"}) + self._assert_management_forbidden( + headers, "POST", "/endpoints", body="{}" + ) + self._assert_management_forbidden( + headers, "PUT", "/endpoints/production-model", body="{}" + ) + self._assert_management_forbidden( + headers, + "DELETE", + "/endpoints/production-model", + allow_nonstandard_methods=True, + ) + self._assert_management_forbidden( + headers, "GET", "/configurations/endpoint_upload_destination" + ) + + def test_evaluate_only_token_cannot_mutate_endpoints(self): + headers = self._bearer({"scope": "tabpy:evaluate"}) + self._assert_management_forbidden( + headers, "POST", "/endpoints", body="{}" + ) + self._assert_management_forbidden( + headers, "DELETE", "/endpoints/production-model", + allow_nonstandard_methods=True, + ) + + def test_scopeless_token_cannot_mutate_endpoints(self): + headers = self._bearer() + self._assert_management_forbidden( + headers, "POST", "/endpoints", body="{}" + ) + self._assert_management_forbidden( + headers, "PUT", "/endpoints/production-model", body="{}" + ) + self._assert_management_forbidden( + headers, + "DELETE", + "/endpoints/production-model", + allow_nonstandard_methods=True, + ) + + def test_deploy_scope_is_authorized_for_management(self): + headers = self._bearer({"scope": "tabpy:deploy"}) + with self._patched_jwks_client(): + upload = self.fetch( + "/configurations/endpoint_upload_destination", headers=headers + ) + create = self.fetch("/endpoints", method="POST", body="{}", headers=headers) + delete = self.fetch( + "/endpoints/production-model", + method="DELETE", + headers=headers, + allow_nonstandard_methods=True, + ) + self.assertEqual(upload.code, 200) + self.assertNotEqual(create.code, 401) + self.assertNotEqual(create.code, 403) + self.assertNotEqual(delete.code, 401) + self.assertNotEqual(delete.code, 403) + + def test_evaluate_only_token_fails_inner_query(self): + """RestrictedTabPy forwards the original JWT to nested /query.""" + headers = self._bearer({"scope": "tabpy:evaluate"}) + with self._patched_jwks_client(): + response = self.fetch( + "/query/missing", method="POST", body="{}", headers=headers + ) + self.assertEqual(response.code, 403) + + def test_query_options_does_not_require_endpoint_scope(self): + headers = self._bearer({"scope": "tabpy:evaluate"}) + with self._patched_jwks_client(): + response = self.fetch( + "/query/missing", + method="OPTIONS", + headers=headers, + allow_nonstandard_methods=True, + ) + self.assertNotEqual(response.code, 403) + + +class TestEndpointScopesWithGlobalRequired(BaseTestOAuthHandler): + @classmethod + def setUpClass(cls): + cls.prefix = "__TestEndpointScopesWithGlobalRequired_" + cls.tabpy_config = [ + "TABPY_OAUTH_ENABLED = true\n", + f"TABPY_OAUTH_ISSUER = {ISSUER}\n", + f"TABPY_OAUTH_JWKS_URI = {JWKS_URI}\n", + f"TABPY_OAUTH_AUDIENCE = {AUDIENCE}\n", + "TABPY_OAUTH_REQUIRED_SCOPES = tabpy\n", + "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true\n", + ] + super().setUpClass() + + def test_missing_global_scope_is_401_on_info(self): + token = self._make_token({"scope": "tabpy:query"}) + headers = {"Authorization": f"Bearer {token}"} + with self._patched_jwks_client(): + response = self.fetch("/info", headers=headers) + self.assertEqual(response.code, 401) + + def test_global_scope_without_query_scope_forbids_query_only(self): + token = self._make_token({"scope": "tabpy"}) + headers = {"Authorization": f"Bearer {token}"} + with self._patched_jwks_client(): + info = self.fetch("/info", headers=headers) + query = self.fetch( + "/query/missing", method="POST", body="{}", headers=headers + ) + self.assertEqual(info.code, 200) + self.assertEqual(query.code, 403) + + +class TestEndpointScopesBasicAuthUnaffected(BaseTestOAuthHandler): + @classmethod + def setUpClass(cls): + cls.prefix = "__TestEndpointScopesBasicAuthUnaffected_" + cls.tabpy_config = [ + "TABPY_PWD_FILE = ./tests/integration/resources/pwdfile.txt\n", + "TABPY_OAUTH_ENABLED = true\n", + f"TABPY_OAUTH_ISSUER = {ISSUER}\n", + f"TABPY_OAUTH_JWKS_URI = {JWKS_URI}\n", + f"TABPY_OAUTH_AUDIENCE = {AUDIENCE}\n", + "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true\n", + ] + super().setUpClass() + + def test_basic_auth_evaluate_still_works(self): + headers = { + "Authorization": "Basic " + + base64.b64encode(b"user1:P@ssw0rd").decode("utf-8"), + } + response = self.fetch( + "/evaluate", method="POST", body=_EVALUATE_SCRIPT, headers=headers + ) + self.assertEqual(response.code, 200) + + def test_basic_auth_query_is_not_forbidden(self): + headers = { + "Authorization": "Basic " + + base64.b64encode(b"user1:P@ssw0rd").decode("utf-8"), + } + response = self.fetch( + "/query/missing", method="POST", body="{}", headers=headers + ) + self.assertNotEqual(response.code, 403) + self.assertNotEqual(response.code, 401) + + if __name__ == "__main__": unittest.main() From a755ecf8058a519285e6a4bc2746d6ce5a717839 Mon Sep 17 00:00:00 2001 From: jakeichikawasalesforce Date: Thu, 20 Aug 2026 13:09:49 -0700 Subject: [PATCH 2/3] Bump TabPy version to 2.15.0 --- CHANGELOG | 14 ++++++++++++++ tabpy/VERSION | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 8899b878..78bd3833 100755 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,19 @@ # Changelog +## v2.15.0 + +### Improvements + +- Add optional OAuth 2.0 / JWT Bearer token authentication on HTTP and + Arrow Flight. Configure it with `TABPY_OAUTH_ENABLED` plus issuer, JWKS + URI, and audience. Basic Auth is unchanged and can run alongside OAuth. +- Add optional global JWT scope checks (`TABPY_OAUTH_REQUIRED_SCOPES`) and + opt-in per-endpoint scopes (`TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES`) for + `tabpy:query` on `/query`, `tabpy:evaluate` on `/evaluate`, and + `tabpy:deploy` on mutating `/endpoints` operations. +- Add optional per-user logging of the JWT `sub` claim when + `TABPY_OAUTH_LOG_USER` is enabled (requires `TABPY_LOG_DETAILS`). + ## v2.14.0 ### Improvements diff --git a/tabpy/VERSION b/tabpy/VERSION index edcfe40d..68e69e40 100755 --- a/tabpy/VERSION +++ b/tabpy/VERSION @@ -1 +1 @@ -2.14.0 +2.15.0 From 9bc194c108bf36926a351fdc51bea94befa78ff5 Mon Sep 17 00:00:00 2001 From: jakeichikawasalesforce Date: Fri, 21 Aug 2026 08:16:00 -0700 Subject: [PATCH 3/3] Update server-config docs TOC. --- docs/server-config.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/server-config.md b/docs/server-config.md index 69b5ca4e..345939a3 100755 --- a/docs/server-config.md +++ b/docs/server-config.md @@ -14,6 +14,9 @@ * [Adding an Account](#adding-an-account) * [Updating an Account](#updating-an-account) * [Deleting an Account](#deleting-an-account) + * [OAuth / JWT Bearer Token Authentication](#oauth--jwt-bearer-token-authentication) + * [Endpoint Security](#endpoint-security) +- [Arrow Flight](#arrow-flight) - [Logging](#logging) * [Request Context Logging](#request-context-logging)