Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support expiration times given as a number with units. #837

Merged
merged 3 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Write the date in place of the "Unreleased" in the case a new version is release
### Added

- `docker-compose.yml` now uses the healthcheck endpoint `/healthz`
- In client, support specifying API key expiration time as string with
units, like ``"7d"` or `"10m"`.

### Fixed

Expand Down
7 changes: 5 additions & 2 deletions docs/source/how-to/api-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,17 @@ as the user who is for. If an API key will be used for a specific task, it is
good security hygiene to give it only the privileges it needs for that task. It
is also recommended to set a limited lifetimes so that if the key is
unknowingly leaked it will not continue to work forever. For example, this
command creates an API key that will expire in 10 minutes (600 seconds) and can
command creates an API key that will expire in 10 minutes and can
search/list metadata but cannot download array data.

```
$ tiled api_key create --expires-in 600 --scopes read:metadata
$ tiled api_key create --expires-in 10m --scopes read:metadata
ba9af604023a829ab22edb786168d6e1b97cef68c54c6d95d7fad5e3e6347fa131263581
```

Expiration can be given in units of years `y`, days `d`, hours `h`, minutes
`m`, or seconds `s`.

See {doc}`../reference/scopes` for the full list of scopes and their capabilities.

```
Expand Down
32 changes: 31 additions & 1 deletion tiled/_tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pathlib import Path

from ..utils import ensure_specified_sql_driver
import pytest

from ..utils import ensure_specified_sql_driver, parse_time_string


def test_ensure_specified_sql_driver():
Expand Down Expand Up @@ -73,3 +75,31 @@ def test_ensure_specified_sql_driver():
ensure_specified_sql_driver(Path("/tmp/test.db"))
== f"sqlite+aiosqlite:///{Path('/tmp/test.db')}"
)


@pytest.mark.parametrize(
"string_input,expected",
[
("3s", 3),
("7m", 7 * 60),
("5h", 5 * 60 * 60),
("1d", 1 * 24 * 60 * 60),
("2y", 2 * 365 * 24 * 60 * 60),
],
)
def test_parse_time_string_valid(string_input, expected):
assert parse_time_string(string_input) == expected


@pytest.mark.parametrize(
"string_input",
[
"3z", # unrecognized units
"3M", # unrecognized units
"-3m", # invalid character '-'
"3 m", # invalid character '-'
],
)
def test_parse_time_string_invalid(string_input):
with pytest.raises(ValueError):
parse_time_string(string_input)
13 changes: 8 additions & 5 deletions tiled/client/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import platformdirs

from .._version import __version__ as tiled_version
from ..utils import UNSET, DictView
from ..utils import UNSET, DictView, parse_time_string
from .auth import CannotRefreshAuthentication, TiledAuth, build_refresh_request
from .decoders import SUPPORTED_DECODERS
from .transport import Transport
Expand Down Expand Up @@ -419,13 +419,16 @@ def create_api_key(self, scopes=None, expires_in=None, note=None):
scopes : Optional[List[str]]
Restrict the access available to the API key by listing specific scopes.
By default, this will have the same access as the user.
expires_in : Optional[int]
Number of seconds until API key expires. If None,
it will never expire or it will have the maximum lifetime
allowed by the server.
expires_in : Optional[Union[int, str]]
Number of seconds until API key expires, given as integer seconds
or a string like: '3y' (years), '3d' (days), '5m' (minutes), '1h'
(hours), '30s' (seconds). If None, it will never expire or it will
have the maximum lifetime allowed by the server.
note : Optional[str]
Description (for humans).
"""
if isinstance(expires_in, str):
expires_in = parse_time_string(expires_in)
return handle_error(
self.http_client.post(
self.server_info["authentication"]["links"]["apikey"],
Expand Down
11 changes: 6 additions & 5 deletions tiled/commandline/_api_key.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import List, Optional, Union

import typer

Expand All @@ -12,12 +12,13 @@ def create_api_key(
profile: Optional[str] = typer.Option(
None, help="If you use more than one Tiled server, use this to specify which."
),
expires_in: Optional[int] = typer.Option(
expires_in: Optional[Union[int, str]] = typer.Option(
None,
help=(
"Number of seconds until API key expires. If None, "
"it will never expire or it will have the maximum lifetime "
"allowed by the server."
"Number of seconds until API key expires, given as integer seconds "
"or a string like: '3y' (years), '3d' (days), '5m' (minutes), '1h' "
"(hours), '30s' (seconds). If None, it will never expire or it will "
"have the maximum lifetime allowed by the server. "
),
),
scopes: Optional[List[str]] = typer.Option(
Expand Down
28 changes: 28 additions & 0 deletions tiled/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,34 @@ def bytesize_repr(num):
num /= 1024.0


TIME_STRING_PATTERN = re.compile(r"(\d+)(s|m|h|d|y)")
TIME_STRING_UNITS = {
"s": 1,
"m": 60,
"h": 60 * 60,
"d": 60 * 60 * 24,
"y": 60 * 60 * 24 * 365,
}


def parse_time_string(s):
"""
Accept strings like '1y', '1d', '24h'; return int seconds.

Accepted Units:
'y' = year
'd' = day
'h' = hour
'm' = minutes
's' = seconds
"""
matched = TIME_STRING_PATTERN.match(s)
if matched is None:
raise ValueError(f"Could not parse {s} as a number and a unit like '5m'")
number, unit = matched.groups()
return int(number) * TIME_STRING_UNITS[unit]


def is_coroutine_callable(call: Callable[..., Any]) -> bool:
if inspect.isroutine(call):
return inspect.iscoroutinefunction(call)
Expand Down
Loading