Skip to content
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ celerybeat-schedule

# Environments
.env
.venv
.venv*
env/
venv/
ENV/
Expand Down
41 changes: 21 additions & 20 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ Equality is a core value at Salesforce. We believe a diverse and inclusive
community fosters innovation and creativity, and are committed to building a
culture where everyone feels included.

Salesforce open-source projects are committed to providing a friendly, safe, and
welcoming environment for all, regardless of gender identity and expression,
sexual orientation, disability, physical appearance, body size, ethnicity, nationality,
race, age, religion, level of experience, education, socioeconomic status, or
other similar personal characteristics.
Salesforce open-source projects are committed to providing a friendly, safe,
and welcoming environment for all, regardless of gender identity and
expression, sexual orientation, disability, physical appearance, body size,
ethnicity, nationality, race, age, religion, level of experience, education,
socioeconomic status, or other similar personal characteristics.

The goal of this code of conduct is to specify a baseline standard of behavior so
that people with different social values and communication styles can work
Expand All @@ -19,16 +19,16 @@ It also establishes a mechanism for reporting issues and resolving conflicts.

All questions and reports of abusive, harassing, or otherwise unacceptable behavior
in a Salesforce open-source project may be reported by contacting the Salesforce
Open Source Conduct Committee at [email protected].
Open Source Conduct Committee at <[email protected]>.

## Our Pledge

In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of gender
identity and expression, sexual orientation, disability, physical appearance,
body size, ethnicity, nationality, race, age, religion, level of experience, education,
socioeconomic status, or other similar personal characteristics.
our community a harassment-free experience for everyone, regardless of gender
identity and expression, sexual orientation, disability, physical appearance,
body size, ethnicity, nationality, race, age, religion, level of experience,
education, socioeconomic status, or other similar personal characteristics.

## Our Standards

Expand Down Expand Up @@ -77,23 +77,24 @@ further defined and clarified by project maintainers.
## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the Salesforce Open Source Conduct Committee
at [email protected]. All complaints will be reviewed and investigated
and will result in a response that is deemed necessary and appropriate to the
circumstances. The committee is obligated to maintain confidentiality with
regard to the reporter of an incident. Further details of specific enforcement
reported by contacting the Salesforce Open Source Conduct Committee
at <[email protected]>. All complaints will be reviewed and investigated
and will result in a response that is deemed necessary and appropriate to the
circumstances. The committee is obligated to maintain confidentiality with
regard to the reporter of an incident. Further details of specific enforcement
policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership and the Salesforce Open Source Conduct
members of the project's leadership and the Salesforce Open Source Conduct
Committee.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home],
version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html.
It includes adaptions and additions from [Go Community Code of Conduct][golang-coc],
This Code of Conduct is adapted from the
[Contributor Covenant][contributor-covenant-home], version 1.4, available at
[https://www.contributor-covenant.org/version/1/4/code-of-conduct.html](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html).
It includes adaptions and additions from [Go Community Code of Conduct][golang-coc],
[CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc].

This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us].
Expand All @@ -102,4 +103,4 @@ This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 Lic
[golang-coc]: https://golang.org/conduct
[cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md
[microsoft-coc]: https://opensource.microsoft.com/codeofconduct/
[cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/
[cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## Security
# Security

Please report any security issue to [https://www.sfdc.co/SubmitVuln](https://www.sfdc.co/SubmitVuln)
as soon as it is discovered. This library limits its runtime dependencies in
Expand Down
62 changes: 60 additions & 2 deletions docs/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,10 @@ connection scenario.

## Authentication

TabPy supports basic access authentication (see
TabPy supports two authentication methods, which can be enabled independently
or at the same time: basic access authentication (see
[https://en.wikipedia.org/wiki/Basic_access_authentication](https://en.wikipedia.org/wiki/Basic_access_authentication)
for more details).
for more details) and OAuth/JWT Bearer token authentication.

### Enabling Authentication

Expand Down Expand Up @@ -269,6 +270,63 @@ will be generated and displayed in the command line.
To delete an account open password file in any text editor and delete the
line with the user name.

### OAuth / JWT Bearer Token Authentication

TabPy can validate OAuth 2.0 JWT Bearer tokens on incoming requests. This
allows per-user credentials issued by an external identity provider (IdP)
to be used instead of, or alongside, a single shared password-file
credential.

To enable it, specify the following parameters in the TabPy configuration
file:

```sh
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_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
`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_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.

Two additional parameters are optional:

```sh
TABPY_OAUTH_REQUIRED_SCOPES = tabpy:query,tabpy:evaluate
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.
- `TABPY_OAUTH_LOG_USER` (default `true`) sets the JWT's `sub` claim as the
authenticated user for logging purposes.

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

```sh
curl -H "Authorization: Bearer <token>" http://localhost:9004/info
```

When both basic access authentication and OAuth are enabled, TabPy picks the
method based on the scheme of the `Authorization` header sent by the client
(`Basic` or `Bearer`), so both can be used against the same server.

JWKS lookups are cached, but because TabPy serves requests on a single
thread, a slow or unresponsive IdP during a cache-cold fetch (startup, or a
key rotation) will briefly stall all concurrent requests, not just the one
that triggered the fetch.

### Endpoint Security

All endpoints require authentication if it is enabled for the server.
Expand Down
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def read(fname):
"configparser",
"coverage",
"coveralls",
"cryptography",
"docopt",
"future",
"genson",
Expand All @@ -81,6 +82,7 @@ def read(fname):
"nltk",
"numpy",
"pandas",
"pyjwt",
"pyopenssl",
"pytest",
"pytest-cov",
Expand Down
63 changes: 59 additions & 4 deletions tabpy/tabpy_server/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,15 @@ def _parse_config(self, config_file):
100, None),
(SettingsParameters.GzipEnabled, ConfigParameters.TABPY_GZIP_ENABLE,
True, parser.getboolean),
(SettingsParameters.ArrowEnabled, ConfigParameters.TABPY_ARROW_ENABLE, False, parser.getboolean),
(SettingsParameters.ArrowEnabled, ConfigParameters.TABPY_ARROW_ENABLE, False, parser.getboolean),
(SettingsParameters.ArrowFlightPort, ConfigParameters.TABPY_ARROWFLIGHT_PORT, 13622, parser.getint),
(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.OAuthAudience, ConfigParameters.TABPY_OAUTH_AUDIENCE, None, None),
(SettingsParameters.OAuthRequiredScopes, ConfigParameters.TABPY_OAUTH_REQUIRED_SCOPES,
None, None),
(SettingsParameters.OAuthLogUser, ConfigParameters.TABPY_OAUTH_LOG_USER, True, parser.getboolean),
]

for setting, parameter, default_val, parse_function in settings_parameters:
Expand Down Expand Up @@ -403,6 +410,10 @@ def _parse_config(self, config_file):
if state_config.has_option("Service Info", "Subdirectory"):
self.subdirectory = "/" + state_config.get("Service Info", "Subdirectory")

# Validate OAuth config if enabled
if self.settings[SettingsParameters.OAuthEnabled]:
self._validate_oauth_settings()

# If passwords file specified load credentials
if ConfigParameters.TABPY_PWD_FILE in self.settings:
if not self._parse_pwd_file():
Expand All @@ -412,7 +423,7 @@ def _parse_config(self, config_file):
)
logger.critical(msg)
raise RuntimeError(msg)
else:
elif not self.settings[SettingsParameters.OAuthEnabled]:
self._handle_configuration_without_authentication()

features = self._get_features()
Expand Down Expand Up @@ -513,14 +524,58 @@ def _handle_configuration_without_authentication(self):
"https://github.com/tableau/TabPy/blob/master/docs/server-config.md#authentication.")
exit()

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
if not self.settings.get(setting)
]
if missing:
msg = (
f"{ConfigParameters.TABPY_OAUTH_ENABLED} is true but missing required "
f"setting(s): {', '.join(missing)}"
)
logger.critical(msg)
raise RuntimeError(msg)

# JWKS/issuer are the trust anchor for JWT verification, so both must
# be fetched over https to prevent an on-path attacker from substituting
# their own keys/issuer.
insecure = [
config_key for setting, config_key in (
(SettingsParameters.OAuthIssuer, ConfigParameters.TABPY_OAUTH_ISSUER),
(SettingsParameters.OAuthJwksUri, ConfigParameters.TABPY_OAUTH_JWKS_URI),
)
if not self.settings[setting].lower().startswith("https://")
]
if insecure:
msg = (
f"{', '.join(insecure)} must use https: JWKS/issuer are the trust "
"anchor for JWT verification and must not be fetched over plain HTTP"
)
logger.critical(msg)
raise RuntimeError(msg)

def _get_features(self):
features = {}

# Check for auth
if ConfigParameters.TABPY_PWD_FILE in self.settings:
if (
ConfigParameters.TABPY_PWD_FILE in self.settings
or self.settings[SettingsParameters.OAuthEnabled]
):
methods = {}
if ConfigParameters.TABPY_PWD_FILE in self.settings:
methods["basic-auth"] = {}
if self.settings[SettingsParameters.OAuthEnabled]:
methods["oauth-jwt"] = {}
features["authentication"] = {
"required": True,
"methods": {"basic-auth": {}},
"methods": methods,
}

features["evaluate_enabled"] = self.settings[SettingsParameters.EvaluateEnabled]
Expand Down
16 changes: 16 additions & 0 deletions tabpy/tabpy_server/app/app_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ class ConfigParameters:
TABPY_ARROW_ENABLE = "TABPY_ARROW_ENABLE"
TABPY_ARROWFLIGHT_PORT = "TABPY_ARROWFLIGHT_PORT"

# OAuth/JWT specific settings
TABPY_OAUTH_ENABLED = "TABPY_OAUTH_ENABLED"
TABPY_OAUTH_ISSUER = "TABPY_OAUTH_ISSUER"
TABPY_OAUTH_JWKS_URI = "TABPY_OAUTH_JWKS_URI"
TABPY_OAUTH_AUDIENCE = "TABPY_OAUTH_AUDIENCE"
TABPY_OAUTH_REQUIRED_SCOPES = "TABPY_OAUTH_REQUIRED_SCOPES"
TABPY_OAUTH_LOG_USER = "TABPY_OAUTH_LOG_USER"


class SettingsParameters:
"""
Expand All @@ -47,3 +55,11 @@ class SettingsParameters:
# Arrow specific settings
ArrowEnabled = "arrow_enabled"
ArrowFlightPort = "arrowflight_port"

# OAuth/JWT specific settings
OAuthEnabled = "oauth_enabled"
OAuthIssuer = "oauth_issuer"
OAuthJwksUri = "oauth_jwks_uri"
OAuthAudience = "oauth_audience"
OAuthRequiredScopes = "oauth_required_scopes"
OAuthLogUser = "oauth_log_user"
21 changes: 21 additions & 0 deletions tabpy/tabpy_server/common/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@
# 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.
# TABPY_OAUTH_ENABLED = true

# Expected `iss` claim on incoming JWTs.
# TABPY_OAUTH_ISSUER = https://idp.example.com/

# 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

# 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

# Log the JWT subject as the authenticated user for OAuth requests.
# TABPY_OAUTH_LOG_USER = true

[loggers]
keys=root

Expand Down
Loading
Loading