flowbyte-secrets 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ bin/
2
+ obj/
3
+ node_modules/
4
+ .vs/
5
+ *.user
6
+ appsettings.*.local.json
7
+ .env
8
+ *.db
9
+ appsettings.Secrets.json
10
+ Application/wwwroot/app.css
11
+ Application.Email/.next/
12
+ **/.react-email/
13
+ clients/python/.venv/
14
+ clients/python/dist/
15
+ **/__pycache__/
16
+ *.egg-info/
17
+ *.nupkg
18
+ TestResults/
19
+ .claude/worktrees/
20
+ *.snupkg
21
+ *.tsbuildinfo
22
+ .pytest_cache/
23
+ nupkg/
24
+ Application/appsettings.json
25
+ Application/appsettings.Development.json
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.5
2
+ Name: flowbyte-secrets
3
+ Version: 1.0.1
4
+ Summary: Python client for the Flowbyte Secrets machine API (fbs_ access tokens), with an optional Dagster resource.
5
+ Author: Flowbyte
6
+ License: Proprietary
7
+ Keywords: configuration,dagster,flowbyte,secrets
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: dagster
13
+ Requires-Dist: dagster>=1.7; extra == 'dagster'
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: respx>=0.21; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # flowbyte-secrets
20
+
21
+ Python client for the Flowbyte Secrets machine API (`fbs_` access tokens), with an optional Dagster resource.
22
+
23
+ ```bash
24
+ pip install flowbyte-secrets # or: pip install "flowbyte-secrets[dagster]"
25
+ ```
26
+
27
+ ```python
28
+ import os
29
+ from flowbyte_secrets import SecretsClient, SecretNotFound
30
+
31
+ c = SecretsClient(base_url="https://secrets.gmrlapp.com", token=os.environ["FBS_TOKEN"], project="dagster-prod")
32
+ c.get("DB_PASSWORD") # str, raises SecretNotFound
33
+ c.list() # [Secret(key, value, version, modified_on)]
34
+ c.set("K", "v") # ReadWrite tokens only
35
+ c.load_into_env(prefix="") # os.environ injection
36
+ ```
37
+
38
+ `base_url`, `token` and `project` fall back to `FBS_URL`, `FBS_TOKEN` and `FBS_PROJECT`.
39
+
40
+ Dagster:
41
+
42
+ ```python
43
+ from dagster import Definitions, EnvVar, asset
44
+ from flowbyte_secrets.dagster import FlowbyteSecretsResource
45
+
46
+ @asset
47
+ def my_asset(secrets: FlowbyteSecretsResource):
48
+ pw = secrets.get("DB_PASSWORD")
49
+
50
+ defs = Definitions(assets=[my_asset], resources={
51
+ "secrets": FlowbyteSecretsResource(base_url=EnvVar("FBS_URL"), token=EnvVar("FBS_TOKEN"), project="dagster-prod"),
52
+ })
53
+ ```
54
+
55
+ See `docs/CLIENTS.md` in the repository for details.
@@ -0,0 +1,37 @@
1
+ # flowbyte-secrets
2
+
3
+ Python client for the Flowbyte Secrets machine API (`fbs_` access tokens), with an optional Dagster resource.
4
+
5
+ ```bash
6
+ pip install flowbyte-secrets # or: pip install "flowbyte-secrets[dagster]"
7
+ ```
8
+
9
+ ```python
10
+ import os
11
+ from flowbyte_secrets import SecretsClient, SecretNotFound
12
+
13
+ c = SecretsClient(base_url="https://secrets.gmrlapp.com", token=os.environ["FBS_TOKEN"], project="dagster-prod")
14
+ c.get("DB_PASSWORD") # str, raises SecretNotFound
15
+ c.list() # [Secret(key, value, version, modified_on)]
16
+ c.set("K", "v") # ReadWrite tokens only
17
+ c.load_into_env(prefix="") # os.environ injection
18
+ ```
19
+
20
+ `base_url`, `token` and `project` fall back to `FBS_URL`, `FBS_TOKEN` and `FBS_PROJECT`.
21
+
22
+ Dagster:
23
+
24
+ ```python
25
+ from dagster import Definitions, EnvVar, asset
26
+ from flowbyte_secrets.dagster import FlowbyteSecretsResource
27
+
28
+ @asset
29
+ def my_asset(secrets: FlowbyteSecretsResource):
30
+ pw = secrets.get("DB_PASSWORD")
31
+
32
+ defs = Definitions(assets=[my_asset], resources={
33
+ "secrets": FlowbyteSecretsResource(base_url=EnvVar("FBS_URL"), token=EnvVar("FBS_TOKEN"), project="dagster-prod"),
34
+ })
35
+ ```
36
+
37
+ See `docs/CLIENTS.md` in the repository for details.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "flowbyte-secrets"
7
+ dynamic = ["version"]
8
+ description = "Python client for the Flowbyte Secrets machine API (fbs_ access tokens), with an optional Dagster resource."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Flowbyte" }]
13
+ keywords = ["secrets", "configuration", "dagster", "flowbyte"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = ["httpx>=0.27"]
19
+
20
+ [project.optional-dependencies]
21
+ dagster = ["dagster>=1.7"]
22
+ dev = ["pytest>=8", "respx>=0.21"]
23
+
24
+ [tool.hatch.version]
25
+ path = "src/flowbyte_secrets/_version.py"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/flowbyte_secrets"]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = ["src/flowbyte_secrets", "tests", "README.md", "pyproject.toml"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ addopts = "-q"
@@ -0,0 +1,40 @@
1
+ """Flowbyte Secrets Python client.
2
+
3
+ >>> from flowbyte_secrets import SecretsClient
4
+ >>> c = SecretsClient(base_url="https://secrets.gmrlapp.com", token=os.environ["FBS_TOKEN"], project="dagster-prod")
5
+ >>> c.get("DB_PASSWORD")
6
+
7
+ The Dagster resource lives in :mod:`flowbyte_secrets.dagster` (``pip install flowbyte-secrets[dagster]``).
8
+ """
9
+
10
+ from ._version import __version__
11
+ from .client import Project, Secret, SecretsClient, TokenScope, WhoAmI
12
+ from .env import load_into_env, to_env, to_env_name
13
+ from .errors import (
14
+ SecretNotFound,
15
+ SecretsAuthError,
16
+ SecretsConfigError,
17
+ SecretsConnectionError,
18
+ SecretsError,
19
+ SecretsForbiddenError,
20
+ SecretsRateLimitError,
21
+ )
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "SecretsClient",
26
+ "Secret",
27
+ "Project",
28
+ "TokenScope",
29
+ "WhoAmI",
30
+ "load_into_env",
31
+ "to_env",
32
+ "to_env_name",
33
+ "SecretsError",
34
+ "SecretsConfigError",
35
+ "SecretsConnectionError",
36
+ "SecretsAuthError",
37
+ "SecretsForbiddenError",
38
+ "SecretNotFound",
39
+ "SecretsRateLimitError",
40
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
@@ -0,0 +1,363 @@
1
+ """HTTP client for the Flowbyte Secrets machine API (``/api/v1/*``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from email.utils import parsedate_to_datetime
11
+ from typing import Any, Callable, Dict, List, MutableMapping, Optional
12
+ from urllib.parse import quote, urlsplit
13
+
14
+ import httpx
15
+
16
+
17
+ def _is_loopback(url: str) -> bool:
18
+ host = (urlsplit(url).hostname or "").lower()
19
+ return host in ("localhost", "::1") or host.startswith("127.")
20
+
21
+ from ._version import __version__
22
+ from .errors import (
23
+ SecretNotFound,
24
+ SecretsAuthError,
25
+ SecretsConfigError,
26
+ SecretsConnectionError,
27
+ SecretsError,
28
+ SecretsForbiddenError,
29
+ SecretsRateLimitError,
30
+ )
31
+
32
+ USER_AGENT = f"flowbyte-secrets-python/{__version__}"
33
+ ENV_URL = "FBS_URL"
34
+ ENV_TOKEN = "FBS_TOKEN"
35
+ ENV_PROJECT = "FBS_PROJECT"
36
+
37
+ _LEVELS = {1: "Read", 2: "ReadWrite", 3: "Manage"}
38
+
39
+
40
+ def _level(value: Any) -> str:
41
+ if isinstance(value, int):
42
+ return _LEVELS.get(value, str(value))
43
+ return "" if value is None else str(value)
44
+
45
+
46
+ def _parse_dt(value: Any) -> Optional[datetime]:
47
+ if not value:
48
+ return None
49
+ try:
50
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
51
+ except ValueError:
52
+ return None
53
+
54
+
55
+ def _json(response: httpx.Response) -> Any:
56
+ try:
57
+ return response.json()
58
+ except ValueError:
59
+ # Never include the payload (it may contain secret values).
60
+ raise SecretsError("The server returned a response that could not be parsed.", response.status_code) from None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Secret:
65
+ key: str
66
+ value: str = field(repr=False) # never shown in repr/logs
67
+ version: int = 0
68
+ modified_on: Optional[datetime] = None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Project:
73
+ id: str
74
+ slug: str
75
+ name: str
76
+ level: str
77
+
78
+ @property
79
+ def can_write(self) -> bool:
80
+ return self.level in ("ReadWrite", "Manage")
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class TokenScope:
85
+ project_id: str
86
+ level: str
87
+ slug: Optional[str] = None
88
+ name: Optional[str] = None
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class WhoAmI:
93
+ machine_account: str
94
+ token_name: str
95
+ company_id: str
96
+ scopes: List[TokenScope]
97
+
98
+
99
+ class SecretsClient:
100
+ """Client for the Flowbyte Secrets machine API.
101
+
102
+ ``base_url``, ``token`` and ``project`` fall back to ``FBS_URL``, ``FBS_TOKEN`` and ``FBS_PROJECT``.
103
+ Requests are retried (``max_retries``, exponential backoff) on connection errors, 5xx and 429
104
+ (honouring ``Retry-After``). Writes are only retried when the request cannot have been applied
105
+ (connection refused, 429, 503).
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ base_url: Optional[str] = None,
111
+ token: Optional[str] = None,
112
+ project: Optional[str] = None,
113
+ *,
114
+ timeout: float = 10.0,
115
+ max_retries: int = 3,
116
+ backoff_factor: float = 0.5,
117
+ max_retry_delay: float = 30.0,
118
+ transport: Optional[httpx.BaseTransport] = None,
119
+ http_client: Optional[httpx.Client] = None,
120
+ ) -> None:
121
+ base_url = (base_url or os.environ.get(ENV_URL) or "").strip()
122
+ token = (token or os.environ.get(ENV_TOKEN) or "").strip()
123
+ project = (project or os.environ.get(ENV_PROJECT) or "").strip() or None
124
+
125
+ if not base_url:
126
+ raise SecretsConfigError(f"base_url is required (or set {ENV_URL}).")
127
+ if not base_url.startswith(("https://", "http://")):
128
+ raise SecretsConfigError("base_url must be an absolute http(s) URL.")
129
+ if base_url.startswith("http://") and not _is_loopback(base_url):
130
+ # The token is a bearer credential: plain http is only acceptable on this machine.
131
+ raise SecretsConfigError("base_url must use https (http is only allowed for localhost).")
132
+ if not token:
133
+ raise SecretsConfigError(f"token is required (or set {ENV_TOKEN}).")
134
+ if not token.startswith("fbs_") or any(c.isspace() for c in token):
135
+ raise SecretsConfigError("token is not a Flowbyte Secrets access token (expected 'fbs_...').")
136
+
137
+ self.base_url = base_url.rstrip("/") + "/"
138
+ self.project = project
139
+ self.timeout = timeout
140
+ self.max_retries = max(0, int(max_retries))
141
+ self.backoff_factor = backoff_factor
142
+ self.max_retry_delay = max_retry_delay
143
+ self._token = token
144
+ self._sleep: Callable[[float], None] = time.sleep
145
+ self._owns_client = http_client is None
146
+ self._http = http_client or httpx.Client(timeout=timeout, transport=transport)
147
+
148
+ # ------------------------------------------------------------------ public API
149
+
150
+ def projects(self) -> List[Project]:
151
+ """Projects the token is scoped to."""
152
+ data = _json(self._request("GET", "api/v1/projects", project=None, idempotent=True))
153
+ return [
154
+ Project(id=str(p.get("id", "")), slug=p.get("slug", ""), name=p.get("name", ""), level=_level(p.get("level")))
155
+ for p in data
156
+ ]
157
+
158
+ def list(self, project: Optional[str] = None) -> List[Secret]:
159
+ """All secrets (with values) of ``project`` / the default project / the token's only project."""
160
+ project = self._project(project)
161
+ data = _json(self._request("GET", "api/v1/secrets", project=project, idempotent=True))
162
+ return [
163
+ Secret(
164
+ key=s.get("key", ""),
165
+ value=s.get("value", ""),
166
+ version=int(s.get("version") or 0),
167
+ modified_on=_parse_dt(s.get("modifiedOn")),
168
+ )
169
+ for s in data
170
+ ]
171
+
172
+ def as_dict(self, project: Optional[str] = None) -> Dict[str, str]:
173
+ """``{key: value}`` for all secrets of the project."""
174
+ return {s.key: s.value for s in self.list(project)}
175
+
176
+ def get_secret(self, key: str, project: Optional[str] = None) -> Secret:
177
+ """One secret with its version. Raises :class:`SecretNotFound`."""
178
+ self._check_key(key)
179
+ project = self._project(project)
180
+ s = _json(self._request("GET", self._secret_path(key), project=project, idempotent=True, key=key))
181
+ return Secret(key=s.get("key", key), value=s.get("value", ""), version=int(s.get("version") or 0))
182
+
183
+ def get(self, key: str, project: Optional[str] = None) -> str:
184
+ """One secret's value. Raises :class:`SecretNotFound`."""
185
+ return self.get_secret(key, project).value
186
+
187
+ def get_or_default(self, key: str, default: Optional[str] = None, project: Optional[str] = None) -> Optional[str]:
188
+ """One secret's value, or ``default`` when it does not exist."""
189
+ try:
190
+ return self.get(key, project)
191
+ except SecretNotFound:
192
+ return default
193
+
194
+ def set(self, key: str, value: str, note: Optional[str] = None, project: Optional[str] = None) -> None:
195
+ """Create or update a secret (ReadWrite token)."""
196
+ self._check_key(key)
197
+ if value is None:
198
+ raise ValueError("value is required")
199
+ body: Dict[str, Any] = {"value": value}
200
+ if note is not None:
201
+ body["note"] = note
202
+ self._request("PUT", self._secret_path(key), project=self._project(project), idempotent=False, key=key, json=body)
203
+
204
+ def delete(self, key: str, project: Optional[str] = None) -> None:
205
+ """Delete a secret (ReadWrite token). Raises :class:`SecretNotFound`."""
206
+ self._check_key(key)
207
+ self._request("DELETE", self._secret_path(key), project=self._project(project), idempotent=False, key=key)
208
+
209
+ def whoami(self) -> WhoAmI:
210
+ """Machine account, token name, company and scopes of the token."""
211
+ d = _json(self._request("GET", "api/v1/whoami", project=None, idempotent=True))
212
+ scopes: List[TokenScope] = []
213
+ for s in d.get("scopes") or []:
214
+ if isinstance(s, str):
215
+ pid, _, lvl = s.rpartition(":")
216
+ scopes.append(TokenScope(project_id=pid or s, level=lvl if pid else ""))
217
+ elif isinstance(s, dict):
218
+ scopes.append(
219
+ TokenScope(
220
+ project_id=str(s.get("projectId") or s.get("id") or ""),
221
+ level=_level(s.get("level")),
222
+ slug=s.get("slug"),
223
+ name=s.get("name"),
224
+ )
225
+ )
226
+ return WhoAmI(
227
+ machine_account=d.get("machineAccount", ""),
228
+ token_name=d.get("tokenName", ""),
229
+ company_id=d.get("companyId", ""),
230
+ scopes=scopes,
231
+ )
232
+
233
+ def load_into_env(
234
+ self,
235
+ prefix: str = "",
236
+ project: Optional[str] = None,
237
+ *,
238
+ override: bool = True,
239
+ map_dots: bool = False,
240
+ environ: Optional[MutableMapping[str, str]] = None,
241
+ ) -> List[str]:
242
+ """Inject all secrets into ``os.environ`` (or ``environ``). Returns the variable names set."""
243
+ from .env import load_into_env
244
+
245
+ return load_into_env(self.list(project), prefix=prefix, override=override, map_dots=map_dots, environ=environ)
246
+
247
+ def close(self) -> None:
248
+ if self._owns_client:
249
+ self._http.close()
250
+
251
+ def __enter__(self) -> "SecretsClient":
252
+ return self
253
+
254
+ def __exit__(self, *exc: Any) -> None:
255
+ self.close()
256
+
257
+ def __repr__(self) -> str:
258
+ return f"SecretsClient(base_url={self.base_url!r}, project={self.project!r}, token=<redacted>)"
259
+
260
+ # ------------------------------------------------------------------ internals
261
+
262
+ def _project(self, project: Optional[str]) -> Optional[str]:
263
+ return (project or "").strip() or self.project
264
+
265
+ @staticmethod
266
+ def _check_key(key: str) -> None:
267
+ if not key or not key.strip():
268
+ raise ValueError("key is required")
269
+
270
+ @staticmethod
271
+ def _secret_path(key: str) -> str:
272
+ return "api/v1/secrets/" + quote(key, safe="")
273
+
274
+ def _backoff(self, attempt: int) -> float:
275
+ return min(self.max_retry_delay, self.backoff_factor * (2**attempt) + random.uniform(0, 0.1))
276
+
277
+ @staticmethod
278
+ def _retry_after(response: httpx.Response) -> Optional[float]:
279
+ value = response.headers.get("Retry-After")
280
+ if not value:
281
+ return None
282
+ try:
283
+ return max(0.0, float(value))
284
+ except ValueError:
285
+ pass
286
+ try:
287
+ when = parsedate_to_datetime(value)
288
+ return max(0.0, when.timestamp() - time.time())
289
+ except (TypeError, ValueError):
290
+ return None
291
+
292
+ def _request(
293
+ self,
294
+ method: str,
295
+ path: str,
296
+ *,
297
+ project: Optional[str],
298
+ idempotent: bool,
299
+ key: Optional[str] = None,
300
+ json: Optional[Dict[str, Any]] = None,
301
+ ) -> httpx.Response:
302
+ url = self.base_url + path
303
+ params = {"project": project} if project else None
304
+ headers = {
305
+ "Authorization": f"Bearer {self._token}",
306
+ "Accept": "application/json",
307
+ "User-Agent": USER_AGENT,
308
+ }
309
+
310
+ attempt = 0
311
+ while True:
312
+ try:
313
+ response = self._http.request(method, url, params=params, headers=headers, json=json, timeout=self.timeout)
314
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
315
+ # Request never reached the server: safe to retry for any method.
316
+ if attempt < self.max_retries:
317
+ self._sleep(self._backoff(attempt))
318
+ attempt += 1
319
+ continue
320
+ raise SecretsConnectionError(f"Could not reach {self.base_url}: {type(exc).__name__}") from None
321
+ except httpx.TransportError as exc: # read timeouts, protocol errors, ...
322
+ if idempotent and attempt < self.max_retries:
323
+ self._sleep(self._backoff(attempt))
324
+ attempt += 1
325
+ continue
326
+ raise SecretsConnectionError(f"Request to {self.base_url} failed: {type(exc).__name__}") from None
327
+
328
+ status = response.status_code
329
+ retryable = status in (429, 503) or (idempotent and status >= 500)
330
+ if retryable and attempt < self.max_retries:
331
+ delay = self._retry_after(response) if status in (429, 503) else None
332
+ self._sleep(min(self.max_retry_delay, delay if delay is not None else self._backoff(attempt)))
333
+ attempt += 1
334
+ continue
335
+
336
+ if status < 400:
337
+ return response
338
+ self._raise_for_status(response, key, project)
339
+
340
+ @staticmethod
341
+ def _raise_for_status(response: httpx.Response, key: Optional[str], project: Optional[str]) -> None:
342
+ status = response.status_code
343
+ where = f"project '{project}'" if project else "the token's project"
344
+ if status == 401:
345
+ raise SecretsAuthError("Unauthorized (401): the access token is invalid, expired or revoked.", 401)
346
+ if status == 403:
347
+ raise SecretsForbiddenError(f"Forbidden (403): the token is not allowed to perform this operation on {where}.", 403)
348
+ if status == 404:
349
+ if key is None:
350
+ raise SecretNotFound(f"Not found (404): {where} does not exist or is not in the token's scope.")
351
+ raise SecretNotFound(f"Not found (404): secret '{key}' does not exist in {where}.", key=key)
352
+ if status == 429:
353
+ raise SecretsRateLimitError("Rate limited (429): too many requests; retries exhausted.",
354
+ SecretsClient._retry_after(response))
355
+ # Only the ProblemDetails title is used: the body/detail could echo a submitted value.
356
+ title = None
357
+ try:
358
+ body = response.json()
359
+ if isinstance(body, dict) and isinstance(body.get("title"), str):
360
+ title = body["title"][:200]
361
+ except ValueError:
362
+ pass
363
+ raise SecretsError(f"The Flowbyte Secrets server returned {status} ({title or response.reason_phrase}).", status)
@@ -0,0 +1,73 @@
1
+ """Dagster resource (requires ``pip install flowbyte-secrets[dagster]``).
2
+
3
+ .. code-block:: python
4
+
5
+ from dagster import Definitions, EnvVar, asset
6
+ from flowbyte_secrets.dagster import FlowbyteSecretsResource
7
+
8
+ @asset
9
+ def my_asset(secrets: FlowbyteSecretsResource):
10
+ pw = secrets.get("DB_PASSWORD")
11
+
12
+ defs = Definitions(
13
+ assets=[my_asset],
14
+ resources={"secrets": FlowbyteSecretsResource(base_url=EnvVar("FBS_URL"), token=EnvVar("FBS_TOKEN"), project="dagster-prod")},
15
+ )
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Dict, List, Optional
21
+
22
+ try:
23
+ from dagster import ConfigurableResource
24
+ from pydantic import Field, PrivateAttr
25
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
26
+ raise ImportError(
27
+ "flowbyte_secrets.dagster requires Dagster. Install with: pip install 'flowbyte-secrets[dagster]'"
28
+ ) from exc
29
+
30
+ from .client import Secret, SecretsClient
31
+
32
+
33
+ class FlowbyteSecretsResource(ConfigurableResource):
34
+ """Reads secrets from Flowbyte Secrets with a machine access token."""
35
+
36
+ base_url: str = Field(description="Server URL, e.g. https://secrets.gmrlapp.com (use EnvVar('FBS_URL')).")
37
+ token: str = Field(description="Access token fbs_... (use EnvVar('FBS_TOKEN'); never hard-code).")
38
+ project: Optional[str] = Field(default=None, description="Project slug or id; optional for single-project tokens.")
39
+ timeout: float = Field(default=10.0, description="HTTP timeout in seconds.")
40
+ max_retries: int = Field(default=3, description="Retries on connection errors, 5xx and 429.")
41
+
42
+ _client: Optional[SecretsClient] = PrivateAttr(default=None)
43
+
44
+ def _get_client(self) -> SecretsClient:
45
+ if self._client is None:
46
+ self._client = SecretsClient(
47
+ base_url=self.base_url,
48
+ token=self.token,
49
+ project=self.project,
50
+ timeout=self.timeout,
51
+ max_retries=self.max_retries,
52
+ )
53
+ return self._client
54
+
55
+ def teardown_after_execution(self, context) -> None: # type: ignore[override]
56
+ if self._client is not None:
57
+ self._client.close()
58
+ self._client = None
59
+
60
+ def get(self, key: str, project: Optional[str] = None) -> str:
61
+ return self._get_client().get(key, project)
62
+
63
+ def get_or_default(self, key: str, default: Optional[str] = None, project: Optional[str] = None) -> Optional[str]:
64
+ return self._get_client().get_or_default(key, default, project)
65
+
66
+ def list(self, project: Optional[str] = None) -> List[Secret]:
67
+ return self._get_client().list(project)
68
+
69
+ def as_dict(self, project: Optional[str] = None) -> Dict[str, str]:
70
+ return self._get_client().as_dict(project)
71
+
72
+ def load_into_env(self, prefix: str = "", project: Optional[str] = None, *, override: bool = True, map_dots: bool = False) -> List[str]:
73
+ return self._get_client().load_into_env(prefix, project, override=override, map_dots=map_dots)
@@ -0,0 +1,58 @@
1
+ """Environment-variable helpers (same mapping rules as ``fbs run``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import warnings
8
+ from typing import Dict, Iterable, List, MutableMapping, Optional
9
+
10
+ from .client import Secret
11
+
12
+ _INVALID = re.compile(r"[^A-Za-z0-9_]")
13
+
14
+
15
+ def to_env_name(key: str, prefix: str = "", map_dots: bool = False) -> str:
16
+ """Map a secret key to an environment variable name.
17
+
18
+ With ``map_dots``, ``.`` becomes ``__`` (``Database.Password`` -> ``Database__Password``);
19
+ every other character outside ``[A-Za-z0-9_]`` (including ``.`` and ``-``) becomes ``_``.
20
+ ``prefix`` is prepended verbatim; case is preserved.
21
+ """
22
+ name = key.replace(".", "__") if map_dots else key
23
+ name = (prefix or "") + _INVALID.sub("_", name)
24
+ if name[:1].isdigit():
25
+ name = "_" + name
26
+ return name
27
+
28
+
29
+ def to_env(secrets: Iterable[Secret], prefix: str = "", map_dots: bool = False) -> Dict[str, str]:
30
+ """``{ENV_NAME: value}``; on name collisions the later key wins (a warning names the keys, never values)."""
31
+ result: Dict[str, str] = {}
32
+ origin: Dict[str, str] = {}
33
+ for s in secrets:
34
+ name = to_env_name(s.key, prefix, map_dots)
35
+ if name in origin:
36
+ warnings.warn(f"secret keys '{origin[name]}' and '{s.key}' both map to {name}; using '{s.key}'", stacklevel=2)
37
+ result[name] = s.value
38
+ origin[name] = s.key
39
+ return result
40
+
41
+
42
+ def load_into_env(
43
+ secrets: Iterable[Secret],
44
+ prefix: str = "",
45
+ *,
46
+ override: bool = True,
47
+ map_dots: bool = False,
48
+ environ: Optional[MutableMapping[str, str]] = None,
49
+ ) -> List[str]:
50
+ """Set the secrets in ``os.environ`` (or ``environ``). Returns the names that were set."""
51
+ target = os.environ if environ is None else environ
52
+ names: List[str] = []
53
+ for name, value in to_env(secrets, prefix, map_dots).items():
54
+ if not override and name in target:
55
+ continue
56
+ target[name] = value
57
+ names.append(name)
58
+ return names
@@ -0,0 +1,45 @@
1
+ """Typed errors. Messages never contain secret values or the access token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class SecretsError(Exception):
9
+ """Base class for Flowbyte Secrets errors."""
10
+
11
+ def __init__(self, message: str, status_code: Optional[int] = None) -> None:
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+
15
+
16
+ class SecretsConfigError(SecretsError):
17
+ """Missing or malformed client settings (base URL, token)."""
18
+
19
+
20
+ class SecretsConnectionError(SecretsError):
21
+ """The server could not be reached (after retries) or timed out."""
22
+
23
+
24
+ class SecretsAuthError(SecretsError):
25
+ """401 (bad/expired/revoked token) or 403 (token not scoped for this project/operation)."""
26
+
27
+
28
+ class SecretsForbiddenError(SecretsAuthError):
29
+ """403: the token is valid but lacks the scope (e.g. a Read token writing)."""
30
+
31
+
32
+ class SecretNotFound(SecretsError):
33
+ """404: the secret or project does not exist (or is outside the token's scope)."""
34
+
35
+ def __init__(self, message: str, key: Optional[str] = None) -> None:
36
+ super().__init__(message, 404)
37
+ self.key = key
38
+
39
+
40
+ class SecretsRateLimitError(SecretsError):
41
+ """429 after retries were exhausted."""
42
+
43
+ def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
44
+ super().__init__(message, 429)
45
+ self.retry_after = retry_after
@@ -0,0 +1,254 @@
1
+ import json
2
+ import os
3
+
4
+ import httpx
5
+ import pytest
6
+ import respx
7
+
8
+ from flowbyte_secrets import (
9
+ SecretNotFound,
10
+ SecretsAuthError,
11
+ SecretsClient,
12
+ SecretsConfigError,
13
+ SecretsConnectionError,
14
+ SecretsError,
15
+ SecretsForbiddenError,
16
+ SecretsRateLimitError,
17
+ __version__,
18
+ load_into_env,
19
+ to_env_name,
20
+ )
21
+ from flowbyte_secrets.client import Secret
22
+
23
+ BASE = "https://secrets.test"
24
+ TOKEN = "fbs_" + "A" * 43
25
+ VALUE = "s3cr3t-never-in-messages"
26
+
27
+
28
+ @pytest.fixture
29
+ def client():
30
+ c = SecretsClient(base_url=BASE, token=TOKEN, project="demo", max_retries=3)
31
+ c._sleep = lambda _s: None # no real backoff in tests
32
+ yield c
33
+ c.close()
34
+
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def _clean_env(monkeypatch):
38
+ for name in ("FBS_URL", "FBS_TOKEN", "FBS_PROJECT"):
39
+ monkeypatch.delenv(name, raising=False)
40
+
41
+
42
+ @respx.mock
43
+ def test_list_sends_auth_user_agent_and_project(client):
44
+ route = respx.get(f"{BASE}/api/v1/secrets").mock(
45
+ return_value=httpx.Response(200, json=[
46
+ {"key": "DB_PASSWORD", "value": "pw", "version": 3, "modifiedOn": "2026-01-02T03:04:05Z"},
47
+ {"key": "Api__Key", "value": "k", "version": 1, "modifiedOn": None},
48
+ ])
49
+ )
50
+ items = client.list()
51
+ assert [s.key for s in items] == ["DB_PASSWORD", "Api__Key"]
52
+ assert items[0].value == "pw" and items[0].version == 3
53
+ assert items[0].modified_on.year == 2026
54
+ req = route.calls.last.request
55
+ assert req.headers["Authorization"] == f"Bearer {TOKEN}"
56
+ assert req.headers["User-Agent"] == f"flowbyte-secrets-python/{__version__}"
57
+ assert req.url.params["project"] == "demo"
58
+
59
+
60
+ @respx.mock
61
+ def test_get_escapes_key_and_returns_value(client):
62
+ route = respx.get(f"{BASE}/api/v1/secrets/a%2Fb%20c").mock(
63
+ return_value=httpx.Response(200, json={"key": "a/b c", "value": "v", "version": 2})
64
+ )
65
+ assert client.get("a/b c") == "v"
66
+ assert route.called
67
+
68
+
69
+ @respx.mock
70
+ def test_set_and_delete(client):
71
+ put = respx.put(f"{BASE}/api/v1/secrets/K").mock(return_value=httpx.Response(204))
72
+ delete = respx.delete(f"{BASE}/api/v1/secrets/K").mock(return_value=httpx.Response(204))
73
+ client.set("K", "v1", note="n")
74
+ client.set("K", "v2")
75
+ client.delete("K")
76
+ assert json.loads(put.calls[0].request.content) == {"value": "v1", "note": "n"}
77
+ assert json.loads(put.calls[1].request.content) == {"value": "v2"}
78
+ assert delete.called
79
+
80
+
81
+ @respx.mock
82
+ def test_401_and_403_raise_auth_errors_without_secrets(client):
83
+ respx.get(f"{BASE}/api/v1/secrets").mock(return_value=httpx.Response(401))
84
+ respx.put(f"{BASE}/api/v1/secrets/K").mock(return_value=httpx.Response(403))
85
+ with pytest.raises(SecretsAuthError) as e401:
86
+ client.list()
87
+ assert e401.value.status_code == 401
88
+ assert TOKEN not in str(e401.value)
89
+ with pytest.raises(SecretsForbiddenError) as e403:
90
+ client.set("K", VALUE)
91
+ assert isinstance(e403.value, SecretsAuthError)
92
+ assert VALUE not in str(e403.value)
93
+
94
+
95
+ @respx.mock
96
+ def test_404_raises_not_found_and_get_or_default(client):
97
+ respx.get(f"{BASE}/api/v1/secrets/MISSING").mock(return_value=httpx.Response(404))
98
+ with pytest.raises(SecretNotFound) as exc:
99
+ client.get("MISSING")
100
+ assert exc.value.key == "MISSING"
101
+ assert client.get_or_default("MISSING", "fallback") == "fallback"
102
+
103
+
104
+ @respx.mock
105
+ def test_5xx_retried_then_succeeds(client):
106
+ route = respx.get(f"{BASE}/api/v1/secrets").mock(
107
+ side_effect=[httpx.Response(502), httpx.Response(500), httpx.Response(200, json=[])]
108
+ )
109
+ assert client.list() == []
110
+ assert route.call_count == 3
111
+
112
+
113
+ @respx.mock
114
+ def test_5xx_exhausted_raises_without_body(client):
115
+ route = respx.get(f"{BASE}/api/v1/secrets").mock(
116
+ return_value=httpx.Response(500, json={"title": "Boom", "detail": f"value {VALUE}"})
117
+ )
118
+ with pytest.raises(SecretsError) as exc:
119
+ client.list()
120
+ assert route.call_count == 4 # 1 + 3 retries
121
+ assert "Boom" in str(exc.value)
122
+ assert VALUE not in str(exc.value)
123
+
124
+
125
+ @respx.mock
126
+ def test_writes_not_retried_on_500(client):
127
+ route = respx.put(f"{BASE}/api/v1/secrets/K").mock(return_value=httpx.Response(500))
128
+ with pytest.raises(SecretsError):
129
+ client.set("K", "v")
130
+ assert route.call_count == 1
131
+
132
+
133
+ @respx.mock
134
+ def test_connection_errors_retried_then_raise(client):
135
+ route = respx.get(f"{BASE}/api/v1/secrets").mock(side_effect=httpx.ConnectError("refused"))
136
+ with pytest.raises(SecretsConnectionError):
137
+ client.list()
138
+ assert route.call_count == 4
139
+
140
+
141
+ @respx.mock
142
+ def test_429_honours_retry_after(client):
143
+ sleeps = []
144
+ client._sleep = sleeps.append
145
+ respx.get(f"{BASE}/api/v1/secrets").mock(
146
+ side_effect=[httpx.Response(429, headers={"Retry-After": "7"}), httpx.Response(200, json=[])]
147
+ )
148
+ client.list()
149
+ assert sleeps == [7.0]
150
+
151
+
152
+ @respx.mock
153
+ def test_429_exhausted(client):
154
+ respx.get(f"{BASE}/api/v1/secrets").mock(return_value=httpx.Response(429, headers={"Retry-After": "1"}))
155
+ with pytest.raises(SecretsRateLimitError) as exc:
156
+ client.list()
157
+ assert exc.value.retry_after == 1.0
158
+
159
+
160
+ @respx.mock
161
+ def test_unparseable_body_does_not_leak(client):
162
+ respx.get(f"{BASE}/api/v1/secrets").mock(return_value=httpx.Response(200, text=f'[{{"value":"{VALUE}"'))
163
+ with pytest.raises(SecretsError) as exc:
164
+ client.list()
165
+ assert VALUE not in str(exc.value)
166
+
167
+
168
+ @respx.mock
169
+ def test_whoami_and_projects(client):
170
+ respx.get(f"{BASE}/api/v1/whoami").mock(return_value=httpx.Response(200, json={
171
+ "machineAccount": "dagster", "tokenName": "prod", "companyId": "GMRL",
172
+ "scopes": ["p1:Read", {"projectId": "p2", "slug": "demo", "level": 2}],
173
+ }))
174
+ respx.get(f"{BASE}/api/v1/projects").mock(return_value=httpx.Response(200, json=[
175
+ {"id": "p2", "slug": "demo", "name": "Demo", "level": "ReadWrite"},
176
+ ]))
177
+ me = client.whoami()
178
+ assert me.company_id == "GMRL"
179
+ assert me.scopes[0].project_id == "p1" and me.scopes[0].level == "Read"
180
+ assert me.scopes[1].slug == "demo" and me.scopes[1].level == "ReadWrite"
181
+ assert client.projects()[0].can_write
182
+
183
+
184
+ @respx.mock
185
+ def test_env_fallbacks(monkeypatch):
186
+ monkeypatch.setenv("FBS_URL", "https://env.test/")
187
+ monkeypatch.setenv("FBS_TOKEN", TOKEN)
188
+ monkeypatch.setenv("FBS_PROJECT", "env-proj")
189
+ route = respx.get("https://env.test/api/v1/secrets").mock(return_value=httpx.Response(200, json=[]))
190
+ with SecretsClient() as c:
191
+ c.list()
192
+ assert route.calls.last.request.url.params["project"] == "env-proj"
193
+
194
+
195
+ @respx.mock
196
+ def test_no_project_omits_query():
197
+ route = respx.get(f"{BASE}/api/v1/secrets").mock(return_value=httpx.Response(200, json=[]))
198
+ with SecretsClient(base_url=BASE, token=TOKEN) as c:
199
+ c.list()
200
+ assert "project" not in route.calls.last.request.url.params
201
+
202
+
203
+ def test_config_errors_do_not_echo_token():
204
+ with pytest.raises(SecretsConfigError):
205
+ SecretsClient(base_url=BASE)
206
+ with pytest.raises(SecretsConfigError) as exc:
207
+ SecretsClient(base_url=BASE, token="ghp_wrongtoken123")
208
+ assert "ghp_wrongtoken123" not in str(exc.value)
209
+ with pytest.raises(SecretsConfigError):
210
+ SecretsClient(base_url="ftp://x", token=TOKEN)
211
+
212
+
213
+ def test_repr_hides_token_and_values():
214
+ c = SecretsClient(base_url=BASE, token=TOKEN)
215
+ assert TOKEN not in repr(c)
216
+ assert VALUE not in repr(Secret(key="K", value=VALUE))
217
+ c.close()
218
+
219
+
220
+ @respx.mock
221
+ def test_load_into_env(client):
222
+ respx.get(f"{BASE}/api/v1/secrets").mock(return_value=httpx.Response(200, json=[
223
+ {"key": "DB_PASSWORD", "value": "pw", "version": 1},
224
+ {"key": "api.key", "value": "k", "version": 1},
225
+ ]))
226
+ env = {"DB_PASSWORD": "existing"}
227
+ names = client.load_into_env(prefix="APP_", environ=env)
228
+ assert sorted(names) == ["APP_DB_PASSWORD", "APP_api_key"]
229
+ assert env["APP_DB_PASSWORD"] == "pw"
230
+
231
+ env2 = {"DB_PASSWORD": "existing"}
232
+ load_into_env([Secret("DB_PASSWORD", "pw")], override=False, environ=env2)
233
+ assert env2["DB_PASSWORD"] == "existing"
234
+
235
+
236
+ @pytest.mark.parametrize(
237
+ "key,prefix,map_dots,expected",
238
+ [
239
+ ("DB_PASSWORD", "", False, "DB_PASSWORD"),
240
+ ("Database__Password", "", False, "Database__Password"),
241
+ ("db.password", "", False, "db_password"),
242
+ ("db.password", "", True, "db__password"),
243
+ ("api-key", "APP_", False, "APP_api_key"),
244
+ ],
245
+ )
246
+ def test_to_env_name(key, prefix, map_dots, expected):
247
+ assert to_env_name(key, prefix, map_dots) == expected
248
+
249
+
250
+ def test_load_into_env_defaults_to_os_environ(monkeypatch):
251
+ monkeypatch.delenv("FBS_TEST_X", raising=False)
252
+ load_into_env([Secret("FBS_TEST_X", "1")])
253
+ assert os.environ["FBS_TEST_X"] == "1"
254
+ monkeypatch.delenv("FBS_TEST_X")
@@ -0,0 +1,41 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ pytest.importorskip("dagster", reason="install flowbyte-secrets[dagster] to run the Dagster resource tests")
6
+
7
+ from dagster import Definitions, asset, materialize # noqa: E402
8
+
9
+ from flowbyte_secrets.dagster import FlowbyteSecretsResource # noqa: E402
10
+
11
+ BASE = "https://secrets.test"
12
+ TOKEN = "fbs_" + "A" * 43
13
+
14
+
15
+ @respx.mock
16
+ def test_resource_get_inside_asset():
17
+ respx.get(f"{BASE}/api/v1/secrets/DB_PASSWORD").mock(
18
+ return_value=httpx.Response(200, json={"key": "DB_PASSWORD", "value": "pw", "version": 1})
19
+ )
20
+ seen = {}
21
+
22
+ @asset
23
+ def my_asset(secrets: FlowbyteSecretsResource):
24
+ seen["pw"] = secrets.get("DB_PASSWORD")
25
+
26
+ result = materialize(
27
+ [my_asset],
28
+ resources={"secrets": FlowbyteSecretsResource(base_url=BASE, token=TOKEN, project="demo")},
29
+ )
30
+ assert result.success
31
+ assert seen["pw"] == "pw"
32
+
33
+
34
+ @respx.mock
35
+ def test_resource_direct_use():
36
+ respx.get(f"{BASE}/api/v1/secrets").mock(
37
+ return_value=httpx.Response(200, json=[{"key": "A", "value": "1", "version": 1}])
38
+ )
39
+ r = FlowbyteSecretsResource(base_url=BASE, token=TOKEN)
40
+ assert r.as_dict() == {"A": "1"}
41
+ Definitions(resources={"secrets": r}) # resource is accepted by Definitions
@@ -0,0 +1,17 @@
1
+ import pytest
2
+
3
+ from flowbyte_secrets import SecretsClient
4
+ from flowbyte_secrets.errors import SecretsConfigError
5
+
6
+ TOKEN = "fbs_" + "A" * 43
7
+
8
+
9
+ @pytest.mark.parametrize("url", ["http://secrets.example.com", "http://10.0.0.5:7240"])
10
+ def test_plain_http_to_remote_host_is_refused(url):
11
+ with pytest.raises(SecretsConfigError, match="https"):
12
+ SecretsClient(base_url=url, token=TOKEN)
13
+
14
+
15
+ @pytest.mark.parametrize("url", ["https://secrets.example.com", "http://localhost:7240", "http://127.0.0.1:7240"])
16
+ def test_https_and_loopback_http_are_allowed(url):
17
+ SecretsClient(base_url=url, token=TOKEN).close()