Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 33 additions & 4 deletions docs/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -73,7 +76,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:
Expand Down Expand Up @@ -310,13 +314,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
Expand All @@ -325,6 +348,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
Expand Down
2 changes: 1 addition & 1 deletion tabpy/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.14.0
2.15.0
12 changes: 11 additions & 1 deletion tabpy/tabpy_server/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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),
]

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tabpy/tabpy_server/app/app_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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"
15 changes: 13 additions & 2 deletions tabpy/tabpy_server/common/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion tabpy/tabpy_server/handlers/base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions tabpy/tabpy_server/handlers/jwt_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions tabpy/tabpy_server/handlers/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class AuthErrorStates(Enum):
NONE = auto()
NotAuthorized = auto()
NotRequired = auto()
InsufficientScope = auto()

def hash_password(username, pwd):
"""
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/server_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading