Skip to content

Commit

Permalink
Merge branch 'main' into debug-helm
Browse files Browse the repository at this point in the history
  • Loading branch information
DiamondJoseph authored Jan 29, 2025
2 parents 2a64dbb + 1f93363 commit c68a05c
Show file tree
Hide file tree
Showing 19 changed files with 173 additions and 157 deletions.
21 changes: 18 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,33 @@ Write the date in place of the "Unreleased" in the case a new version is release

## Unreleased

### Changed

- Refactor and standardize Adapter API: implement from_uris and from_catalog
classmethods for instantiation from files and registered Tiled nodes, respectively.
- Refactor CSVAdapter to allow pd.read_csv kwargs
- Removed `tiled.adapters.zarr.read_zarr` utility function.
- Server declares authentication provider modes are `external` or `internal`. The
latter was renamed from `password`. Client accepts either `internal` or `password`
for backward-compatibility with older servers.

### Added

- Added `.get` methods on TableAdapter and ParquetDatasetAdapter
- Ability to read string-valued columns of data frames as arrays

### Fixed

- Do not attempt to use auth tokens if the server declares no authentication
providers.

### Maintenance

- Make depedencies shared by client and server into core dependencies.
- Use schemas for describing server configuration on the client side too.
- Refactored Authentication providers to make use of inheritance, adjusted
mode in the `AboutAuthenticationProvider` schema to be `internal`|`external`.
- Improved type hinting and efficiency of caching singleton values
- Allow and document remote debugging with Helm

## v0.1.0-b16 (2024-01-23)
Expand All @@ -37,9 +55,6 @@ Write the date in place of the "Unreleased" in the case a new version is release
`public_keys`, which can be fetched at initialization (server startup) time.
See examples `example_configs/orcid_auth.yml`,
`example_configs/google_auth.yml`, and `example_configs/simple_oidc`.
- Refactor and standardize Adapter API: implement from_uris and from_catalog
classmethods for instantiation from files and registered Tiled nodes, respectively.
- Refactor CSVAdapter to allow pd.read_csv kwargs

### Maintenance

Expand Down
2 changes: 1 addition & 1 deletion docs/source/reference/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ $ http :8000/api/v1/ | jq .authentication
"providers": [
{
"provider": "toy",
"mode": "password",
"mode": "internal",
"links": {
"auth_endpoint": "http://localhost:8000/api/v1/auth/provider/toy/token"
},
Expand Down
6 changes: 3 additions & 3 deletions example_configs/external_service/custom.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import numpy

from tiled.adapters.array import ArrayAdapter
from tiled.authenticators import Mode, UserSessionState
from tiled.authenticators import UserSessionState
from tiled.server.protocols import InternalAuthenticator
from tiled.structures.core import StructureFamily


class Authenticator:
class Authenticator(InternalAuthenticator):
"This accepts any password and stashes it in session state as 'token'."
mode = Mode.password

async def authenticate(self, username: str, password: str) -> UserSessionState:
return UserSessionState(username, {"token": password})
Expand Down
17 changes: 17 additions & 0 deletions tiled/_tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ def server(tmpdir):
yield url


@pytest.fixture
def public_server(tmpdir):
catalog = in_memory(writable_storage=tmpdir)
app = build_app(
catalog, {"single_user_api_key": API_KEY, "allow_anonymous_access": True}
)
app.include_router(router)
config = uvicorn.Config(app, port=0, loop="asyncio", log_config=LOGGING_CONFIG)
server = Server(config)
with server.run_in_thread() as url:
yield url


@router.get("/error")
def error():
1 / 0 # error!
Expand All @@ -80,3 +93,7 @@ def test_writing_integration(server):
client = from_uri(server, api_key=API_KEY)
x = client.write_array([1, 2, 3], key="array")
x[:]


def test_public_server(public_server):
from_uri(public_server)
20 changes: 1 addition & 19 deletions tiled/adapters/zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,6 @@
INLINED_DEPTH = int(os.getenv("TILED_HDF5_INLINED_CONTENTS_MAX_DEPTH", "7"))


def read_zarr(
data_uri: str,
structure: Optional[ArrayStructure] = None,
**kwargs: Any,
) -> Union["ZarrGroupAdapter", ArrayAdapter]:
filepath = path_from_uri(data_uri)
zarr_obj = zarr.open(filepath) # Group or Array
adapter: Union[ZarrGroupAdapter, ArrayAdapter]
if isinstance(zarr_obj, zarr.hierarchy.Group):
adapter = ZarrGroupAdapter(zarr_obj, **kwargs)
else:
if structure is None:
adapter = ZarrArrayAdapter.from_array(zarr_obj, **kwargs)
else:
adapter = ZarrArrayAdapter(zarr_obj, structure=structure, **kwargs)
return adapter


class ZarrArrayAdapter(ArrayAdapter):
""" """

Expand Down Expand Up @@ -381,7 +363,7 @@ def from_catalog(
zarr_obj = zarr.open(
path_from_uri(data_source.assets[0].data_uri)
) # Group or Array
if isinstance(zarr_obj, zarr.hierarchy.Group):
if node.structure_family == StructureFamily.container:
return ZarrGroupAdapter(
zarr_obj,
structure=data_source.structure,
Expand Down
80 changes: 41 additions & 39 deletions tiled/authenticators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,47 @@
import re
import secrets
from collections.abc import Iterable
from typing import Any, cast
from typing import Any, Mapping, Optional, cast

import httpx
from fastapi import APIRouter, Request
from jose import JWTError, jwt
from pydantic import Secret
from starlette.responses import RedirectResponse

from .server.authentication import Mode
from .server.protocols import UserSessionState
from .server.protocols import (
ExternalAuthenticator,
InternalAuthenticator,
UserSessionState,
)
from .server.utils import get_root_url
from .utils import modules_available

logger = logging.getLogger(__name__)


class DummyAuthenticator:
class DummyAuthenticator(InternalAuthenticator):
"""
For test and demo purposes only!
Accept any username and any password.
"""

mode = Mode.password

def __init__(self, confirmation_message=""):
def __init__(self, confirmation_message: str = ""):
self.confirmation_message = confirmation_message

async def authenticate(self, username: str, password: str) -> UserSessionState:
return UserSessionState(username, {})


class DictionaryAuthenticator:
class DictionaryAuthenticator(InternalAuthenticator):
"""
For test and demo purposes only!
Check passwords from a dictionary of usernames mapped to passwords.
"""

mode = Mode.password
configuration_schema = """
$schema": http://json-schema.org/draft-07/schema#
type: object
Expand All @@ -61,11 +61,15 @@ class DictionaryAuthenticator:
description: May be displayed by client after successful login.
"""

def __init__(self, users_to_passwords, confirmation_message=""):
def __init__(
self, users_to_passwords: Mapping[str, str], confirmation_message: str = ""
):
self._users_to_passwords = users_to_passwords
self.confirmation_message = confirmation_message

async def authenticate(self, username: str, password: str) -> UserSessionState:
async def authenticate(
self, username: str, password: str
) -> Optional[UserSessionState]:
true_password = self._users_to_passwords.get(username)
if not true_password:
# Username is not valid.
Expand All @@ -74,8 +78,7 @@ async def authenticate(self, username: str, password: str) -> UserSessionState:
return UserSessionState(username, {})


class PAMAuthenticator:
mode = Mode.password
class PAMAuthenticator(InternalAuthenticator):
configuration_schema = """
$schema": http://json-schema.org/draft-07/schema#
type: object
Expand All @@ -89,7 +92,7 @@ class PAMAuthenticator:
description: May be displayed by client after successful login.
"""

def __init__(self, service="login", confirmation_message=""):
def __init__(self, service: str = "login", confirmation_message: str = ""):
if not modules_available("pamela"):
raise ModuleNotFoundError(
"This PAMAuthenticator requires the module 'pamela' to be installed."
Expand All @@ -98,20 +101,20 @@ def __init__(self, service="login", confirmation_message=""):
self.confirmation_message = confirmation_message
# TODO Try to open a PAM session.

async def authenticate(self, username: str, password: str) -> UserSessionState:
async def authenticate(
self, username: str, password: str
) -> Optional[UserSessionState]:
import pamela

try:
pamela.authenticate(username, password, service=self.service)
return UserSessionState(username, {})
except pamela.PAMError:
# Authentication failed.
return
else:
return UserSessionState(username, {})


class OIDCAuthenticator:
mode = Mode.external
class OIDCAuthenticator(ExternalAuthenticator):
configuration_schema = """
$schema": http://json-schema.org/draft-07/schema#
type: object
Expand Down Expand Up @@ -178,7 +181,7 @@ def authorization_endpoint(self) -> httpx.URL:
cast(str, self._config_from_oidc_url.get("authorization_endpoint"))
)

async def authenticate(self, request: Request) -> UserSessionState:
async def authenticate(self, request: Request) -> Optional[UserSessionState]:
code = request.query_params["code"]
# A proxy in the middle may make the request into something like
# 'http://localhost:8000/...' so we fix the first part but keep
Expand Down Expand Up @@ -216,11 +219,13 @@ async def authenticate(self, request: Request) -> UserSessionState:
return UserSessionState(verified_body["sub"], {})


class KeyNotFoundError(Exception):
pass


async def exchange_code(token_uri, auth_code, client_id, client_secret, redirect_uri):
async def exchange_code(
token_uri: str,
auth_code: str,
client_id: str,
client_secret: str,
redirect_uri: str,
) -> httpx.Response:
"""Method that talks to an IdP to exchange a code for an access_token and/or id_token
Args:
token_url ([type]): [description]
Expand All @@ -241,14 +246,12 @@ async def exchange_code(token_uri, auth_code, client_id, client_secret, redirect
return response


class SAMLAuthenticator:
mode = Mode.external

class SAMLAuthenticator(ExternalAuthenticator):
def __init__(
self,
saml_settings, # See EXAMPLE_SAML_SETTINGS below.
attribute_name, # which SAML attribute to use as 'id' for Idenity
confirmation_message="",
attribute_name: str, # which SAML attribute to use as 'id' for Idenity
confirmation_message: str = "",
):
self.saml_settings = saml_settings
self.attribute_name = attribute_name
Expand All @@ -268,7 +271,7 @@ def __init__(
from onelogin.saml2.auth import OneLogin_Saml2_Auth

@router.get("/login")
async def saml_login(request: Request):
async def saml_login(request: Request) -> RedirectResponse:
req = await prepare_saml_from_fastapi_request(request)
auth = OneLogin_Saml2_Auth(req, self.saml_settings)
# saml_settings = auth.get_settings()
Expand All @@ -279,12 +282,11 @@ async def saml_login(request: Request):
# else:
# print("Error found on Metadata: %s" % (', '.join(errors)))
callback_url = auth.login()
response = RedirectResponse(url=callback_url)
return response
return RedirectResponse(url=callback_url)

self.include_routers = [router]

async def authenticate(self, request) -> UserSessionState:
async def authenticate(self, request: Request) -> Optional[UserSessionState]:
if not modules_available("onelogin"):
raise ModuleNotFoundError(
"This SAMLAuthenticator requires the module 'oneline' to be installed."
Expand All @@ -310,7 +312,7 @@ async def authenticate(self, request) -> UserSessionState:
return None


async def prepare_saml_from_fastapi_request(request, debug=False):
async def prepare_saml_from_fastapi_request(request: Request) -> Mapping[str, str]:
form_data = await request.form()
rv = {
"http_host": request.client.host,
Expand All @@ -336,7 +338,7 @@ async def prepare_saml_from_fastapi_request(request, debug=False):
return rv


class LDAPAuthenticator:
class LDAPAuthenticator(InternalAuthenticator):
"""
The authenticator code is based on https://github.com/jupyterhub/ldapauthenticator
The parameter ``use_tls`` was added for convenience of testing.
Expand Down Expand Up @@ -519,8 +521,6 @@ class LDAPAuthenticator:
id: user02
"""

mode = Mode.password

def __init__(
self,
server_address,
Expand Down Expand Up @@ -733,7 +733,9 @@ async def get_user_attributes(self, conn, userdn):
attrs = conn.entries[0].entry_attributes_as_dict
return attrs

async def authenticate(self, username: str, password: str) -> UserSessionState:
async def authenticate(
self, username: str, password: str
) -> Optional[UserSessionState]:
import ldap3

username_saved = username # Save the user name passed as a parameter
Expand Down
7 changes: 4 additions & 3 deletions tiled/client/constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@ def from_context(
>>> c = from_uri("...", api_key="...")
"""
)
found_valid_tokens = remember_me and context.use_cached_tokens()
if (not found_valid_tokens) and auth_is_required:
context.authenticate(remember_me=remember_me)
if has_providers:
found_valid_tokens = remember_me and context.use_cached_tokens()
if (not found_valid_tokens) and auth_is_required:
context.authenticate(remember_me=remember_me)
# Context ensures that context.api_uri has a trailing slash.
item_uri = f"{context.api_uri}metadata/{'/'.join(node_path_parts)}"
content = handle_error(
Expand Down
2 changes: 1 addition & 1 deletion tiled/client/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def prompt_for_credentials(http_client, providers: List[AboutAuthenticationProvi
auth_endpoint = spec.links["auth_endpoint"]
provider = spec.provider
mode = spec.mode
if mode == "password":
if mode == "internal":
# Prompt for username, password at terminal.
username = username_input()
PASSWORD_ATTEMPTS = 3
Expand Down
Loading

0 comments on commit c68a05c

Please sign in to comment.