diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e6eebbc..228ee3e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/source/how-to/api-keys.md b/docs/source/how-to/api-keys.md index d8d4cb7b9..25b04f346 100644 --- a/docs/source/how-to/api-keys.md +++ b/docs/source/how-to/api-keys.md @@ -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. ``` diff --git a/tiled/_tests/test_utils.py b/tiled/_tests/test_utils.py index 2e39725e7..b2de27bd4 100644 --- a/tiled/_tests/test_utils.py +++ b/tiled/_tests/test_utils.py @@ -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(): @@ -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) diff --git a/tiled/client/context.py b/tiled/client/context.py index 1c336d464..3ac193cc5 100644 --- a/tiled/client/context.py +++ b/tiled/client/context.py @@ -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 @@ -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"], diff --git a/tiled/commandline/_api_key.py b/tiled/commandline/_api_key.py index 3b7382d21..0a83b3740 100644 --- a/tiled/commandline/_api_key.py +++ b/tiled/commandline/_api_key.py @@ -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[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( @@ -35,6 +36,8 @@ def create_api_key( # This is how typer interprets unspecified scopes. # Replace with None to get default scopes. scopes = None + if expires_in.isdigit(): + expires_in = int(expires_in) info = context.create_api_key(scopes=scopes, expires_in=expires_in, note=note) # TODO Print other info to the stderr? typer.echo(info["secret"]) diff --git a/tiled/utils.py b/tiled/utils.py index 732424c35..1d57a62a7 100644 --- a/tiled/utils.py +++ b/tiled/utils.py @@ -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)