provablyfine-client 0.3.0__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,15 @@
1
+ **/__pycache__/*
2
+ **/*.egg-info/*
3
+ **/*.swp
4
+ **/*.db
5
+ **/*.json
6
+ **/*.key
7
+ **/*.pub
8
+ **/*.orig
9
+ **/*.rej
10
+ **/*.patch
11
+ **/*.swo
12
+ .coverage*
13
+ uv.lock
14
+ site
15
+ .idea
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026, Mathieu Lacage
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ software and associated documentation files (the "Software"), to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy, modify,
8
+ merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all copies
13
+ or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
16
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
17
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
18
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
19
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
20
+ OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: provablyfine-client
3
+ Version: 0.3.0
4
+ Summary: HTTP client library for the ProvablyFine API
5
+ Project-URL: Documentation, https://docs.provablyfine.net
6
+ Project-URL: Repository, https://github.com/provablyfine/pf.git
7
+ Project-URL: Issues, https://github.com/provablyfine/pf/issues
8
+ Author-email: Mathieu Lacage <mathieu.lacage@cutebugs.net>
9
+ Maintainer-email: Mathieu Lacage <mathieu.lacage@cutebugs.net>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: SSH,access-control,bastion
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: Pydantic
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
17
+ Classifier: Natural Language :: English
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: http-sfv
24
+ Requires-Dist: pydantic
25
+ Requires-Dist: requests>=2.32.5
@@ -0,0 +1,11 @@
1
+ # Provably Fine, the HTTP client library
2
+
3
+ This is an HTTP client library for Provably Fine, the SSH
4
+ centralized access control package.
5
+
6
+ This library will be installed as a dependency when you install
7
+ [ProvablyFine](https://github.com/provablyfine/pf)
8
+
9
+ ## License
10
+
11
+ This software is released under the [MIT license](./LICENSE.md).
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.build.targets.wheel]
6
+ packages = ["src/provablyfine_client"]
7
+
8
+
9
+ [project]
10
+ name = "provablyfine-client"
11
+ authors = [
12
+ {name = "Mathieu Lacage", email = "mathieu.lacage@cutebugs.net"},
13
+ ]
14
+ maintainers = [
15
+ {name = "Mathieu Lacage", email = "mathieu.lacage@cutebugs.net"},
16
+ ]
17
+ description = "HTTP client library for the ProvablyFine API"
18
+ version = "0.3.0"
19
+ license = "MIT"
20
+ license-file = "LICENSE"
21
+ keywords = ["SSH", "access-control", "bastion"]
22
+ classifiers = [
23
+ "Development Status :: 3 - Alpha",
24
+ "Framework :: Pydantic",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: GNU Affero General Public License v3",
27
+ "Natural Language :: English",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Programming Language :: Python :: 3.14",
31
+ "Typing :: Typed",
32
+ ]
33
+ requires-python = ">=3.12"
34
+ dependencies = [
35
+ "http-sfv",
36
+ "pydantic",
37
+ "requests>=2.32.5",
38
+ ]
39
+
40
+ [project.urls]
41
+ Documentation = "https://docs.provablyfine.net"
42
+ Repository = "https://github.com/provablyfine/pf.git"
43
+ Issues = "https://github.com/provablyfine/pf/issues"
@@ -0,0 +1,28 @@
1
+ from . import exceptions, schemas
2
+ from .account_client import AccountClient
3
+ from .aio import AsyncAccountClient, AsyncInvitationClient, AsyncPublicClient, AsyncSessionClient
4
+ from .directory import Directory
5
+ from .http_session import HttpSession
6
+ from .http_signatures import Auth
7
+ from .invitation_client import InvitationClient
8
+ from .public_client import PublicClient
9
+ from .session_client import SessionClient
10
+ from .signer import HmacSigner, Signer
11
+
12
+ __all__ = [
13
+ "AccountClient",
14
+ "AsyncAccountClient",
15
+ "AsyncInvitationClient",
16
+ "AsyncPublicClient",
17
+ "AsyncSessionClient",
18
+ "Auth",
19
+ "Directory",
20
+ "HmacSigner",
21
+ "HttpSession",
22
+ "InvitationClient",
23
+ "PublicClient",
24
+ "SessionClient",
25
+ "Signer",
26
+ "exceptions",
27
+ "schemas",
28
+ ]
@@ -0,0 +1,9 @@
1
+ import base64
2
+
3
+
4
+ def decode(s: str) -> bytes:
5
+ return base64.urlsafe_b64decode(s + "=======")
6
+
7
+
8
+ def encode(s: bytes) -> str:
9
+ return base64.urlsafe_b64encode(s).decode().rstrip("=")
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+
5
+ from . import directory, exceptions, http_session, http_signatures, signer
6
+
7
+
8
+ class AccountClient:
9
+ """API methods that require account + session authentication."""
10
+
11
+ def __init__(
12
+ self,
13
+ session: http_session.HttpSession,
14
+ _directory: directory.Directory,
15
+ account_signer: signer.Signer,
16
+ session_signer: signer.Signer,
17
+ ) -> None:
18
+ self._session = session
19
+ self._directory = _directory
20
+ self._account_signer = account_signer
21
+ self._session_signer = session_signer
22
+
23
+ def login_http_sig(self, session_public_key: dict[str, typing.Any]) -> None:
24
+ auth = http_signatures.Auth([self._account_signer, self._session_signer])
25
+ response = self._session.post(
26
+ self._directory.login,
27
+ auth=auth,
28
+ json={"session_public_key": session_public_key},
29
+ )
30
+ if response.status_code != 204:
31
+ raise exceptions.UI(f"Unable to login successfully: {response.text}")
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import typing
5
+
6
+ from . import account_client, invitation_client, public_client, schemas, session_client, signer
7
+
8
+
9
+ class AsyncPublicClient:
10
+ def __init__(self, inner: public_client.PublicClient) -> None:
11
+ self._inner = inner
12
+ self._lock = asyncio.Lock()
13
+
14
+ async def _run(self, fn: typing.Callable[[], typing.Any]) -> typing.Any:
15
+ async with self._lock:
16
+ return await asyncio.to_thread(fn)
17
+
18
+ async def get_user_trusted_keys_public(self) -> str:
19
+ return await self._run(self._inner.get_user_trusted_keys_public)
20
+
21
+ async def get_public_auth(self, auth_name: str) -> schemas.AuthPublic:
22
+ return await self._run(lambda: self._inner.get_public_auth(auth_name))
23
+
24
+ async def list_public_auths(self) -> list[schemas.AuthPublicSummary]:
25
+ return await self._run(self._inner.list_public_auths)
26
+
27
+ async def initialize(self, account_signer: signer.Signer, account_public_key: dict[str, typing.Any]) -> None:
28
+ return await self._run(lambda: self._inner.initialize(account_signer, account_public_key))
29
+
30
+
31
+ class AsyncSessionClient:
32
+ def __init__(self, inner: session_client.SessionClient) -> None:
33
+ self._inner = inner
34
+ self._lock = asyncio.Lock()
35
+
36
+ async def _run(self, fn: typing.Callable[[], typing.Any]) -> typing.Any:
37
+ async with self._lock:
38
+ return await asyncio.to_thread(fn)
39
+
40
+ async def list_ssh_hosts(self) -> schemas.SshHostsResponse:
41
+ return await self._run(self._inner.list_ssh_hosts)
42
+
43
+ async def get_host_trusted_keys(self) -> str:
44
+ return await self._run(self._inner.get_host_trusted_keys)
45
+
46
+ async def get_user_certificate(
47
+ self,
48
+ hostname: str,
49
+ username: str,
50
+ action: str,
51
+ public_key: dict[str, typing.Any],
52
+ command: str | None = None,
53
+ ) -> schemas.SshUserCertificateResponse:
54
+ return await self._run(
55
+ lambda: self._inner.get_user_certificate(hostname, username, action, public_key, command)
56
+ )
57
+
58
+ async def sign_host_certificates(
59
+ self, public_keys: list[dict[str, typing.Any]]
60
+ ) -> schemas.SshHostCertificateResponse:
61
+ return await self._run(lambda: self._inner.sign_host_certificates(public_keys))
62
+
63
+ async def list_self_bastions(self) -> schemas.IdentitySelfBastionListResponse:
64
+ return await self._run(self._inner.list_self_bastions)
65
+
66
+ async def get_self_token(self, service: str) -> schemas.IdentitySelfTokenResponse:
67
+ return await self._run(lambda: self._inner.get_self_token(service))
68
+
69
+ async def list_tags(
70
+ self,
71
+ id: int | None = None,
72
+ name: str | None = None,
73
+ value: str | None = None,
74
+ ) -> schemas.TagsResponse:
75
+ return await self._run(lambda: self._inner.list_tags(id, name, value))
76
+
77
+ async def create_tag(self, name: str, value: str) -> schemas.Tag:
78
+ return await self._run(lambda: self._inner.create_tag(name, value))
79
+
80
+ async def delete_tag(self, id: int) -> None:
81
+ return await self._run(lambda: self._inner.delete_tag(id))
82
+
83
+ async def list_tenants(self, id: int | None = None) -> schemas.TenantsResponse:
84
+ return await self._run(lambda: self._inner.list_tenants(id))
85
+
86
+ async def get_tenant(self, id: int) -> schemas.Tenant:
87
+ return await self._run(lambda: self._inner.get_tenant(id))
88
+
89
+ async def create_tenant(self, name: str, display_name: str) -> schemas.Tenant:
90
+ return await self._run(lambda: self._inner.create_tenant(name, display_name))
91
+
92
+ async def update_tenant(
93
+ self,
94
+ id: int,
95
+ display_name: str | None = None,
96
+ is_enabled: bool | None = None,
97
+ ) -> None:
98
+ return await self._run(lambda: self._inner.update_tenant(id, display_name, is_enabled))
99
+
100
+ async def delete_tenant(self, id: int) -> None:
101
+ return await self._run(lambda: self._inner.delete_tenant(id))
102
+
103
+ async def list_auths(self) -> schemas.AuthListResponse:
104
+ return await self._run(self._inner.list_auths)
105
+
106
+ async def get_auth(self, id: int) -> schemas.Auth:
107
+ return await self._run(lambda: self._inner.get_auth(id))
108
+
109
+ async def create_auth_http_sig(self, name: str, description: str, tags: list[dict[str, str]]) -> schemas.Auth:
110
+ return await self._run(lambda: self._inner.create_auth_http_sig(name, description, tags))
111
+
112
+ async def create_auth_oidc(
113
+ self,
114
+ name: str,
115
+ description: str,
116
+ tags: list[dict[str, str]],
117
+ issuer: str,
118
+ client_id: str,
119
+ client_secret: str | None,
120
+ ) -> schemas.Auth:
121
+ return await self._run(
122
+ lambda: self._inner.create_auth_oidc(name, description, tags, issuer, client_id, client_secret)
123
+ )
124
+
125
+ async def create_auth_oauth2_github(
126
+ self,
127
+ name: str,
128
+ description: str,
129
+ tags: list[dict[str, str]],
130
+ client_id: str,
131
+ client_secret: str,
132
+ ) -> schemas.Auth:
133
+ return await self._run(
134
+ lambda: self._inner.create_auth_oauth2_github(name, description, tags, client_id, client_secret)
135
+ )
136
+
137
+ async def update_auth(
138
+ self,
139
+ id: int,
140
+ name: str | None = None,
141
+ description: str | None = None,
142
+ is_enabled: bool | None = None,
143
+ tags: list[schemas.TagNameValue] | None = None,
144
+ ) -> None:
145
+ return await self._run(lambda: self._inner.update_auth(id, name, description, is_enabled, tags))
146
+
147
+ async def delete_auth(self, id: int) -> None:
148
+ return await self._run(lambda: self._inner.delete_auth(id))
149
+
150
+ async def list_bastions(self, id: int | None = None) -> schemas.BastionListResponse:
151
+ return await self._run(lambda: self._inner.list_bastions(id))
152
+
153
+ async def get_bastion(self, id: int) -> schemas.Bastion:
154
+ return await self._run(lambda: self._inner.get_bastion(id))
155
+
156
+ async def create_bastion(
157
+ self,
158
+ url: str,
159
+ ssh_proxy_jump: str | None,
160
+ tag_id_list: list[int],
161
+ tag_name_value_list: list[dict[str, str]],
162
+ ) -> schemas.Bastion:
163
+ return await self._run(
164
+ lambda: self._inner.create_bastion(url, ssh_proxy_jump, tag_id_list, tag_name_value_list)
165
+ )
166
+
167
+ async def update_bastion(
168
+ self,
169
+ id: int,
170
+ url: str | None = None,
171
+ ssh_proxy_jump: str | None = None,
172
+ tag_id_list: list[int] | None = None,
173
+ tag_name_value_list: list[schemas.TagNameValue] | None = None,
174
+ ) -> None:
175
+ return await self._run(
176
+ lambda: self._inner.update_bastion(id, url, ssh_proxy_jump, tag_id_list, tag_name_value_list)
177
+ )
178
+
179
+ async def delete_bastion(self, id: int) -> None:
180
+ return await self._run(lambda: self._inner.delete_bastion(id))
181
+
182
+ async def list_roles(self, id: int | None = None, name: str | None = None) -> schemas.RolesResponse:
183
+ return await self._run(lambda: self._inner.list_roles(id, name))
184
+
185
+ async def get_role(self, id: int) -> schemas.Role:
186
+ return await self._run(lambda: self._inner.get_role(id))
187
+
188
+ async def create_role(self, name: str, description: str) -> schemas.Role:
189
+ return await self._run(lambda: self._inner.create_role(name, description))
190
+
191
+ async def update_role(
192
+ self,
193
+ id: int,
194
+ name: str | None = None,
195
+ description: str | None = None,
196
+ grant_list: list[schemas.Grant] | None = None,
197
+ member_list: list[schemas.RoleMemberRef] | None = None,
198
+ ) -> None:
199
+ return await self._run(lambda: self._inner.update_role(id, name, description, grant_list, member_list))
200
+
201
+ async def delete_role(self, id: int) -> None:
202
+ return await self._run(lambda: self._inner.delete_role(id))
203
+
204
+ async def list_boundaries(self, id: int | None = None, name: str | None = None) -> schemas.BoundariesResponse:
205
+ return await self._run(lambda: self._inner.list_boundaries(id, name))
206
+
207
+ async def get_boundary(self, id: int) -> schemas.Boundary:
208
+ return await self._run(lambda: self._inner.get_boundary(id))
209
+
210
+ async def create_boundary(self, name: str, description: str) -> schemas.Boundary:
211
+ return await self._run(lambda: self._inner.create_boundary(name, description))
212
+
213
+ async def update_boundary(
214
+ self,
215
+ id: int,
216
+ name: str | None = None,
217
+ description: str | None = None,
218
+ ceiling_list: list[schemas.Grant] | None = None,
219
+ denied_list: list[schemas.Grant] | None = None,
220
+ ) -> None:
221
+ return await self._run(lambda: self._inner.update_boundary(id, name, description, ceiling_list, denied_list))
222
+
223
+ async def delete_boundary(self, id: int) -> None:
224
+ return await self._run(lambda: self._inner.delete_boundary(id))
225
+
226
+ async def list_identities(
227
+ self,
228
+ id: int | None = None,
229
+ name: str | None = None,
230
+ tag_id: list[str] | None = None,
231
+ tag_name: list[str] | None = None,
232
+ boundary_id: list[str] | None = None,
233
+ boundary_name: list[str] | None = None,
234
+ ) -> schemas.IdentitiesResponse:
235
+ return await self._run(
236
+ lambda: self._inner.list_identities(id, name, tag_id, tag_name, boundary_id, boundary_name)
237
+ )
238
+
239
+ async def get_identity(self, id: int) -> schemas.Identity:
240
+ return await self._run(lambda: self._inner.get_identity(id))
241
+
242
+ async def create_identity(
243
+ self,
244
+ name: str | None,
245
+ boundary_id_list: list[int],
246
+ boundary_name_list: list[str],
247
+ tag_id_list: list[int],
248
+ tag_name_value_list: list[dict[str, str]],
249
+ ) -> schemas.Identity:
250
+ return await self._run(
251
+ lambda: self._inner.create_identity(
252
+ name, boundary_id_list, boundary_name_list, tag_id_list, tag_name_value_list
253
+ )
254
+ )
255
+
256
+ async def invite_identity(self, id: int, delivery: str) -> str | None:
257
+ return await self._run(lambda: self._inner.invite_identity(id, delivery))
258
+
259
+ async def delete_identity(self, id: int) -> None:
260
+ return await self._run(lambda: self._inner.delete_identity(id))
261
+
262
+ async def update_identity(
263
+ self,
264
+ id: int,
265
+ name: str | None = None,
266
+ tags: list[schemas.IdentityTagOp] | None = None,
267
+ ) -> None:
268
+ return await self._run(lambda: self._inner.update_identity(id, name, tags))
269
+
270
+ async def login_oidc(
271
+ self,
272
+ auth_name: str,
273
+ id_token: str,
274
+ session_public_key: dict[str, typing.Any],
275
+ ) -> None:
276
+ return await self._run(lambda: self._inner.login_oidc(auth_name, id_token, session_public_key))
277
+
278
+ async def login_oauth2_start(
279
+ self,
280
+ auth_name: str,
281
+ session_public_key: dict[str, typing.Any],
282
+ client_redirect_uri: str,
283
+ ) -> str:
284
+ return await self._run(
285
+ lambda: self._inner.login_oauth2_start(auth_name, session_public_key, client_redirect_uri)
286
+ )
287
+
288
+ async def list_audit_log(
289
+ self,
290
+ level: int | None = None,
291
+ object_type: str | None = None,
292
+ by_identity_id: str | None = None,
293
+ start_time: int | None = None,
294
+ end_time: int | None = None,
295
+ ) -> schemas.AuditLogListResponse:
296
+ return await self._run(
297
+ lambda: self._inner.list_audit_log(level, object_type, by_identity_id, start_time, end_time)
298
+ )
299
+
300
+
301
+ class AsyncAccountClient:
302
+ def __init__(self, inner: account_client.AccountClient) -> None:
303
+ self._inner = inner
304
+ self._lock = asyncio.Lock()
305
+
306
+ async def _run(self, fn: typing.Callable[[], typing.Any]) -> typing.Any:
307
+ async with self._lock:
308
+ return await asyncio.to_thread(fn)
309
+
310
+ async def login_http_sig(self, session_public_key: dict[str, typing.Any]) -> None:
311
+ return await self._run(lambda: self._inner.login_http_sig(session_public_key))
312
+
313
+
314
+ class AsyncInvitationClient:
315
+ def __init__(self, inner: invitation_client.InvitationClient) -> None:
316
+ self._inner = inner
317
+ self._lock = asyncio.Lock()
318
+
319
+ async def _run(self, fn: typing.Callable[[], typing.Any]) -> typing.Any:
320
+ async with self._lock:
321
+ return await asyncio.to_thread(fn)
322
+
323
+ async def connect(self, account_public_key: dict[str, typing.Any]) -> None:
324
+ return await self._run(lambda: self._inner.connect(account_public_key))
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import requests
4
+
5
+ from . import exceptions
6
+
7
+
8
+ class Directory:
9
+ """Fetches and caches the server's endpoint URL map from a well-known URL."""
10
+
11
+ def __init__(self, url: str, timeout: float = 5.0) -> None:
12
+ self._url = url
13
+ self._timeout = timeout
14
+ self._data: dict[str, str] | None = None
15
+
16
+ def _load(self) -> dict[str, str]:
17
+ if self._data is None:
18
+ try:
19
+ response = requests.get(self._url, timeout=self._timeout)
20
+ except requests.exceptions.ConnectionError:
21
+ raise exceptions.UI("Unable to connect to server")
22
+ except requests.exceptions.ReadTimeout:
23
+ raise exceptions.UI("Request timed out")
24
+ if response.status_code != 200:
25
+ raise exceptions.UI("Unable to read directory from server")
26
+ self._data = response.json()
27
+ assert self._data is not None
28
+ return self._data
29
+
30
+ @property
31
+ def accept_invitation(self) -> str:
32
+ return self._load()["accept_invitation"]
33
+
34
+ @property
35
+ def audit_log(self) -> str:
36
+ return self._load()["audit_log"]
37
+
38
+ @property
39
+ def auth(self) -> str:
40
+ return self._load()["auth"]
41
+
42
+ @property
43
+ def bastion(self) -> str:
44
+ return self._load()["bastion"]
45
+
46
+ @property
47
+ def boundary(self) -> str:
48
+ return self._load()["boundary"]
49
+
50
+ @property
51
+ def identity(self) -> str:
52
+ return self._load()["identity"]
53
+
54
+ @property
55
+ def initialize(self) -> str:
56
+ return self._load()["initialize"]
57
+
58
+ @property
59
+ def login(self) -> str:
60
+ return self._load()["login"]
61
+
62
+ @property
63
+ def login_oauth2_start(self) -> str:
64
+ return self._load()["login_oauth2_start"]
65
+
66
+ @property
67
+ def login_oidc(self) -> str:
68
+ return self._load()["login_oidc"]
69
+
70
+ @property
71
+ def public_auth(self) -> str:
72
+ return self._load()["public_auth"]
73
+
74
+ @property
75
+ def role(self) -> str:
76
+ return self._load()["role"]
77
+
78
+ @property
79
+ def ssh(self) -> str:
80
+ return self._load()["ssh"]
81
+
82
+ @property
83
+ def tag(self) -> str:
84
+ return self._load()["tag"]
85
+
86
+ @property
87
+ def tenant(self) -> str:
88
+ return self._load()["tenant"]
@@ -0,0 +1,11 @@
1
+ class UI(Exception):
2
+ pass
3
+
4
+
5
+ class Forbidden(UI):
6
+ pass
7
+
8
+
9
+ class KeyExpired(Exception):
10
+ def __init__(self, key_type: str):
11
+ self.key_type = key_type