konklav-control-plane 1.4.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.
- konklav_control_plane-1.4.0/LICENSE +21 -0
- konklav_control_plane-1.4.0/PKG-INFO +58 -0
- konklav_control_plane-1.4.0/PYPI.md +41 -0
- konklav_control_plane-1.4.0/pyproject.toml +42 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/__init__.py +46 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_async/__init__.py +0 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_async/auth.py +95 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_async/batch.py +119 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_async/client.py +220 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_async/resources.py +677 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_backend.py +27 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_common.py +156 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_sync/__init__.py +1 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_sync/auth.py +96 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_sync/batch.py +120 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_sync/client.py +221 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/_sync/resources.py +678 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/errors.py +133 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/py.typed +0 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/sync.py +9 -0
- konklav_control_plane-1.4.0/src/konklav_control_plane/types.py +2046 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Albin Skott
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: konklav-control-plane
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: Manage Konklav from Python: tenants, subjects, credentials, roles and the rest of the control plane
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Topic :: Security
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Dist: httpx>=0.28
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Changelog, https://github.com/cstruct/konklav/releases
|
|
13
|
+
Project-URL: Documentation, https://github.com/cstruct/konklav/tree/main/items/py-control-plane
|
|
14
|
+
Project-URL: Issues, https://github.com/cstruct/konklav/issues
|
|
15
|
+
Project-URL: Repository, https://github.com/cstruct/konklav
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# konklav-control-plane
|
|
19
|
+
|
|
20
|
+
Manage [Konklav](https://github.com/cstruct/konklav) from Python.
|
|
21
|
+
|
|
22
|
+
Konklav is a standalone identity and access service. This package is
|
|
23
|
+
the Python client for its control plane, the API operators and automation
|
|
24
|
+
use to manage tenants, subjects, credentials, roles, shares and the rest.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from konklav_control_plane import ControlPlaneClient, SecretKey
|
|
28
|
+
|
|
29
|
+
auth = SecretKey(subject_url="https://konklav:8080", key_id=key_id, secret=secret)
|
|
30
|
+
async with ControlPlaneClient("https://konklav:8082", auth=auth) as client:
|
|
31
|
+
written = await client.tenants.create(tenant_id, {"facts": {}})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Every control-plane operation has a method, with request and response
|
|
35
|
+
types generated from Konklav's API document. `client.batch()` applies
|
|
36
|
+
several writes in one request, all or nothing, through the same methods.
|
|
37
|
+
`konklav_control_plane.sync` has the same client for synchronous code.
|
|
38
|
+
Failed requests are retried where that's safe, and errors raise
|
|
39
|
+
`KonklavError` subclasses such as `NotFound` and `Forbidden`.
|
|
40
|
+
|
|
41
|
+
To decide authorization in a relying system, use
|
|
42
|
+
[`konklav-relying`](https://pypi.org/project/konklav-relying/) instead. The
|
|
43
|
+
two packages don't depend on each other.
|
|
44
|
+
|
|
45
|
+
## Versions
|
|
46
|
+
|
|
47
|
+
The package has the same version as the Konklav server it was released
|
|
48
|
+
with, and only that server version is supported. Pre-releases, such as
|
|
49
|
+
`1.3.0rc1`, are installed only with `pip install --pre`.
|
|
50
|
+
|
|
51
|
+
## Links
|
|
52
|
+
|
|
53
|
+
- [Konklav](https://github.com/cstruct/konklav)
|
|
54
|
+
- [This package's README](https://github.com/cstruct/konklav/tree/main/items/py-control-plane)
|
|
55
|
+
- [Releases and changelog](https://github.com/cstruct/konklav/releases)
|
|
56
|
+
- [Issues](https://github.com/cstruct/konklav/issues)
|
|
57
|
+
|
|
58
|
+
MIT licensed.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# konklav-control-plane
|
|
2
|
+
|
|
3
|
+
Manage [Konklav](https://github.com/cstruct/konklav) from Python.
|
|
4
|
+
|
|
5
|
+
Konklav is a standalone identity and access service. This package is
|
|
6
|
+
the Python client for its control plane, the API operators and automation
|
|
7
|
+
use to manage tenants, subjects, credentials, roles, shares and the rest.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from konklav_control_plane import ControlPlaneClient, SecretKey
|
|
11
|
+
|
|
12
|
+
auth = SecretKey(subject_url="https://konklav:8080", key_id=key_id, secret=secret)
|
|
13
|
+
async with ControlPlaneClient("https://konklav:8082", auth=auth) as client:
|
|
14
|
+
written = await client.tenants.create(tenant_id, {"facts": {}})
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Every control-plane operation has a method, with request and response
|
|
18
|
+
types generated from Konklav's API document. `client.batch()` applies
|
|
19
|
+
several writes in one request, all or nothing, through the same methods.
|
|
20
|
+
`konklav_control_plane.sync` has the same client for synchronous code.
|
|
21
|
+
Failed requests are retried where that's safe, and errors raise
|
|
22
|
+
`KonklavError` subclasses such as `NotFound` and `Forbidden`.
|
|
23
|
+
|
|
24
|
+
To decide authorization in a relying system, use
|
|
25
|
+
[`konklav-relying`](https://pypi.org/project/konklav-relying/) instead. The
|
|
26
|
+
two packages don't depend on each other.
|
|
27
|
+
|
|
28
|
+
## Versions
|
|
29
|
+
|
|
30
|
+
The package has the same version as the Konklav server it was released
|
|
31
|
+
with, and only that server version is supported. Pre-releases, such as
|
|
32
|
+
`1.3.0rc1`, are installed only with `pip install --pre`.
|
|
33
|
+
|
|
34
|
+
## Links
|
|
35
|
+
|
|
36
|
+
- [Konklav](https://github.com/cstruct/konklav)
|
|
37
|
+
- [This package's README](https://github.com/cstruct/konklav/tree/main/items/py-control-plane)
|
|
38
|
+
- [Releases and changelog](https://github.com/cstruct/konklav/releases)
|
|
39
|
+
- [Issues](https://github.com/cstruct/konklav/issues)
|
|
40
|
+
|
|
41
|
+
MIT licensed.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.8.24,<0.9"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "konklav-control-plane"
|
|
7
|
+
# Set by `mise run release:bump`, with the rest of the release.
|
|
8
|
+
version = "1.4.0"
|
|
9
|
+
description = "Manage Konklav from Python: tenants, subjects, credentials, roles and the rest of the control plane"
|
|
10
|
+
readme = "PYPI.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = ["httpx>=0.28"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Security",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://github.com/cstruct/konklav"
|
|
23
|
+
Documentation = "https://github.com/cstruct/konklav/tree/main/items/py-control-plane"
|
|
24
|
+
Changelog = "https://github.com/cstruct/konklav/releases"
|
|
25
|
+
Issues = "https://github.com/cstruct/konklav/issues"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"datamodel-code-generator==0.88.0",
|
|
30
|
+
"mypy==1.20.2",
|
|
31
|
+
"pytest>=8",
|
|
32
|
+
"unasync==0.6.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.mypy]
|
|
36
|
+
files = ["src", "tools", "tests/typing"]
|
|
37
|
+
strict = true
|
|
38
|
+
python_version = "3.12"
|
|
39
|
+
|
|
40
|
+
[[tool.mypy.overrides]]
|
|
41
|
+
module = "unasync"
|
|
42
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""A client for Konklav's control-plane surface.
|
|
2
|
+
|
|
3
|
+
`ControlPlaneClient` is asynchronous; `konklav_control_plane.sync` has the
|
|
4
|
+
same client for synchronous code. Request and response types are in
|
|
5
|
+
`konklav_control_plane.types`, and errors in `konklav_control_plane.errors`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# r[impl item.py-control-plane.pure-python]
|
|
9
|
+
# r[impl item.py-control-plane.independent]
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
|
|
12
|
+
from ._async.auth import BearerToken, SecretKey
|
|
13
|
+
from ._async.batch import Batch
|
|
14
|
+
# r[impl item.py-control-plane.async-default]
|
|
15
|
+
from ._async.client import ControlPlaneClient
|
|
16
|
+
from ._common import Mode, Pending, Written
|
|
17
|
+
from .errors import (
|
|
18
|
+
Conflict,
|
|
19
|
+
Forbidden,
|
|
20
|
+
InvalidRequest,
|
|
21
|
+
KonklavError,
|
|
22
|
+
NotFound,
|
|
23
|
+
RateLimited,
|
|
24
|
+
Unauthenticated,
|
|
25
|
+
Unavailable,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = version("konklav-control-plane")
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Batch",
|
|
32
|
+
"BearerToken",
|
|
33
|
+
"Conflict",
|
|
34
|
+
"ControlPlaneClient",
|
|
35
|
+
"Forbidden",
|
|
36
|
+
"InvalidRequest",
|
|
37
|
+
"KonklavError",
|
|
38
|
+
"Mode",
|
|
39
|
+
"NotFound",
|
|
40
|
+
"Pending",
|
|
41
|
+
"RateLimited",
|
|
42
|
+
"SecretKey",
|
|
43
|
+
"Unauthenticated",
|
|
44
|
+
"Unavailable",
|
|
45
|
+
"Written",
|
|
46
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""How the client authenticates to the control plane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from .. import _backend
|
|
10
|
+
from .._backend import AsyncLock, AsyncToken
|
|
11
|
+
from .._common import base
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import ControlPlaneClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class Auth(Protocol):
|
|
19
|
+
async def token(self, client: ControlPlaneClient) -> str:
|
|
20
|
+
"""The bearer token to send."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def invalidate(self) -> bool:
|
|
24
|
+
"""Forgets a rejected token; `True` if the next one will differ."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# r[impl item.py-control-plane.auth]
|
|
29
|
+
class BearerToken:
|
|
30
|
+
"""A fixed token: the bootstrap token, or an operator's access token."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, token: str) -> None:
|
|
33
|
+
self._token = token
|
|
34
|
+
|
|
35
|
+
async def token(self, client: ControlPlaneClient) -> str:
|
|
36
|
+
return self._token
|
|
37
|
+
|
|
38
|
+
def invalidate(self) -> bool:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SecretKey:
|
|
43
|
+
"""A service principal's secret key, logged in at the subject-facing
|
|
44
|
+
surface and logged in again once 80% of the token's lifetime has
|
|
45
|
+
passed."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, *, subject_url: str, key_id: str, secret: str) -> None:
|
|
48
|
+
self._login_url = f"{base(subject_url)}/v1/login/secret-key"
|
|
49
|
+
self._key_id = key_id
|
|
50
|
+
self._secret = secret
|
|
51
|
+
self._cached: tuple[str, float] | None = None
|
|
52
|
+
self._lock = AsyncLock()
|
|
53
|
+
|
|
54
|
+
async def token(self, client: ControlPlaneClient) -> str:
|
|
55
|
+
async with self._lock:
|
|
56
|
+
if self._cached is not None and self._cached[1] > _backend.now():
|
|
57
|
+
return self._cached[0]
|
|
58
|
+
response = await client._send(
|
|
59
|
+
"POST",
|
|
60
|
+
self._login_url,
|
|
61
|
+
json={"key_id": self._key_id, "secret": self._secret},
|
|
62
|
+
authenticate=False,
|
|
63
|
+
)
|
|
64
|
+
body = response.json()
|
|
65
|
+
renew_at = _backend.now() + body["expires_in"] * 0.8
|
|
66
|
+
self._cached = (body["access_token"], renew_at)
|
|
67
|
+
return self._cached[0]
|
|
68
|
+
|
|
69
|
+
def invalidate(self) -> bool:
|
|
70
|
+
self._cached = None
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TokenFunction:
|
|
75
|
+
"""A caller's function that returns the token to send."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, function: Callable[[], AsyncToken]) -> None:
|
|
78
|
+
self._function = function
|
|
79
|
+
|
|
80
|
+
async def token(self, client: ControlPlaneClient) -> str:
|
|
81
|
+
token = self._function()
|
|
82
|
+
if inspect.isawaitable(token):
|
|
83
|
+
token = await token
|
|
84
|
+
if not isinstance(token, str):
|
|
85
|
+
raise TypeError(f"the token function returned {type(token).__name__}, not str")
|
|
86
|
+
return token
|
|
87
|
+
|
|
88
|
+
def invalidate(self) -> bool:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve(auth: Auth | Callable[[], AsyncToken]) -> Auth:
|
|
93
|
+
if isinstance(auth, Auth):
|
|
94
|
+
return auth
|
|
95
|
+
return TokenFunction(auth)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Batches: writes collected through the client's namespaces and applied in
|
|
2
|
+
one request, all or nothing."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .._common import Call, Pending
|
|
10
|
+
from . import resources
|
|
11
|
+
from .resources import Batched
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import ControlPlaneClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# r[impl item.py-control-plane.batch]
|
|
18
|
+
class Batch:
|
|
19
|
+
"""Writes to apply in one request, all or nothing; make one with
|
|
20
|
+
`client.batch()`.
|
|
21
|
+
|
|
22
|
+
Its namespaces mirror the client's, writes only. Each call is recorded
|
|
23
|
+
and returns a `Pending`, whose `result` is what the same call on the
|
|
24
|
+
client returns, once the batch has been sent: on leaving `async with`
|
|
25
|
+
without an error, or by `send()`. Later operations name what earlier ones
|
|
26
|
+
create by the ids the caller chose for them. If an operation fails,
|
|
27
|
+
nothing is applied and its error is raised, with its position as
|
|
28
|
+
`operation`. `cursor` is then the change-feed cursor of the last event
|
|
29
|
+
the batch produced, if any.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
tenants: resources.Tenants[Batched]
|
|
33
|
+
memberships: resources.Memberships[Batched]
|
|
34
|
+
subjects: resources.Subjects[Batched]
|
|
35
|
+
credentials: resources.Credentials[Batched]
|
|
36
|
+
sessions: resources.Sessions[Batched]
|
|
37
|
+
signing_keys: resources.SigningKeys[Batched]
|
|
38
|
+
catalog: resources.Catalog[Batched]
|
|
39
|
+
templates: resources.Templates[Batched]
|
|
40
|
+
roles: resources.Roles[Batched]
|
|
41
|
+
assignments: resources.Assignments[Batched]
|
|
42
|
+
shares: resources.Shares[Batched]
|
|
43
|
+
registered_scopes: resources.RegisteredScopes[Batched]
|
|
44
|
+
invites: resources.Invites[Batched]
|
|
45
|
+
oidc_clients: resources.OidcClients[Batched]
|
|
46
|
+
imports: resources.Imports[Batched]
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
client: ControlPlaneClient,
|
|
51
|
+
*,
|
|
52
|
+
idempotency_key: str | None = None,
|
|
53
|
+
idempotency_seconds: int | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._client = client
|
|
56
|
+
self._key = idempotency_key
|
|
57
|
+
self._seconds = idempotency_seconds
|
|
58
|
+
self._calls: list[tuple[Call, Pending[Any]]] = []
|
|
59
|
+
self._sent = False
|
|
60
|
+
self.cursor: int | None = None
|
|
61
|
+
self.tenants = resources.Tenants(client, self)
|
|
62
|
+
self.memberships = resources.Memberships(client, self)
|
|
63
|
+
self.subjects = resources.Subjects(client, self)
|
|
64
|
+
self.credentials = resources.Credentials(client, self)
|
|
65
|
+
self.sessions = resources.Sessions(client, self)
|
|
66
|
+
self.signing_keys = resources.SigningKeys(client, self)
|
|
67
|
+
self.catalog = resources.Catalog(client, self)
|
|
68
|
+
self.templates = resources.Templates(client, self)
|
|
69
|
+
self.roles = resources.Roles(client, self)
|
|
70
|
+
self.assignments = resources.Assignments(client, self)
|
|
71
|
+
self.shares = resources.Shares(client, self)
|
|
72
|
+
self.registered_scopes = resources.RegisteredScopes(client, self)
|
|
73
|
+
self.invites = resources.Invites(client, self)
|
|
74
|
+
self.oidc_clients = resources.OidcClients(client, self)
|
|
75
|
+
self.imports = resources.Imports(client, self)
|
|
76
|
+
|
|
77
|
+
def _add(self, call: Call) -> Pending[Any]:
|
|
78
|
+
if self._sent:
|
|
79
|
+
raise RuntimeError("the batch was sent already")
|
|
80
|
+
pending: Pending[Any] = Pending()
|
|
81
|
+
self._calls.append((call, pending))
|
|
82
|
+
return pending
|
|
83
|
+
|
|
84
|
+
async def send(self) -> None:
|
|
85
|
+
"""Sends the recorded writes. A batch is sent once; one with nothing
|
|
86
|
+
recorded sends nothing."""
|
|
87
|
+
if self._sent:
|
|
88
|
+
raise RuntimeError("the batch was sent already")
|
|
89
|
+
self._sent = True
|
|
90
|
+
if not self._calls:
|
|
91
|
+
return
|
|
92
|
+
body: dict[str, Any] = {"operations": [call.operation() for call, _ in self._calls]}
|
|
93
|
+
if self._key is not None:
|
|
94
|
+
body["idempotency_key"] = self._key
|
|
95
|
+
if self._seconds is not None:
|
|
96
|
+
body["idempotency_seconds"] = self._seconds
|
|
97
|
+
response = await self._client._send(
|
|
98
|
+
"POST",
|
|
99
|
+
f"{self._client._url}/batch",
|
|
100
|
+
json=body,
|
|
101
|
+
# r[impl item.py-control-plane.retries]
|
|
102
|
+
retry_as="PUT" if self._key is not None else None,
|
|
103
|
+
)
|
|
104
|
+
answer = response.json()
|
|
105
|
+
self.cursor = answer.get("cursor")
|
|
106
|
+
for (call, pending), result in zip(self._calls, answer["results"], strict=True):
|
|
107
|
+
pending._resolve(call.result(result["status"], result["body"]))
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> Batch:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(
|
|
113
|
+
self,
|
|
114
|
+
kind: type[BaseException] | None,
|
|
115
|
+
error: BaseException | None,
|
|
116
|
+
traceback: TracebackType | None,
|
|
117
|
+
) -> None:
|
|
118
|
+
if error is None:
|
|
119
|
+
await self.send()
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The control-plane client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .. import _backend
|
|
12
|
+
from .._backend import AsyncToken
|
|
13
|
+
from .._common import Call, Written, base, delay, retryable, written
|
|
14
|
+
from ..errors import KonklavError, from_response
|
|
15
|
+
from . import resources
|
|
16
|
+
from .auth import Auth, resolve
|
|
17
|
+
from .batch import Batch
|
|
18
|
+
from .resources import Direct
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
DEFAULT_RETRIES = 3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ControlPlaneClient:
|
|
25
|
+
"""A client for Konklav's control-plane surface.
|
|
26
|
+
|
|
27
|
+
`url` is the surface's base URL, without `/v1`; a path prefix it has,
|
|
28
|
+
such as a proxy's, is kept. `auth` is a `BearerToken`, a `SecretKey`
|
|
29
|
+
or a function returning the token to send. Pass `http` to send through
|
|
30
|
+
your own `httpx` client; otherwise the client makes one with `timeout`
|
|
31
|
+
and closes it when the client is closed. `retries` is how many times a failed request
|
|
32
|
+
is sent again, where it is safe to; `0` turns retries off.
|
|
33
|
+
|
|
34
|
+
The operations are grouped as the API document tags them, such as
|
|
35
|
+
`client.tenants.create(...)` and `client.roles.put(...)`. `batch()`
|
|
36
|
+
collects writes made through the same namespaces and applies them in
|
|
37
|
+
one request, all or nothing.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
tenants: resources.Tenants[Direct]
|
|
41
|
+
memberships: resources.Memberships[Direct]
|
|
42
|
+
subjects: resources.Subjects[Direct]
|
|
43
|
+
credentials: resources.Credentials[Direct]
|
|
44
|
+
sessions: resources.Sessions[Direct]
|
|
45
|
+
signing_keys: resources.SigningKeys[Direct]
|
|
46
|
+
catalog: resources.Catalog[Direct]
|
|
47
|
+
templates: resources.Templates[Direct]
|
|
48
|
+
roles: resources.Roles[Direct]
|
|
49
|
+
assignments: resources.Assignments[Direct]
|
|
50
|
+
entitlements: resources.Entitlements[Direct]
|
|
51
|
+
explain: resources.Explain[Direct]
|
|
52
|
+
shares: resources.Shares[Direct]
|
|
53
|
+
registered_scopes: resources.RegisteredScopes[Direct]
|
|
54
|
+
invites: resources.Invites[Direct]
|
|
55
|
+
oidc_clients: resources.OidcClients[Direct]
|
|
56
|
+
imports: resources.Imports[Direct]
|
|
57
|
+
batches: resources.Batches[Direct]
|
|
58
|
+
|
|
59
|
+
# r[impl item.py-control-plane.injectable-http]
|
|
60
|
+
# r[impl item.py-control-plane.explicit-config]
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
url: str,
|
|
64
|
+
*,
|
|
65
|
+
auth: Auth | Callable[[], AsyncToken],
|
|
66
|
+
http: httpx.AsyncClient | None = None,
|
|
67
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
68
|
+
retries: int = DEFAULT_RETRIES,
|
|
69
|
+
) -> None:
|
|
70
|
+
if retries < 0:
|
|
71
|
+
raise ValueError("retries can't be negative")
|
|
72
|
+
self._url = f"{base(url)}/v1"
|
|
73
|
+
self._auth = resolve(auth)
|
|
74
|
+
self._owns_http = http is None
|
|
75
|
+
# The environment holds no configuration for this client.
|
|
76
|
+
self._http = http if http is not None else httpx.AsyncClient(timeout=timeout, trust_env=False)
|
|
77
|
+
self._retries = retries
|
|
78
|
+
self.tenants = resources.Tenants(self)
|
|
79
|
+
self.memberships = resources.Memberships(self)
|
|
80
|
+
self.subjects = resources.Subjects(self)
|
|
81
|
+
self.credentials = resources.Credentials(self)
|
|
82
|
+
self.sessions = resources.Sessions(self)
|
|
83
|
+
self.signing_keys = resources.SigningKeys(self)
|
|
84
|
+
self.catalog = resources.Catalog(self)
|
|
85
|
+
self.templates = resources.Templates(self)
|
|
86
|
+
self.roles = resources.Roles(self)
|
|
87
|
+
self.assignments = resources.Assignments(self)
|
|
88
|
+
self.entitlements = resources.Entitlements(self)
|
|
89
|
+
self.explain = resources.Explain(self)
|
|
90
|
+
self.shares = resources.Shares(self)
|
|
91
|
+
self.registered_scopes = resources.RegisteredScopes(self)
|
|
92
|
+
self.invites = resources.Invites(self)
|
|
93
|
+
self.oidc_clients = resources.OidcClients(self)
|
|
94
|
+
self.imports = resources.Imports(self)
|
|
95
|
+
self.batches = resources.Batches(self)
|
|
96
|
+
|
|
97
|
+
def batch(self, *, idempotency_key: str | None = None, idempotency_seconds: int | None = None) -> Batch:
|
|
98
|
+
"""A batch: `async with client.batch() as batch:` collects the writes
|
|
99
|
+
made through `batch`'s namespaces, which mirror the client's, and
|
|
100
|
+
sends them in one request on leaving the block without an error.
|
|
101
|
+
|
|
102
|
+
With an `idempotency_key`, sending the same batch again answers what
|
|
103
|
+
it first answered without applying it again, so it is retried like a
|
|
104
|
+
`PUT`. The server remembers the key for `idempotency_seconds`, or its
|
|
105
|
+
default (ten minutes) when `None`.
|
|
106
|
+
"""
|
|
107
|
+
return Batch(self, idempotency_key=idempotency_key, idempotency_seconds=idempotency_seconds)
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> ControlPlaneClient:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(
|
|
113
|
+
self,
|
|
114
|
+
kind: type[BaseException] | None,
|
|
115
|
+
error: BaseException | None,
|
|
116
|
+
traceback: TracebackType | None,
|
|
117
|
+
) -> None:
|
|
118
|
+
await self.aclose()
|
|
119
|
+
|
|
120
|
+
async def aclose(self) -> None:
|
|
121
|
+
"""Closes the `httpx` client, unless it was passed in."""
|
|
122
|
+
if self._owns_http:
|
|
123
|
+
await self._http.aclose()
|
|
124
|
+
|
|
125
|
+
async def request(
|
|
126
|
+
self,
|
|
127
|
+
method: str,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
json: Any = None,
|
|
131
|
+
params: Mapping[str, Any] | None = None,
|
|
132
|
+
) -> Any:
|
|
133
|
+
"""Sends a request to `path` under `/v1` and returns its decoded
|
|
134
|
+
JSON body, or `None` when it has none."""
|
|
135
|
+
response = await self._send(method, f"{self._url}{path}", json=json, params=params)
|
|
136
|
+
return response.json() if response.content else None
|
|
137
|
+
|
|
138
|
+
async def _get(self, path: str, params: Mapping[str, Any] | None = None) -> Any:
|
|
139
|
+
"""A `GET`, leaving out query parameters that are `None`."""
|
|
140
|
+
query = {name: value for name, value in (params or {}).items() if value is not None}
|
|
141
|
+
return await self.request("GET", path, params=query)
|
|
142
|
+
|
|
143
|
+
async def _post(self, path: str, body: Any = None) -> Any:
|
|
144
|
+
return await self.request("POST", path, json=body)
|
|
145
|
+
|
|
146
|
+
async def _write(
|
|
147
|
+
self,
|
|
148
|
+
method: str,
|
|
149
|
+
path: str,
|
|
150
|
+
*,
|
|
151
|
+
json: Any = None,
|
|
152
|
+
params: Mapping[str, Any] | None = None,
|
|
153
|
+
create: bool = False,
|
|
154
|
+
) -> Written[Any]:
|
|
155
|
+
"""Sends a write and splits the cursor off its result."""
|
|
156
|
+
response = await self._send(method, f"{self._url}{path}", json=json, params=params)
|
|
157
|
+
return written(response.status_code, response.json(), create=create)
|
|
158
|
+
|
|
159
|
+
async def _call(self, call: Call) -> Any:
|
|
160
|
+
"""Sends `call` and returns what its method returns."""
|
|
161
|
+
response = await self._send(call.method, f"{self._url}{call.path}", json=call.json, params=call.query())
|
|
162
|
+
return call.result(response.status_code, response.json() if response.content else None)
|
|
163
|
+
|
|
164
|
+
# r[impl item.py-control-plane.paging]
|
|
165
|
+
async def _pages(
|
|
166
|
+
self, path: str, key: str, params: Mapping[str, Any] | None = None
|
|
167
|
+
) -> AsyncIterator[Any]:
|
|
168
|
+
"""Every item of a paged list, page after page."""
|
|
169
|
+
query = {name: value for name, value in (params or {}).items() if value is not None}
|
|
170
|
+
while True:
|
|
171
|
+
page = await self._get(path, query)
|
|
172
|
+
for item in page[key]:
|
|
173
|
+
yield item
|
|
174
|
+
if page.get("next") is None:
|
|
175
|
+
return
|
|
176
|
+
query["before"] = page["next"]
|
|
177
|
+
|
|
178
|
+
async def _send(
|
|
179
|
+
self,
|
|
180
|
+
method: str,
|
|
181
|
+
url: str,
|
|
182
|
+
*,
|
|
183
|
+
json: Any = None,
|
|
184
|
+
params: Mapping[str, Any] | None = None,
|
|
185
|
+
authenticate: bool = True,
|
|
186
|
+
retry_as: str | None = None,
|
|
187
|
+
) -> httpx.Response:
|
|
188
|
+
"""Sends a request, logging in again once and retrying as allowed,
|
|
189
|
+
as for `retry_as` when given instead of `method`."""
|
|
190
|
+
attempt = 0
|
|
191
|
+
relogged = False
|
|
192
|
+
while True:
|
|
193
|
+
headers = {}
|
|
194
|
+
if authenticate:
|
|
195
|
+
headers["authorization"] = f"Bearer {await self._auth.token(self)}"
|
|
196
|
+
cause: Exception | None = None
|
|
197
|
+
try:
|
|
198
|
+
response = await self._http.request(
|
|
199
|
+
method, url, json=json, params=params, headers=headers
|
|
200
|
+
)
|
|
201
|
+
except httpx.TransportError as error:
|
|
202
|
+
failure = KonklavError(f"Konklav couldn't be reached: {error}")
|
|
203
|
+
cause = error
|
|
204
|
+
else:
|
|
205
|
+
if response.is_success:
|
|
206
|
+
return response
|
|
207
|
+
failure = from_response(response)
|
|
208
|
+
# r[impl item.py-control-plane.auth-relogin]
|
|
209
|
+
if (
|
|
210
|
+
authenticate
|
|
211
|
+
and failure.code == "UNAUTHENTICATED"
|
|
212
|
+
and not relogged
|
|
213
|
+
and self._auth.invalidate()
|
|
214
|
+
):
|
|
215
|
+
relogged = True
|
|
216
|
+
continue
|
|
217
|
+
if attempt >= self._retries or not retryable(retry_as or method, failure):
|
|
218
|
+
raise failure from cause
|
|
219
|
+
await _backend.async_sleep(delay(attempt, failure))
|
|
220
|
+
attempt += 1
|