secryn 1.0.0__py3-none-any.whl
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.
- secryn/__init__.py +7 -0
- secryn/client.py +424 -0
- secryn/errors.py +32 -0
- secryn/tests/__init__.py +0 -0
- secryn/tests/test_client.py +1170 -0
- secryn-1.0.0.dist-info/METADATA +13 -0
- secryn-1.0.0.dist-info/RECORD +9 -0
- secryn-1.0.0.dist-info/WHEEL +5 -0
- secryn-1.0.0.dist-info/top_level.txt +1 -0
secryn/__init__.py
ADDED
secryn/client.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Secryn API client — full coverage of the Secryn REST API.
|
|
2
|
+
|
|
3
|
+
Supports both cookie-based authentication (after login) and API-key
|
|
4
|
+
authentication for programmatic access.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .errors import SecrynApiError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _RequestMixin:
|
|
16
|
+
"""Internal mixin that provides the low-level HTTP transport."""
|
|
17
|
+
|
|
18
|
+
def _url(self, path: str) -> str:
|
|
19
|
+
"""Build an absolute URL by joining the base URL with a relative path.
|
|
20
|
+
|
|
21
|
+
Normalises the base URL to always end with a single ``/`` and strips
|
|
22
|
+
any leading ``/`` from the path so that :func:`urljoin` produces a
|
|
23
|
+
predictable result regardless of caller formatting.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
path: Relative API path (e.g. ``/projects/1``).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Fully-qualified URL string.
|
|
30
|
+
"""
|
|
31
|
+
base = self.base_url.rstrip("/") + "/" # type: ignore[attr-defined]
|
|
32
|
+
return urljoin(base, path.lstrip("/"))
|
|
33
|
+
|
|
34
|
+
def _request(
|
|
35
|
+
self,
|
|
36
|
+
method: str,
|
|
37
|
+
path: str,
|
|
38
|
+
body: Optional[dict] = None,
|
|
39
|
+
raw: bool = False,
|
|
40
|
+
) -> Any:
|
|
41
|
+
"""Execute an HTTP request against the Secryn API and handle the response.
|
|
42
|
+
|
|
43
|
+
Decision order for response handling:
|
|
44
|
+
1. If ``raw=True``, return the response body as plain text (still
|
|
45
|
+
raises on >=400).
|
|
46
|
+
2. If the status is 204 No Content or the body is empty, return
|
|
47
|
+
``None`` (but still raise on >=400 with a best-effort message).
|
|
48
|
+
3. Attempt to parse the body as JSON; if parsing fails and the status
|
|
49
|
+
is >=400, raise with the raw text. On success with a non-JSON body,
|
|
50
|
+
return the plain text.
|
|
51
|
+
4. If status is >=400 and JSON was parsed, raise
|
|
52
|
+
:exc:`SecrynApiError` using the structured error fields from the
|
|
53
|
+
API response (``message``, ``code``, ``details``).
|
|
54
|
+
5. Otherwise return the parsed JSON data.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
method: HTTP method (``GET``, ``POST``, ``PUT``, ``DELETE``).
|
|
58
|
+
path: Relative API path.
|
|
59
|
+
body: Optional JSON-serializable request body.
|
|
60
|
+
raw: If ``True``, return the response text directly instead of
|
|
61
|
+
attempting JSON parsing.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Parsed JSON data, plain text (when ``raw``), or ``None`` for
|
|
65
|
+
empty/204 responses.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
SecrynApiError: When the API responds with a status >=400.
|
|
69
|
+
"""
|
|
70
|
+
url = self._url(path)
|
|
71
|
+
kwargs: dict = {}
|
|
72
|
+
if body is not None:
|
|
73
|
+
kwargs["json"] = body
|
|
74
|
+
|
|
75
|
+
resp = self.session.request(method, url, **kwargs) # type: ignore[attr-defined]
|
|
76
|
+
|
|
77
|
+
if raw:
|
|
78
|
+
if resp.status_code >= 400:
|
|
79
|
+
self._raise_error(resp)
|
|
80
|
+
return resp.text
|
|
81
|
+
|
|
82
|
+
if resp.status_code == 204 or not resp.text.strip():
|
|
83
|
+
if resp.status_code >= 400:
|
|
84
|
+
raise SecrynApiError("Request failed", resp.status_code, str(resp.status_code))
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
data = resp.json()
|
|
89
|
+
except ValueError:
|
|
90
|
+
if resp.status_code >= 400:
|
|
91
|
+
raise SecrynApiError(resp.text, resp.status_code)
|
|
92
|
+
return resp.text
|
|
93
|
+
|
|
94
|
+
if resp.status_code >= 400:
|
|
95
|
+
raise SecrynApiError(
|
|
96
|
+
data.get("message", "Request failed"),
|
|
97
|
+
resp.status_code,
|
|
98
|
+
data.get("code", ""),
|
|
99
|
+
data.get("details"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
def _raise_error(self, resp: requests.Response) -> None:
|
|
105
|
+
"""Extract structured error information from a response and raise.
|
|
106
|
+
|
|
107
|
+
Attempts to parse the response body as JSON first; falls back to the
|
|
108
|
+
raw response text if parsing fails.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
resp: The :class:`requests.Response` object with status >=400.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
SecrynApiError: Always — this is a terminal handler for error
|
|
115
|
+
responses.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
data = resp.json()
|
|
119
|
+
raise SecrynApiError(
|
|
120
|
+
data.get("message", resp.text),
|
|
121
|
+
resp.status_code,
|
|
122
|
+
data.get("code", ""),
|
|
123
|
+
data.get("details"),
|
|
124
|
+
)
|
|
125
|
+
except (ValueError, KeyError):
|
|
126
|
+
raise SecrynApiError(resp.text, resp.status_code)
|
|
127
|
+
|
|
128
|
+
def _get(self, path: str) -> Any:
|
|
129
|
+
return self._request("GET", path)
|
|
130
|
+
|
|
131
|
+
def _post(self, path: str, body: Optional[dict] = None) -> Any:
|
|
132
|
+
return self._request("POST", path, body)
|
|
133
|
+
|
|
134
|
+
def _put(self, path: str, body: Optional[dict] = None) -> Any:
|
|
135
|
+
return self._request("PUT", path, body)
|
|
136
|
+
|
|
137
|
+
def _delete(self, path: str) -> Any:
|
|
138
|
+
return self._request("DELETE", path)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _AuthProxy:
|
|
142
|
+
"""Proxy providing auth-related API methods."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
145
|
+
self._client = client
|
|
146
|
+
|
|
147
|
+
def login(self, email: str, password: str) -> Any:
|
|
148
|
+
return self._client._post("/auth/login", {"email": email, "password": password})
|
|
149
|
+
|
|
150
|
+
def register(self, email: str, password: str, username: Optional[str] = None) -> Any:
|
|
151
|
+
body: dict = {"email": email, "password": password}
|
|
152
|
+
if username:
|
|
153
|
+
body["username"] = username
|
|
154
|
+
return self._client._post("/auth/register", body)
|
|
155
|
+
|
|
156
|
+
def logout(self) -> None:
|
|
157
|
+
"""Send a logout request and unconditionally clear the local session.
|
|
158
|
+
|
|
159
|
+
The cookie jar is cleared inside a ``finally`` block so that even if
|
|
160
|
+
the server returns an error the local session is still discarded.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
self._client._post("/auth/logout")
|
|
164
|
+
finally:
|
|
165
|
+
self._client.session.cookies.clear()
|
|
166
|
+
|
|
167
|
+
def refresh(self) -> None:
|
|
168
|
+
self._client._post("/auth/refresh")
|
|
169
|
+
|
|
170
|
+
def forgot_password(self, email: str) -> Any:
|
|
171
|
+
return self._client._post("/auth/forgot-password", {"email": email})
|
|
172
|
+
|
|
173
|
+
def reset_password(self, token: str, password: str) -> Any:
|
|
174
|
+
return self._client._post("/auth/reset-password", {"token": token, "password": password})
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _MFAProxy:
|
|
178
|
+
"""Proxy providing MFA-related API methods."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
181
|
+
self._client = client
|
|
182
|
+
|
|
183
|
+
def setup(self) -> Any:
|
|
184
|
+
return self._client._get("/auth/mfa/setup")
|
|
185
|
+
|
|
186
|
+
def enable(self, token: str) -> Any:
|
|
187
|
+
return self._client._post("/auth/mfa/enable", {"token": token})
|
|
188
|
+
|
|
189
|
+
def disable(self) -> Any:
|
|
190
|
+
return self._client._post("/auth/mfa/disable")
|
|
191
|
+
|
|
192
|
+
def confirm(self, token: str, mfa_token: str) -> Any:
|
|
193
|
+
return self._client._post("/auth/mfa/confirm", {"token": token, "mfaToken": mfa_token})
|
|
194
|
+
|
|
195
|
+
def recovery(self, code: str, mfa_token: str) -> Any:
|
|
196
|
+
return self._client._post("/auth/mfa/recovery", {"code": code, "mfaToken": mfa_token})
|
|
197
|
+
|
|
198
|
+
def recovery_codes(self) -> Any:
|
|
199
|
+
return self._client._get("/auth/mfa/recovery-codes")
|
|
200
|
+
|
|
201
|
+
def regenerate_codes(self) -> Any:
|
|
202
|
+
return self._client._post("/auth/mfa/recovery-codes/regenerate")
|
|
203
|
+
|
|
204
|
+
def send_backup_code(self, email: str) -> Any:
|
|
205
|
+
return self._client._post("/auth/mfa/send-backup-code", {"email": email})
|
|
206
|
+
|
|
207
|
+
def status(self) -> Any:
|
|
208
|
+
return self._client._get("/auth/mfa/status")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _UsersProxy:
|
|
212
|
+
"""Proxy providing user-related API methods."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
215
|
+
self._client = client
|
|
216
|
+
|
|
217
|
+
def me(self) -> Any:
|
|
218
|
+
return self._client._get("/users/@me")
|
|
219
|
+
|
|
220
|
+
def get(self, user_id: str) -> Any:
|
|
221
|
+
return self._client._get(f"/users/{user_id}")
|
|
222
|
+
|
|
223
|
+
def update(self, **kwargs: Any) -> Any:
|
|
224
|
+
return self._client._put("/users", kwargs)
|
|
225
|
+
|
|
226
|
+
def delete(self) -> None:
|
|
227
|
+
self._client._delete("/users")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _ApiKeysProxy:
|
|
231
|
+
"""Proxy providing API-key-related API methods."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
234
|
+
self._client = client
|
|
235
|
+
|
|
236
|
+
def create(self, name: str, permissions: Optional[List[str]] = None) -> Any:
|
|
237
|
+
body: dict = {"name": name}
|
|
238
|
+
if permissions:
|
|
239
|
+
body["permissions"] = permissions
|
|
240
|
+
return self._client._post("/api-keys", body)
|
|
241
|
+
|
|
242
|
+
def list(self) -> Any:
|
|
243
|
+
return self._client._get("/api-keys/@all-user")
|
|
244
|
+
|
|
245
|
+
def get(self, key_id: str) -> Any:
|
|
246
|
+
return self._client._get(f"/api-keys/{key_id}")
|
|
247
|
+
|
|
248
|
+
def update(self, key_id: str, **kwargs: Any) -> Any:
|
|
249
|
+
return self._client._put(f"/api-keys/{key_id}", kwargs)
|
|
250
|
+
|
|
251
|
+
def delete(self, key_id: str) -> None:
|
|
252
|
+
self._client._delete(f"/api-keys/{key_id}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class _ProjectsProxy:
|
|
256
|
+
"""Proxy providing project-related API methods."""
|
|
257
|
+
|
|
258
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
259
|
+
self._client = client
|
|
260
|
+
|
|
261
|
+
def create(self, name: str, description: Optional[str] = None) -> Any:
|
|
262
|
+
body: dict = {"name": name}
|
|
263
|
+
if description:
|
|
264
|
+
body["description"] = description
|
|
265
|
+
return self._client._post("/projects", body)
|
|
266
|
+
|
|
267
|
+
def list(self) -> Any:
|
|
268
|
+
return self._client._get("/projects/@all")
|
|
269
|
+
|
|
270
|
+
def get(self, project_id: str) -> Any:
|
|
271
|
+
return self._client._get(f"/projects/{project_id}")
|
|
272
|
+
|
|
273
|
+
def update(self, project_id: str, **kwargs: Any) -> Any:
|
|
274
|
+
return self._client._put(f"/projects/{project_id}", kwargs)
|
|
275
|
+
|
|
276
|
+
def delete(self, project_id: str) -> None:
|
|
277
|
+
self._client._delete(f"/projects/{project_id}")
|
|
278
|
+
|
|
279
|
+
def transfer(self, project_id: str, new_owner_id: str) -> Any:
|
|
280
|
+
return self._client._post(
|
|
281
|
+
f"/projects/{project_id}/transfer",
|
|
282
|
+
{"newOwnerId": new_owner_id},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class _InvitesProxy:
|
|
287
|
+
"""Proxy providing project-invitation API methods."""
|
|
288
|
+
|
|
289
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
290
|
+
self._client = client
|
|
291
|
+
|
|
292
|
+
def create(self, project_id: str, email: Optional[str] = None) -> Any:
|
|
293
|
+
body: dict = {}
|
|
294
|
+
if email:
|
|
295
|
+
body["email"] = email
|
|
296
|
+
# body or None: send None (no body) when no email is provided,
|
|
297
|
+
# so the server creates an open invite that anyone can accept.
|
|
298
|
+
return self._client._post(f"/projects/{project_id}/invites", body or None)
|
|
299
|
+
|
|
300
|
+
def accept(self, slug: str) -> Any:
|
|
301
|
+
"""Accept a project invitation by its slug.
|
|
302
|
+
|
|
303
|
+
Uses ``GET`` instead of ``POST`` because the server identifies the
|
|
304
|
+
invite via a unique URL slug and does not require a request body.
|
|
305
|
+
"""
|
|
306
|
+
return self._client._get(f"/projects/invites/{slug}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class _MembersProxy:
|
|
310
|
+
"""Proxy providing project-member API methods."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
313
|
+
self._client = client
|
|
314
|
+
|
|
315
|
+
def remove(self, project_id: str, member_id: str) -> None:
|
|
316
|
+
self._client._delete(f"/projects/{project_id}/members/{member_id}")
|
|
317
|
+
|
|
318
|
+
def add_permissions(self, project_id: str, member_id: str, permissions: List[str]) -> Any:
|
|
319
|
+
return self._client._post(
|
|
320
|
+
f"/projects/{project_id}/members/{member_id}/permissions",
|
|
321
|
+
{"permissions": permissions},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def remove_permissions(self, project_id: str, member_id: str, permissions: List[str]) -> Any:
|
|
325
|
+
# Uses raw _request instead of _delete because the DELETE endpoint
|
|
326
|
+
# accepts a JSON body listing the permissions to remove.
|
|
327
|
+
self._client._request(
|
|
328
|
+
"DELETE",
|
|
329
|
+
f"/projects/{project_id}/members/{member_id}/permissions",
|
|
330
|
+
{"permissions": permissions},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class _SecretsProxy:
|
|
335
|
+
"""Proxy providing secret-related API methods."""
|
|
336
|
+
|
|
337
|
+
def __init__(self, client: "SecrynClient") -> None:
|
|
338
|
+
self._client = client
|
|
339
|
+
|
|
340
|
+
def create(
|
|
341
|
+
self,
|
|
342
|
+
project_id: str,
|
|
343
|
+
name: str,
|
|
344
|
+
value: str,
|
|
345
|
+
notes: Optional[str] = None,
|
|
346
|
+
) -> Any:
|
|
347
|
+
body: dict = {"name": name, "value": value}
|
|
348
|
+
if notes:
|
|
349
|
+
body["notes"] = notes
|
|
350
|
+
return self._client._post(f"/projects/{project_id}/secrets", body)
|
|
351
|
+
|
|
352
|
+
def get(self, secret_id: str) -> Any:
|
|
353
|
+
return self._client._get(f"/projects/secrets/{secret_id}")
|
|
354
|
+
|
|
355
|
+
def list(self, project_id: str) -> Any:
|
|
356
|
+
return self._client._get(f"/projects/{project_id}/secrets")
|
|
357
|
+
|
|
358
|
+
def update(self, secret_id: str, **kwargs: Any) -> Any:
|
|
359
|
+
return self._client._put(f"/projects/secrets/{secret_id}", kwargs)
|
|
360
|
+
|
|
361
|
+
def delete(self, secret_id: str) -> None:
|
|
362
|
+
self._client._delete(f"/projects/secrets/{secret_id}")
|
|
363
|
+
|
|
364
|
+
def export_dotenv(self, project_id: str) -> str:
|
|
365
|
+
"""Export project secrets as a dotenv-formatted string.
|
|
366
|
+
|
|
367
|
+
Uses raw mode to prevent JSON parsing of the ``.env`` payload.
|
|
368
|
+
Falls back to an empty string when the response body is ``None``
|
|
369
|
+
(e.g. a 204 No Content from an empty project).
|
|
370
|
+
"""
|
|
371
|
+
result = self._client._request("GET", f"/projects/{project_id}/secrets/export", raw=True)
|
|
372
|
+
return result if result is not None else ""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class SecrynClient(_RequestMixin):
|
|
376
|
+
"""HTTP client for the full Secryn REST API.
|
|
377
|
+
|
|
378
|
+
Supports both cookie-based authentication (via ``auth.login``) and
|
|
379
|
+
API-key authentication (pass ``api_key`` to the constructor).
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
base_url: Base URL of the Secryn API including the ``/api/v1`` prefix.
|
|
383
|
+
api_key: Optional API key for programmatic access.
|
|
384
|
+
|
|
385
|
+
Example:
|
|
386
|
+
|
|
387
|
+
# Cookie-based
|
|
388
|
+
client = SecrynClient()
|
|
389
|
+
client.auth.login("user@example.com", "password")
|
|
390
|
+
secrets = client.secrets.list("project-id")
|
|
391
|
+
|
|
392
|
+
# API-key-based
|
|
393
|
+
client = SecrynClient(api_key="sk-...")
|
|
394
|
+
secret = client.secrets.get("secret-id")
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
def __init__(
|
|
398
|
+
self,
|
|
399
|
+
base_url: str = "http://localhost:3000/api/v1",
|
|
400
|
+
api_key: Optional[str] = None,
|
|
401
|
+
user_agent: str = "secryn-sdk-python/1.0.0",
|
|
402
|
+
) -> None:
|
|
403
|
+
self.base_url = base_url
|
|
404
|
+
self.api_key = api_key
|
|
405
|
+
self.session = requests.Session()
|
|
406
|
+
self.session.headers.update(
|
|
407
|
+
{
|
|
408
|
+
"Content-Type": "application/json",
|
|
409
|
+
"Accept": "application/json",
|
|
410
|
+
"User-Agent": user_agent,
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
if api_key:
|
|
414
|
+
self.session.headers["api-key"] = api_key
|
|
415
|
+
|
|
416
|
+
# Proxies for API resource groups
|
|
417
|
+
self.auth = _AuthProxy(self)
|
|
418
|
+
self.mfa = _MFAProxy(self)
|
|
419
|
+
self.users = _UsersProxy(self)
|
|
420
|
+
self.api_keys = _ApiKeysProxy(self)
|
|
421
|
+
self.projects = _ProjectsProxy(self)
|
|
422
|
+
self.invites = _InvitesProxy(self)
|
|
423
|
+
self.members = _MembersProxy(self)
|
|
424
|
+
self.secrets = _SecretsProxy(self)
|
secryn/errors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Error types for the Secryn Python SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SecrynApiError(Exception):
|
|
7
|
+
"""Raised when the Secryn API returns a non-2xx status code.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
status_code: HTTP status code returned by the server.
|
|
11
|
+
message: Human-readable error description.
|
|
12
|
+
code: Machine-readable error identifier.
|
|
13
|
+
details: Optional structured context (e.g. validation errors).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
status_code: int,
|
|
20
|
+
code: str = "UNKNOWN",
|
|
21
|
+
details: Any = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.message = message
|
|
25
|
+
self.code = code
|
|
26
|
+
self.details = details
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
if self.details:
|
|
31
|
+
return f"{self.message} ({self.code}): {self.details}"
|
|
32
|
+
return f"{self.message} ({self.code})"
|
secryn/tests/__init__.py
ADDED
|
File without changes
|