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
15 changes: 15 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## v2.16.0

### Improvements

- Accept OAuth access tokens without an `aud` claim when required scopes
provide the global resource authorization boundary. Audience validation
remains enforced when `TABPY_OAUTH_AUDIENCE` is configured. Endpoint scopes
provide optional, additional authorization for specific HTTP operations.
- Add a default-off `TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS` override for trusted
JWKS endpoints routed through internal networks, split DNS, or enterprise
proxies. HTTPS and certificate validation remain required.
- Improve OAuth discovery on `/info` and the landing page by separating global
`required_scopes` from `endpoint_scopes`, showing endpoint enforcement state,
and presenting read-only boolean values as status badges.

## v2.15.1

### Improvements
Expand Down
72 changes: 54 additions & 18 deletions docs/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ at [`logging.config` documentation page](https://docs.python.org/3.6/library/log
authentication can be found in [Authentication](#authentication)
section. Default value - not set.
- `TABPY_OAUTH_ENABLED`, `TABPY_OAUTH_ISSUER`, `TABPY_OAUTH_JWKS_URI`,
`TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS`,
`TABPY_OAUTH_AUDIENCE`, `TABPY_OAUTH_REQUIRED_SCOPES`,
`TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES`, `TABPY_OAUTH_QUERY_SCOPE`,
`TABPY_OAUTH_EVALUATE_SCOPE`, `TABPY_OAUTH_DEPLOY_SCOPE`,
Expand Down Expand Up @@ -293,28 +294,44 @@ file:
TABPY_OAUTH_ENABLED = true
TABPY_OAUTH_ISSUER = https://idp.example.com/
TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json
TABPY_OAUTH_AUDIENCE = tabpy
TABPY_OAUTH_AUDIENCE = api://tabpy
```

`TABPY_OAUTH_ISSUER`, `TABPY_OAUTH_JWKS_URI`, and `TABPY_OAUTH_AUDIENCE` are
all required when `TABPY_OAUTH_ENABLED` is `true`; TabPy will fail to start
if any are missing. `TABPY_OAUTH_ISSUER` and `TABPY_OAUTH_JWKS_URI` must use
`TABPY_OAUTH_ISSUER` and `TABPY_OAUTH_JWKS_URI` are required when
`TABPY_OAUTH_ENABLED` is `true`. You must also configure a global resource
authorization boundary: `TABPY_OAUTH_AUDIENCE` or
`TABPY_OAUTH_REQUIRED_SCOPES`. Endpoint scope enforcement provides additional
authorization for specific HTTP operations and is not sufficient by itself.
Audience validation is the preferred interoperable configuration when the
authorization server includes an `aud` claim. TabPy fails to start if no
boundary is configured. The issuer and JWKS URI must use
`https://` -- the JWKS response is the trust anchor for verifying JWT
signatures, so fetching it over plain HTTP would let anyone on the network
path substitute their own keys. `TABPY_OAUTH_JWKS_URI` is also resolved at
startup, and TabPy will fail to start if it resolves to a private,
loopback, or link-local address, since that endpoint is fetched over the
network on TabPy's behalf and could otherwise be pointed at an internal
service (e.g. a cloud metadata endpoint).
startup, and TabPy will fail to start if it resolves to a non-public address,
including private, loopback, link-local, or shared CGNAT space. The endpoint
is fetched over the network on TabPy's behalf and could otherwise be pointed
at an internal service (e.g. a cloud metadata endpoint). For trusted
deployments using split DNS, an internal IdP, or an enterprise proxy, the
default-off `TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS` override permits the configured JWKS
hostname to resolve to a non-public address. HTTPS remains required.

- `TABPY_OAUTH_ISSUER` is the expected `iss` claim on incoming JWTs.
- `TABPY_OAUTH_JWKS_URI` is the IdP's JWKS endpoint, used to fetch and cache
the signing keys used to verify JWT signatures.
- `TABPY_OAUTH_AUDIENCE` is the expected `aud` claim on incoming JWTs.
- `TABPY_OAUTH_AUDIENCE` is an optional expected `aud` claim. When configured,
tokens with a missing or different audience are rejected. Prefer this mode
when the IdP supports a configurable API audience. For example, configure
the authorization server with audience `api://tabpy`, then use that exact
value here. When unset, audience validation is disabled so access tokens
without `aud` can authenticate; required scopes must then provide the global
resource authorization boundary.

Six additional parameters are optional:
Eight authorization, networking, and logging parameters are optional:

```sh
TABPY_OAUTH_AUDIENCE = api://tabpy
TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = true
TABPY_OAUTH_REQUIRED_SCOPES = tabpy
TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = true
TABPY_OAUTH_QUERY_SCOPE = tabpy:query
Expand All @@ -323,6 +340,14 @@ TABPY_OAUTH_DEPLOY_SCOPE = tabpy:deploy
TABPY_OAUTH_LOG_USER = true
```

- `TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS` (default `false`) permits only the
explicitly configured `TABPY_OAUTH_JWKS_URI` hostname to resolve to a
non-public IP address. Use it only when that exact HTTPS endpoint is trusted
and intentionally routed through a controlled internal network, split-DNS
setup, or enterprise proxy. TabPy logs a warning whenever the override is
exercised. It does not permit plain HTTP or disable TLS certificate and
hostname validation.

- `TABPY_OAUTH_REQUIRED_SCOPES` is a comma-separated list of scopes that
must all be present in the JWT's `scope` claim on **every** request,
including `/info`. If unset, no global scope check is performed. A
Expand All @@ -335,12 +360,17 @@ TABPY_OAUTH_LOG_USER = true
Cognito custom scopes use
`<resource-server-identifier>/<scope-name>`. A Cognito resource server
named `tabpy` with `access` and `finance` scopes could restrict a finance
team's TabPy deployment with:
team's TabPy deployment when its access tokens do not contain `aud` with:

```sh
TABPY_OAUTH_REQUIRED_SCOPES = tabpy/access,tabpy/finance
```

Configure the same resource-server scope values in Tableau's OAuth
configuration **Scopes** field. Tableau Desktop then requests them during
the authorization-code plus PKCE flow and sends the resulting access token
to TabPy; no ID-token fallback is needed.

A token whose `scope` claim is `openid tabpy/access tabpy/finance` would
pass the global scope check, while one containing
`openid tabpy/access tabpy/marketing` would be rejected. When multiple
Expand All @@ -354,11 +384,14 @@ TABPY_OAUTH_LOG_USER = true
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
configured audience or global required scope is therefore always required;
endpoint scopes provide additional fine-grained authorization and never act
as the resource boundary by themselves. 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.
it still uses only `TABPY_OAUTH_AUDIENCE` and
`TABPY_OAUTH_REQUIRED_SCOPES`. Basic Auth is unaffected.
- `TABPY_OAUTH_QUERY_SCOPE`, `TABPY_OAUTH_EVALUATE_SCOPE`, and
`TABPY_OAUTH_DEPLOY_SCOPE` configure the exact scope names used by
endpoint enforcement and advertised by `/info`. Their defaults are
Expand All @@ -384,11 +417,14 @@ 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 the configured endpoint scopes
(by default, `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.
When OAuth is enabled, `/info` advertises global required scopes under
`versions.v1.features.authentication.methods.oauth-jwt.required_scopes` and
the configured endpoint scopes (by default, `tabpy:query`, `tabpy:evaluate`,
and `tabpy:deploy`) under the sibling `endpoint_scopes` field. The landing page
displays global `required_scopes` first, followed by `endpoint_scopes` and its
adjacent `endpoint_scopes_enforced` setting. The latter controls whether those
endpoint-specific scopes are enforced per route; the scopes remain advertised
when enforcement is off so an IdP or Tableau connection can still request them.

To authenticate a request, send the JWT as a Bearer token:

Expand Down
2 changes: 1 addition & 1 deletion tabpy/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.15.1
2.16.0
74 changes: 59 additions & 15 deletions tabpy/tabpy_server/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def _get_arrow_server(self, config):
"jwt": JwtAuthServerMiddlewareFactory(
issuer=config[SettingsParameters.OAuthIssuer],
jwks_uri=config[SettingsParameters.OAuthJwksUri],
audience=config[SettingsParameters.OAuthAudience],
audience=config.get(SettingsParameters.OAuthAudience),
required_scopes=config.get(
SettingsParameters.OAuthRequiredScopes
),
Expand Down Expand Up @@ -245,34 +245,35 @@ def try_exit(self):

# initialize Tornado application
_init_asyncio_patch()
route_prefix = re.escape(self.subdirectory)
application = TabPyTornadoApp(
[
(
self.subdirectory + r"/query/([^/]+)",
route_prefix + r"/query/([^/]+)",
QueryPlaneHandler,
dict(app=self),
),
(self.subdirectory + r"/status", StatusHandler, dict(app=self)),
(self.subdirectory + r"/info", ServiceInfoHandler, dict(app=self)),
(self.subdirectory + r"/endpoints", EndpointsHandler, dict(app=self)),
(route_prefix + r"/status", StatusHandler, dict(app=self)),
(route_prefix + r"/info", ServiceInfoHandler, dict(app=self)),
(route_prefix + r"/endpoints", EndpointsHandler, dict(app=self)),
(
self.subdirectory + r"/endpoints/([^/]+)?",
route_prefix + r"/endpoints/([^/]+)?",
EndpointHandler,
dict(app=self),
),
(
self.subdirectory + r"/evaluate",
route_prefix + r"/evaluate",
EvaluationPlaneHandler if self.settings[SettingsParameters.EvaluateEnabled]
else EvaluationPlaneDisabledHandler,
dict(executor=executor, app=self),
),
(
self.subdirectory + r"/configurations/endpoint_upload_destination",
route_prefix + r"/configurations/endpoint_upload_destination",
UploadDestinationHandler,
dict(app=self),
),
(
self.subdirectory + r"/(.*)",
route_prefix + r"/(.*)",
tornado.web.StaticFileHandler,
dict(
path=self.settings[SettingsParameters.StaticPath],
Expand Down Expand Up @@ -401,6 +402,9 @@ def _parse_config(self, config_file):
(SettingsParameters.OAuthEnabled, ConfigParameters.TABPY_OAUTH_ENABLED, False, parser.getboolean),
(SettingsParameters.OAuthIssuer, ConfigParameters.TABPY_OAUTH_ISSUER, None, None),
(SettingsParameters.OAuthJwksUri, ConfigParameters.TABPY_OAUTH_JWKS_URI, None, None),
(SettingsParameters.OAuthAllowNonpublicJwks,
ConfigParameters.TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS, False,
parser.getboolean),
(SettingsParameters.OAuthAudience, ConfigParameters.TABPY_OAUTH_AUDIENCE, None, None),
(SettingsParameters.OAuthRequiredScopes, ConfigParameters.TABPY_OAUTH_REQUIRED_SCOPES,
None, None),
Expand Down Expand Up @@ -581,7 +585,6 @@ def _validate_oauth_settings(self):
required = [
(SettingsParameters.OAuthIssuer, ConfigParameters.TABPY_OAUTH_ISSUER),
(SettingsParameters.OAuthJwksUri, ConfigParameters.TABPY_OAUTH_JWKS_URI),
(SettingsParameters.OAuthAudience, ConfigParameters.TABPY_OAUTH_AUDIENCE),
]
missing = [
config_key for setting, config_key in required
Expand All @@ -595,6 +598,25 @@ def _validate_oauth_settings(self):
logger.critical(msg)
raise RuntimeError(msg)

required_scopes = self.settings.get(SettingsParameters.OAuthRequiredScopes)
has_required_scopes = any(
scope.strip() for scope in (required_scopes or "").split(",")
)
has_resource_boundary = (
bool(self.settings.get(SettingsParameters.OAuthAudience))
or has_required_scopes
)
if not has_resource_boundary:
msg = (
"OAuth requires a global resource authorization boundary: configure "
f"{ConfigParameters.TABPY_OAUTH_AUDIENCE}, "
f"or {ConfigParameters.TABPY_OAUTH_REQUIRED_SCOPES}. "
f"{ConfigParameters.TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES} provides "
"additional per-endpoint authorization only"
)
logger.critical(msg)
raise RuntimeError(msg)

endpoint_scopes = [
(SettingsParameters.OAuthQueryScope,
ConfigParameters.TABPY_OAUTH_QUERY_SCOPE),
Expand Down Expand Up @@ -671,19 +693,33 @@ def _validate_oauth_settings(self):
# is_private/is_loopback/is_link_local/is_reserved misses ranges
# like IPv4-mapped IPv6 (::ffff:169.254.169.254) and CGNAT
# (100.64.0.0/10), which is_global correctly excludes.
unsafe_addresses = [
unsafe_addresses = sorted(
address for address in jwks_addresses
if not ipaddress.ip_address(address).is_global
]
if unsafe_addresses:
)
if unsafe_addresses and not self.settings[
SettingsParameters.OAuthAllowNonpublicJwks
]:
msg = (
f"{ConfigParameters.TABPY_OAUTH_JWKS_URI} host \"{jwks_host}\" "
f"resolves to a non-public address "
f"({', '.join(unsafe_addresses)}): refusing to use it as the "
"JWKS endpoint"
"JWKS endpoint. Set "
f"{ConfigParameters.TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS}=true only "
"when this exact endpoint is trusted and intentionally routed "
"through a private network or enterprise proxy"
)
logger.critical(msg)
raise RuntimeError(msg)
if unsafe_addresses:
logger.warning(
f"{ConfigParameters.TABPY_OAUTH_JWKS_URI} host \"{jwks_host}\" "
f"resolves to a non-public address "
f"({', '.join(unsafe_addresses)}), but "
f"{ConfigParameters.TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS} is enabled. "
"Only use this override for a trusted JWKS endpoint on a "
"controlled network"
)

def _get_features(self):
features = {}
Expand All @@ -697,8 +733,16 @@ def _get_features(self):
if ConfigParameters.TABPY_PWD_FILE in self.settings:
methods["basic-auth"] = {}
if self.settings[SettingsParameters.OAuthEnabled]:
required_scopes = [
scope.strip()
for scope in (
self.settings.get(SettingsParameters.OAuthRequiredScopes) or ""
).split(",")
if scope.strip()
]
methods["oauth-jwt"] = {
"scopes": list(
"required_scopes": required_scopes,
"endpoint_scopes": list(
endpoint_scope_names(
self.settings[SettingsParameters.OAuthEndpointScopes]
)
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 @@ -26,6 +26,7 @@ class ConfigParameters:
TABPY_OAUTH_ENABLED = "TABPY_OAUTH_ENABLED"
TABPY_OAUTH_ISSUER = "TABPY_OAUTH_ISSUER"
TABPY_OAUTH_JWKS_URI = "TABPY_OAUTH_JWKS_URI"
TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = "TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS"
TABPY_OAUTH_AUDIENCE = "TABPY_OAUTH_AUDIENCE"
TABPY_OAUTH_REQUIRED_SCOPES = "TABPY_OAUTH_REQUIRED_SCOPES"
TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES = "TABPY_OAUTH_ENFORCE_ENDPOINT_SCOPES"
Expand Down Expand Up @@ -64,6 +65,7 @@ class SettingsParameters:
OAuthEnabled = "oauth_enabled"
OAuthIssuer = "oauth_issuer"
OAuthJwksUri = "oauth_jwks_uri"
OAuthAllowNonpublicJwks = "oauth_allow_nonpublic_jwks"
OAuthAudience = "oauth_audience"
OAuthRequiredScopes = "oauth_required_scopes"
OAuthEnforceEndpointScopes = "oauth_enforce_endpoint_scopes"
Expand Down
18 changes: 13 additions & 5 deletions tabpy/tabpy_server/common/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@
# Enable Gzip compression for requests and responses.
# TABPY_GZIP_ENABLE = true

# Enable OAuth/JWT Bearer-token authentication. When enabled,
# TABPY_OAUTH_ISSUER, TABPY_OAUTH_JWKS_URI, and TABPY_OAUTH_AUDIENCE are
# all required.
# Enable OAuth/JWT Bearer-token authentication. TABPY_OAUTH_ISSUER and
# TABPY_OAUTH_JWKS_URI are required. Also configure at least one resource
# authorization boundary: TABPY_OAUTH_AUDIENCE or TABPY_OAUTH_REQUIRED_SCOPES.
# Prefer audience validation when the authorization server includes a
# configurable aud claim. Endpoint scopes are additional authorization only.
# TABPY_OAUTH_ENABLED = true

# Expected `iss` claim on incoming JWTs.
Expand All @@ -49,8 +51,14 @@
# JWKS endpoint used to fetch and cache the IdP's signing keys.
# TABPY_OAUTH_JWKS_URI = https://idp.example.com/.well-known/jwks.json

# Expected `aud` claim on incoming JWTs.
# TABPY_OAUTH_AUDIENCE = tabpy
# Permit the configured JWKS hostname to resolve to non-public IP addresses.
# Default false. Enable only for a trusted endpoint intentionally routed through
# an internal network, split DNS, or enterprise proxy. HTTPS remains required.
# TABPY_OAUTH_ALLOW_NONPUBLIC_JWKS = true

# Optional expected `aud` claim on incoming JWTs. This is the preferred resource
# boundary when the authorization server supports it. Otherwise, use scopes.
# TABPY_OAUTH_AUDIENCE = api://tabpy

# Comma-separated list of scopes that must all be present in the JWT's
# `scope` claim on every request (including /info). Leave unset to skip
Expand Down
6 changes: 3 additions & 3 deletions tabpy/tabpy_server/handlers/base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,8 @@ def _validate_basic_auth_credentials(self) -> bool:
def _validate_jwt_credentials(self) -> bool:
"""
Validates the Bearer token found by _get_bearer_token against the
configured IdP: signature (via JWKS), issuer, audience, expiry,
nbf, and optionally required scopes.
configured IdP: signature (via JWKS), issuer, expiry, nbf, and
optionally audience and required scopes.
Returns
-------
Expand All @@ -434,7 +434,7 @@ def _validate_jwt_credentials(self) -> bool:
self.jwt_token,
issuer=self.settings[SettingsParameters.OAuthIssuer],
jwks_uri=self.settings[SettingsParameters.OAuthJwksUri],
audience=self.settings[SettingsParameters.OAuthAudience],
audience=self.settings.get(SettingsParameters.OAuthAudience),
required_scopes=self.settings.get(SettingsParameters.OAuthRequiredScopes),
)
except JwtValidationError as ex:
Expand Down
Loading
Loading