Skip to content

Commit 9cb8bf4

Browse files
authored
feat(auth): python client support for bearer auth (#240)
mint tokens in our client library if a secret key and other config has been passed in i will update #231 for end-to-end testing of both clients Ref FS-202
1 parent e6dd4ea commit 9cb8bf4

File tree

7 files changed

+285
-36
lines changed

7 files changed

+285
-36
lines changed

clients/python/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,21 @@ import urllib3
1414
from objectstore_client import (
1515
Client,
1616
NoOpMetricsBackend,
17+
Permission,
1718
TimeToIdle,
1819
TimeToLive,
20+
TokenGenerator,
1921
Usecase,
2022
)
2123

24+
# Necessary when using Objectstore instances that enforce authorization checks.
25+
token_generator = TokenGenerator(
26+
"my-key-id",
27+
"<securely inject EdDSA private key>",
28+
expiry_seconds=60,
29+
permissions=Permission.max(),
30+
)
31+
2232
# This should be stored in a global variable and reused, in order to reuse the connection
2333
client = Client(
2434
"http://localhost:8888",
@@ -31,6 +41,8 @@ client = Client(
3141
retries=3, # Number of connection retries
3242
# For further customization, provide additional kwargs for urllib3.HTTPConnectionPool
3343
connection_kwargs={"maxsize": 10},
44+
# Optionally, provide a token generator for Objectstore instances with authorization enforced
45+
token_generator=token_generator,
3446
)
3547

3648
# This could also be stored in a global/shared variable, as you will deal with a fixed number of usecases with statically defined defaults

clients/python/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"urllib3>=2.2.2",
1616
"zstandard>=0.18.0",
1717
"filetype>=1.2.0",
18+
"PyJWT[crypto]>=2.10.1",
1819
]
1920

2021
[build-system]

clients/python/src/objectstore_client/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from objectstore_client.auth import Permission, TokenGenerator
12
from objectstore_client.client import (
23
Client,
34
GetResponse,
@@ -23,8 +24,10 @@
2324
"Compression",
2425
"ExpirationPolicy",
2526
"Metadata",
27+
"Permission",
2628
"TimeToIdle",
2729
"TimeToLive",
30+
"TokenGenerator",
2831
"MetricsBackend",
2932
"NoOpMetricsBackend",
3033
]
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from datetime import UTC, datetime, timedelta
2+
from enum import StrEnum
3+
from typing import Self
4+
5+
import jwt
6+
7+
from objectstore_client.scope import Scope
8+
9+
10+
class Permission(StrEnum):
11+
"""
12+
Enum listing permissions that Objectstore tokens may be granted.
13+
"""
14+
15+
OBJECT_READ = "object.read"
16+
OBJECT_WRITE = "object.write"
17+
OBJECT_DELETE = "object.delete"
18+
19+
@classmethod
20+
def max(cls) -> list[Self]:
21+
return list(cls.__members__.values())
22+
23+
24+
class TokenGenerator:
25+
def __init__(
26+
self,
27+
kid: str,
28+
secret_key: str,
29+
expiry_seconds: int = 60,
30+
permissions: list[Permission] = Permission.max(),
31+
):
32+
self.kid = kid
33+
self.secret_key = secret_key
34+
self.expiry_seconds = expiry_seconds
35+
self.permissions = permissions
36+
37+
def sign_for_scope(self, usecase: str, scope: Scope) -> str:
38+
"""
39+
Sign a JWT for the passed-in usecase and scope using the configured key
40+
information, expiry, and permissions.
41+
42+
The JWT is signed using EdDSA, so `self.secret_key` must be an EdDSA private
43+
key. `self.kid` is used by the Objectstore server to load the corresponding
44+
public key from its configuration.
45+
"""
46+
headers = {"kid": self.kid}
47+
claims = {
48+
"res": {
49+
"os:usecase": usecase,
50+
**{k: str(v) for k, v in scope.dict().items()},
51+
},
52+
"permissions": self.permissions,
53+
"exp": datetime.now(tz=UTC) + timedelta(seconds=self.expiry_seconds),
54+
}
55+
56+
return jwt.encode(claims, self.secret_key, algorithm="EdDSA", headers=headers)

clients/python/src/objectstore_client/client.py

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import string
43
from collections.abc import Mapping
54
from dataclasses import asdict, dataclass
65
from io import BytesIO
@@ -12,6 +11,7 @@
1211
import zstandard
1312
from urllib3.connectionpool import HTTPConnectionPool
1413

14+
from objectstore_client.auth import TokenGenerator
1515
from objectstore_client.metadata import (
1616
HEADER_EXPIRATION,
1717
HEADER_META_PREFIX,
@@ -25,8 +25,7 @@
2525
NoOpMetricsBackend,
2626
measure_storage_operation,
2727
)
28-
29-
Permission = Literal["read", "write"]
28+
from objectstore_client.scope import Scope
3029

3130

3231
class GetResponse(NamedTuple):
@@ -68,12 +67,6 @@ def __init__(
6867
self._expiration_policy = expiration_policy
6968

7069

71-
# Characters allowed in a Scope's key and value.
72-
# These are the URL safe characters, except for `.` which we use as separator between
73-
# key and value of Scope components.
74-
SCOPE_VALUE_ALLOWED_CHARS = set(string.ascii_letters + string.digits + "-_()$!+'")
75-
76-
7770
@dataclass
7871
class _ConnectionDefaults:
7972
retries: urllib3.Retry = urllib3.Retry(connect=3, read=0)
@@ -100,6 +93,7 @@ def __init__(
10093
retries: int | None = None,
10194
timeout_ms: float | None = None,
10295
connection_kwargs: Mapping[str, Any] | None = None,
96+
token_generator: TokenGenerator | None = None,
10397
):
10498
connection_kwargs_to_use = asdict(_ConnectionDefaults())
10599

@@ -125,11 +119,12 @@ def __init__(
125119
self._base_path = urlparse(base_url).path
126120
self._metrics_backend = metrics_backend or NoOpMetricsBackend()
127121
self._propagate_traces = propagate_traces
122+
self._token_generator = token_generator
128123

129124
def session(self, usecase: Usecase, **scopes: str | int | bool) -> Session:
130125
"""
131126
Create a [Session] with the Objectstore server, tied to a specific [Usecase] and
132-
Scope.
127+
[Scope].
133128
134129
A Scope is a (possibly nested) namespace within a Usecase, given as a sequence
135130
of key-value pairs passed as kwargs.
@@ -148,37 +143,14 @@ def session(self, usecase: Usecase, **scopes: str | int | bool) -> Session:
148143
```
149144
"""
150145

151-
parts = []
152-
for key, value in scopes.items():
153-
if not key:
154-
raise ValueError("Scope key cannot be empty")
155-
if not value:
156-
raise ValueError("Scope value cannot be empty")
157-
158-
if any(c not in SCOPE_VALUE_ALLOWED_CHARS for c in key):
159-
raise ValueError(
160-
f"Invalid scope key {key}. The valid character set is: "
161-
f"{''.join(SCOPE_VALUE_ALLOWED_CHARS)}"
162-
)
163-
164-
value = str(value)
165-
if any(c not in SCOPE_VALUE_ALLOWED_CHARS for c in value):
166-
raise ValueError(
167-
f"Invalid scope value {value}. The valid character set is: "
168-
f"{''.join(SCOPE_VALUE_ALLOWED_CHARS)}"
169-
)
170-
171-
formatted = f"{key}={value}"
172-
parts.append(formatted)
173-
scope_str = ";".join(parts)
174-
175146
return Session(
176147
self._pool,
177148
self._base_path,
178149
self._metrics_backend,
179150
self._propagate_traces,
180151
usecase,
181-
scope_str,
152+
Scope(**scopes),
153+
self._token_generator,
182154
)
183155

184156

@@ -196,21 +168,28 @@ def __init__(
196168
metrics_backend: MetricsBackend,
197169
propagate_traces: bool,
198170
usecase: Usecase,
199-
scope: str,
171+
scope: Scope,
172+
token_generator: TokenGenerator | None,
200173
):
201174
self._pool = pool
202175
self._base_path = base_path
203176
self._metrics_backend = metrics_backend
204177
self._propagate_traces = propagate_traces
205178
self._usecase = usecase
206179
self._scope = scope
180+
self._token_generator = token_generator
207181

208182
def _make_headers(self) -> dict[str, str]:
209183
headers = dict(self._pool.headers)
210184
if self._propagate_traces:
211185
headers.update(
212186
dict(sentry_sdk.get_current_scope().iter_trace_propagation_headers())
213187
)
188+
if self._token_generator:
189+
token = self._token_generator.sign_for_scope(
190+
self._usecase.name, self._scope
191+
)
192+
headers["Authorization"] = f"Bearer {token}"
214193
return headers
215194

216195
def _make_url(self, key: str | None, full: bool = False) -> str:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import string
2+
3+
# Characters allowed in a Scope's key and value.
4+
# These are the URL safe characters, except for `.` which we use as separator between
5+
# key and value of Scope components.
6+
SCOPE_VALUE_ALLOWED_CHARS = set(string.ascii_letters + string.digits + "-_()$!+'")
7+
8+
9+
class Scope:
10+
def __init__(self, **scopes: str | int | bool):
11+
parts = []
12+
for key, value in scopes.items():
13+
if not key:
14+
raise ValueError("Scope key cannot be empty")
15+
if not value:
16+
raise ValueError("Scope value cannot be empty")
17+
18+
if any(c not in SCOPE_VALUE_ALLOWED_CHARS for c in key):
19+
raise ValueError(
20+
f"Invalid scope key {key}. The valid character set is: "
21+
f"{''.join(SCOPE_VALUE_ALLOWED_CHARS)}"
22+
)
23+
24+
value = str(value)
25+
if any(c not in SCOPE_VALUE_ALLOWED_CHARS for c in value):
26+
raise ValueError(
27+
f"Invalid scope value {value}. The valid character set is: "
28+
f"{''.join(SCOPE_VALUE_ALLOWED_CHARS)}"
29+
)
30+
31+
formatted = f"{key}={value}"
32+
parts.append(formatted)
33+
34+
self._scope = scopes
35+
self._scope_str = ";".join(parts)
36+
37+
def __str__(self) -> str:
38+
return self._scope_str
39+
40+
def dict(self) -> dict[str, str | int | bool]:
41+
return self._scope

0 commit comments

Comments
 (0)