iceberg-subzero 0.1.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.
- iceberg_subzero-0.1.0.dist-info/METADATA +153 -0
- iceberg_subzero-0.1.0.dist-info/RECORD +13 -0
- iceberg_subzero-0.1.0.dist-info/WHEEL +4 -0
- subzero/__init__.py +43 -0
- subzero/_http.py +195 -0
- subzero/admin.py +282 -0
- subzero/auth.py +232 -0
- subzero/client.py +96 -0
- subzero/exceptions.py +43 -0
- subzero/members.py +71 -0
- subzero/openai.py +29 -0
- subzero/proxy.py +49 -0
- subzero/types.py +177 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iceberg-subzero
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Subzero tokenization vault and LLM proxy
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.28.0
|
|
7
|
+
Provides-Extra: auth
|
|
8
|
+
Requires-Dist: pyotp>=2.9.0; extra == 'auth'
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: openai>=1.0.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pyotp>=2.9.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.22.0; extra == 'dev'
|
|
14
|
+
Provides-Extra: openai
|
|
15
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Subzero Python SDK
|
|
19
|
+
|
|
20
|
+
Thin Python client for the [Subzero](../api/) tokenization vault and LLM proxy.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd python-sdk
|
|
26
|
+
pip install -e ".[dev,openai,auth]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The optional `auth` extra adds `pyotp` for TOTP code generation during dashboard login.
|
|
30
|
+
|
|
31
|
+
## Two auth planes
|
|
32
|
+
|
|
33
|
+
| Plane | Credential | Use for |
|
|
34
|
+
|-------|------------|---------|
|
|
35
|
+
| **Server integration** | API key (`sz_live_...`) | `tokenize`, `search`, `reveal`, `proxy`, tenant admin after bootstrap |
|
|
36
|
+
| **Dashboard / human** | JWT access token | `auth.*`, `members.*`, `admin.create_tenant`, `admin.get_tenant` |
|
|
37
|
+
|
|
38
|
+
Pass an API key, an access token, or both when constructing the client:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from subzero import SubzeroClient
|
|
42
|
+
|
|
43
|
+
# Vault + proxy (server-side)
|
|
44
|
+
vault = SubzeroClient(api_key="sz_live_...", base_url="http://127.0.0.1:8000")
|
|
45
|
+
|
|
46
|
+
# Dashboard session (platform or tenant admin JWT)
|
|
47
|
+
dashboard = SubzeroClient(access_token="eyJ...", base_url="http://127.0.0.1:8000")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start (vault)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from subzero import SubzeroClient
|
|
54
|
+
|
|
55
|
+
client = SubzeroClient(
|
|
56
|
+
api_key="sz_live_...",
|
|
57
|
+
base_url="http://127.0.0.1:8000",
|
|
58
|
+
)
|
|
59
|
+
client.ready()
|
|
60
|
+
|
|
61
|
+
token = client.tokenize("SSN", "123-45-6789").token
|
|
62
|
+
value = client.reveal(token).value
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Platform admin login (tenant provisioning)
|
|
66
|
+
|
|
67
|
+
`POST /v1/tenants` requires a platform-admin JWT. Log in with MFA, then create tenants:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from subzero import SubzeroClient
|
|
71
|
+
|
|
72
|
+
client = SubzeroClient(base_url="http://127.0.0.1:8000")
|
|
73
|
+
client.ready()
|
|
74
|
+
|
|
75
|
+
client.auth.login_platform(
|
|
76
|
+
email="admin@iceberg.local",
|
|
77
|
+
password="...",
|
|
78
|
+
totp_code="123456", # or totp_secret="BASE32..." with pip install 'subzero[auth]'
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
tenant = client.admin.create_tenant(name="Acme", slug="acme")
|
|
82
|
+
admin_key = tenant.bootstrap_api_key
|
|
83
|
+
|
|
84
|
+
# Switch to tenant admin API key for day-to-day vault/proxy setup
|
|
85
|
+
admin = SubzeroClient(api_key=admin_key, base_url="http://127.0.0.1:8000")
|
|
86
|
+
admin.admin.create_entity_type(tenant.id, name="SSN", deterministic=True)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Tenant member invites and management use `client.members` with a tenant-admin or platform-admin JWT.
|
|
90
|
+
|
|
91
|
+
## API key scopes
|
|
92
|
+
|
|
93
|
+
| Scope | Vault methods | Notes |
|
|
94
|
+
|-------|---------------|-------|
|
|
95
|
+
| `tokenize` | `tokenize`, `search` | No plaintext return |
|
|
96
|
+
| `reveal` | `reveal` | Server-side plaintext reveal; requires matching policy rule |
|
|
97
|
+
| `reveal_grant` | `create_reveal_grant` | Mint browser reveal grants only; requires matching policy rule |
|
|
98
|
+
| `proxy` | `proxy.chat.completions` | In-flight tokenization |
|
|
99
|
+
| `admin` | All of the above + `admin.*` + `delete_token` | Bypasses reveal policy |
|
|
100
|
+
|
|
101
|
+
**Browser reveal:** the iframe calls `POST /v1/browser/reveal` with a server-minted grant. Your BFF uses a **`reveal_grant`** key to call `create_reveal_grant(token, client_public_key_jwk=..., allowed_origin=...)`. Keep **`reveal`** keys for server pipelines that need `client.reveal(token).value`.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
grant = client.create_reveal_grant(
|
|
105
|
+
token,
|
|
106
|
+
client_public_key_jwk=jwk_from_iframe,
|
|
107
|
+
allowed_origin="https://app.yourcompany.com",
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Delete requires admin.** There is no delegatable delete-scoped API key at the HTTP layer. `delete_token()` needs an admin key even though policy rules support a `delete` action at the service layer.
|
|
112
|
+
|
|
113
|
+
## Proxy vs reveal vs detokenize
|
|
114
|
+
|
|
115
|
+
- **Vault reveal:** `client.reveal(token)` — `POST /v1/reveal`, reveal-scoped key + policy
|
|
116
|
+
- **Proxy chat:** `client.proxy.chat.completions(...)` — tokenizes declared patterns in-flight
|
|
117
|
+
- **Proxy detokenize:** pass `detokenize=True` or use OpenAI helper below — governed by reveal policy for the proxy key, not the reveal endpoint
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from subzero import create_openai_client
|
|
121
|
+
|
|
122
|
+
client = create_openai_client(
|
|
123
|
+
api_key="sz_live_...", # Subzero proxy key
|
|
124
|
+
base_url="http://127.0.0.1:8000/v1",
|
|
125
|
+
detokenize=True, # X-Subzero-Detokenize: true via default_headers
|
|
126
|
+
)
|
|
127
|
+
client.chat.completions.create(model="gpt-4o", stream=False, messages=[...])
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Examples
|
|
131
|
+
|
|
132
|
+
With the API running (`docker compose up` or `uvicorn`):
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
export SUBZERO_PLATFORM_EMAIL=admin@iceberg.local
|
|
136
|
+
export SUBZERO_PLATFORM_PASSWORD=...
|
|
137
|
+
export SUBZERO_TOTP_CODE=123456 # or SUBZERO_TOTP_SECRET / SUBZERO_ACCESS_TOKEN
|
|
138
|
+
|
|
139
|
+
python examples/hero_demo.py
|
|
140
|
+
python examples/vault_loop.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Set `SUBZERO_BASE_URL` and `OPENAI_API_KEY` as needed.
|
|
144
|
+
|
|
145
|
+
## Tests
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pytest
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## PyPI
|
|
152
|
+
|
|
153
|
+
Local editable install only for now. `# TODO: twine upload` when ready to publish.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
subzero/__init__.py,sha256=MmL2CriJloDNmHj6m5qwi0W4OjUm4SWFV8zf6ki1SyU,1016
|
|
2
|
+
subzero/_http.py,sha256=VByOmsY7Xxhbtfdz5C4ksBkylhsgG2Ie_IxLLNrXIuU,6429
|
|
3
|
+
subzero/admin.py,sha256=WgZeiL72JAOCN3LBIYYHLSLYkQD2IW89TpUCJC8T74o,9529
|
|
4
|
+
subzero/auth.py,sha256=kCTG_7SFaAXdldoFax_ajGCEB-Dk5FBJVEvBu_z2Hek,7944
|
|
5
|
+
subzero/client.py,sha256=Tenrn14QamCZmP9GCNFHdUiHsNyVXOPLxZXuxj53L6w,3203
|
|
6
|
+
subzero/exceptions.py,sha256=jCq4Ec8Pemty_347pCM6epcD54SZtygBIWq6C4HZE3k,812
|
|
7
|
+
subzero/members.py,sha256=snUOMX_C3iBe8JCAA-s6e-fVnzB9aOkzlUiJ5w-MimM,2230
|
|
8
|
+
subzero/openai.py,sha256=tfWvAjclLS-ZRLrur3S4QCK5XPcWsM2nASIBpFWib_k,786
|
|
9
|
+
subzero/proxy.py,sha256=f3EULiF7jSWNOt1vkcPZLv9cWNLTIkcoeLpPCFAq9yo,1338
|
|
10
|
+
subzero/types.py,sha256=xvVjGyH31p4LmY1Hiwc5HhXFY9NZi_BDManED0U0EZc,3402
|
|
11
|
+
iceberg_subzero-0.1.0.dist-info/METADATA,sha256=Adkc7jVtkwf9SCBiONAtrnpHgkntkZFLS0q20xXdSVE,5001
|
|
12
|
+
iceberg_subzero-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
iceberg_subzero-0.1.0.dist-info/RECORD,,
|
subzero/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from subzero.auth import AuthResource
|
|
2
|
+
from subzero.client import SubzeroClient
|
|
3
|
+
from subzero.exceptions import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
AuthorizationError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
PolicyDeniedError,
|
|
9
|
+
SubzeroAPIError,
|
|
10
|
+
SubzeroNotReadyError,
|
|
11
|
+
)
|
|
12
|
+
from subzero.members import MembersResource
|
|
13
|
+
from subzero.openai import create_openai_client
|
|
14
|
+
from subzero.types import (
|
|
15
|
+
AcceptInviteResult,
|
|
16
|
+
LoginResult,
|
|
17
|
+
MeResult,
|
|
18
|
+
MemberResult,
|
|
19
|
+
MfaEnrollConfirmResult,
|
|
20
|
+
MfaEnrollStartResult,
|
|
21
|
+
TokenResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SubzeroClient",
|
|
26
|
+
"AuthResource",
|
|
27
|
+
"MembersResource",
|
|
28
|
+
"create_openai_client",
|
|
29
|
+
"LoginResult",
|
|
30
|
+
"TokenResult",
|
|
31
|
+
"MfaEnrollStartResult",
|
|
32
|
+
"MfaEnrollConfirmResult",
|
|
33
|
+
"AcceptInviteResult",
|
|
34
|
+
"MeResult",
|
|
35
|
+
"MemberResult",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"AuthorizationError",
|
|
38
|
+
"ConflictError",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"PolicyDeniedError",
|
|
41
|
+
"SubzeroAPIError",
|
|
42
|
+
"SubzeroNotReadyError",
|
|
43
|
+
]
|
subzero/_http.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from subzero.exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
AuthorizationError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PolicyDeniedError,
|
|
14
|
+
SubzeroAPIError,
|
|
15
|
+
SubzeroNotReadyError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
AuthMode = bool | Literal["api_key", "access_token", "none"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HTTPClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
access_token: str | None = None,
|
|
27
|
+
base_url: str,
|
|
28
|
+
timeout: float = 60.0,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._api_key = api_key
|
|
31
|
+
self._access_token = access_token
|
|
32
|
+
self._base_url = base_url.rstrip("/")
|
|
33
|
+
self._client = httpx.Client(base_url=self._base_url, timeout=timeout)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def access_token(self) -> str | None:
|
|
37
|
+
return self._access_token
|
|
38
|
+
|
|
39
|
+
def set_access_token(self, token: str | None) -> None:
|
|
40
|
+
self._access_token = token
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
self._client.close()
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> HTTPClient:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *args: object) -> None:
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
def _headers(
|
|
52
|
+
self,
|
|
53
|
+
extra: dict[str, str] | None = None,
|
|
54
|
+
*,
|
|
55
|
+
auth: AuthMode = True,
|
|
56
|
+
) -> dict[str, str]:
|
|
57
|
+
headers: dict[str, str] = {}
|
|
58
|
+
if auth is True:
|
|
59
|
+
if self._api_key is not None:
|
|
60
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
61
|
+
elif self._access_token is not None:
|
|
62
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
63
|
+
elif auth == "api_key":
|
|
64
|
+
if self._api_key is not None:
|
|
65
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
66
|
+
elif auth == "access_token":
|
|
67
|
+
if self._access_token is not None:
|
|
68
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
69
|
+
if extra:
|
|
70
|
+
headers.update(extra)
|
|
71
|
+
return headers
|
|
72
|
+
|
|
73
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
74
|
+
if response.is_success:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
reason: str | None = None
|
|
78
|
+
detail = response.text
|
|
79
|
+
try:
|
|
80
|
+
body = response.json()
|
|
81
|
+
if isinstance(body, dict):
|
|
82
|
+
detail = str(body.get("detail", detail))
|
|
83
|
+
raw_reason = body.get("reason")
|
|
84
|
+
if isinstance(raw_reason, str):
|
|
85
|
+
reason = raw_reason
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
status = response.status_code
|
|
90
|
+
if status == 401:
|
|
91
|
+
raise AuthenticationError(detail, status_code=status, reason=reason, response=response)
|
|
92
|
+
if status == 403:
|
|
93
|
+
if reason == "policy_denied":
|
|
94
|
+
raise PolicyDeniedError(detail, status_code=status, reason=reason, response=response)
|
|
95
|
+
if reason in {"insufficient_scope", "tenant_mismatch"}:
|
|
96
|
+
raise AuthorizationError(detail, status_code=status, reason=reason, response=response)
|
|
97
|
+
raise SubzeroAPIError(detail, status_code=status, reason=reason, response=response)
|
|
98
|
+
if status == 404:
|
|
99
|
+
raise NotFoundError(detail, status_code=status, reason=reason, response=response)
|
|
100
|
+
if status == 409:
|
|
101
|
+
raise ConflictError(detail, status_code=status, reason=reason, response=response)
|
|
102
|
+
raise SubzeroAPIError(detail, status_code=status, reason=reason, response=response)
|
|
103
|
+
|
|
104
|
+
def request(
|
|
105
|
+
self,
|
|
106
|
+
method: str,
|
|
107
|
+
path: str,
|
|
108
|
+
*,
|
|
109
|
+
json_body: dict[str, Any] | None = None,
|
|
110
|
+
headers: dict[str, str] | None = None,
|
|
111
|
+
params: dict[str, Any] | None = None,
|
|
112
|
+
auth: AuthMode = True,
|
|
113
|
+
) -> httpx.Response:
|
|
114
|
+
request_headers = self._headers(headers, auth=auth)
|
|
115
|
+
response = self._client.request(
|
|
116
|
+
method,
|
|
117
|
+
path,
|
|
118
|
+
json=json_body,
|
|
119
|
+
headers=request_headers,
|
|
120
|
+
params=params,
|
|
121
|
+
)
|
|
122
|
+
self._raise_for_status(response)
|
|
123
|
+
return response
|
|
124
|
+
|
|
125
|
+
def get_json(
|
|
126
|
+
self,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
auth: AuthMode = True,
|
|
130
|
+
params: dict[str, Any] | None = None,
|
|
131
|
+
) -> Any:
|
|
132
|
+
response = self.request("GET", path, auth=auth, params=params)
|
|
133
|
+
if not response.content:
|
|
134
|
+
return None
|
|
135
|
+
return response.json()
|
|
136
|
+
|
|
137
|
+
def post_json(
|
|
138
|
+
self,
|
|
139
|
+
path: str,
|
|
140
|
+
body: dict[str, Any] | None = None,
|
|
141
|
+
*,
|
|
142
|
+
auth: AuthMode = True,
|
|
143
|
+
headers: dict[str, str] | None = None,
|
|
144
|
+
) -> Any:
|
|
145
|
+
response = self.request("POST", path, json_body=body, auth=auth, headers=headers)
|
|
146
|
+
if not response.content:
|
|
147
|
+
return None
|
|
148
|
+
return response.json()
|
|
149
|
+
|
|
150
|
+
def patch_json(
|
|
151
|
+
self,
|
|
152
|
+
path: str,
|
|
153
|
+
body: dict[str, Any],
|
|
154
|
+
*,
|
|
155
|
+
auth: AuthMode = True,
|
|
156
|
+
) -> Any:
|
|
157
|
+
response = self.request("PATCH", path, json_body=body, auth=auth)
|
|
158
|
+
if not response.content:
|
|
159
|
+
return None
|
|
160
|
+
return response.json()
|
|
161
|
+
|
|
162
|
+
def delete(self, path: str, *, auth: AuthMode = True) -> None:
|
|
163
|
+
self.request("DELETE", path, auth=auth)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def encode_token_path(token: str) -> str:
|
|
167
|
+
from urllib.parse import quote
|
|
168
|
+
|
|
169
|
+
return quote(token, safe="")
|
|
170
|
+
|
|
171
|
+
def health(self) -> dict[str, Any]:
|
|
172
|
+
return self.get_json("/v1/health", auth="none")
|
|
173
|
+
|
|
174
|
+
def ready(self, base_url: str) -> dict[str, Any]:
|
|
175
|
+
try:
|
|
176
|
+
response = self._client.get("/v1/ready")
|
|
177
|
+
except httpx.RequestError as exc:
|
|
178
|
+
raise SubzeroNotReadyError(
|
|
179
|
+
f"Subzero API not reachable at {base_url} — is `docker compose up` running? "
|
|
180
|
+
f"Migrations applied?",
|
|
181
|
+
) from exc
|
|
182
|
+
|
|
183
|
+
if response.status_code != 200:
|
|
184
|
+
raise SubzeroNotReadyError(
|
|
185
|
+
f"Subzero API not ready at {base_url} — is `docker compose up` running? "
|
|
186
|
+
f"Migrations applied?",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
body = response.json()
|
|
190
|
+
if not isinstance(body, dict) or body.get("status") != "ready":
|
|
191
|
+
raise SubzeroNotReadyError(
|
|
192
|
+
f"Subzero API not ready at {base_url} — is `docker compose up` running? "
|
|
193
|
+
f"Migrations applied?",
|
|
194
|
+
)
|
|
195
|
+
return body
|
subzero/admin.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from subzero._http import HTTPClient
|
|
7
|
+
from subzero.types import (
|
|
8
|
+
ApiKeyResult,
|
|
9
|
+
ApiKeyScope,
|
|
10
|
+
AuditEventResult,
|
|
11
|
+
AuditLogPageResult,
|
|
12
|
+
EntityTypeResult,
|
|
13
|
+
LlmConfigResult,
|
|
14
|
+
PolicyAction,
|
|
15
|
+
PolicyRuleResult,
|
|
16
|
+
PublishableKeyResult,
|
|
17
|
+
TenantResult,
|
|
18
|
+
parse_datetime,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AdminResource:
|
|
23
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
24
|
+
self._http = http
|
|
25
|
+
|
|
26
|
+
def create_tenant(self, *, name: str, slug: str) -> TenantResult:
|
|
27
|
+
body = self._http.post_json(
|
|
28
|
+
"/v1/tenants",
|
|
29
|
+
{"name": name, "slug": slug},
|
|
30
|
+
auth="access_token",
|
|
31
|
+
)
|
|
32
|
+
return TenantResult(
|
|
33
|
+
id=body["id"],
|
|
34
|
+
name=body["name"],
|
|
35
|
+
slug=body["slug"],
|
|
36
|
+
created_at=parse_datetime(body["created_at"]),
|
|
37
|
+
bootstrap_api_key=body.get("bootstrap_api_key"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def get_tenant(self, tenant_id: str) -> TenantResult:
|
|
41
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}", auth="access_token")
|
|
42
|
+
return TenantResult(
|
|
43
|
+
id=body["id"],
|
|
44
|
+
name=body["name"],
|
|
45
|
+
slug=body["slug"],
|
|
46
|
+
created_at=parse_datetime(body["created_at"]),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def create_entity_type(
|
|
50
|
+
self,
|
|
51
|
+
tenant_id: str,
|
|
52
|
+
*,
|
|
53
|
+
name: str,
|
|
54
|
+
description: str | None = None,
|
|
55
|
+
deterministic: bool = False,
|
|
56
|
+
match_pattern: str | None = None,
|
|
57
|
+
) -> EntityTypeResult:
|
|
58
|
+
payload: dict[str, Any] = {
|
|
59
|
+
"name": name,
|
|
60
|
+
"deterministic": deterministic,
|
|
61
|
+
}
|
|
62
|
+
if description is not None:
|
|
63
|
+
payload["description"] = description
|
|
64
|
+
if match_pattern is not None:
|
|
65
|
+
payload["match_pattern"] = match_pattern
|
|
66
|
+
body = self._http.post_json(f"/v1/tenants/{tenant_id}/entity-types", payload)
|
|
67
|
+
return _entity_type_from_body(body)
|
|
68
|
+
|
|
69
|
+
def list_entity_types(self, tenant_id: str) -> list[EntityTypeResult]:
|
|
70
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}/entity-types")
|
|
71
|
+
return [_entity_type_from_body(item) for item in body]
|
|
72
|
+
|
|
73
|
+
def create_policy(
|
|
74
|
+
self,
|
|
75
|
+
tenant_id: str,
|
|
76
|
+
*,
|
|
77
|
+
api_key_id: str,
|
|
78
|
+
entity_type: str,
|
|
79
|
+
action: PolicyAction,
|
|
80
|
+
) -> PolicyRuleResult:
|
|
81
|
+
body = self._http.post_json(
|
|
82
|
+
f"/v1/tenants/{tenant_id}/policies",
|
|
83
|
+
{
|
|
84
|
+
"api_key_id": api_key_id,
|
|
85
|
+
"entity_type": entity_type,
|
|
86
|
+
"action": action,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
return _policy_from_body(body)
|
|
90
|
+
|
|
91
|
+
def list_policies(self, tenant_id: str) -> list[PolicyRuleResult]:
|
|
92
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}/policies")
|
|
93
|
+
return [_policy_from_body(item) for item in body]
|
|
94
|
+
|
|
95
|
+
def delete_policy(self, tenant_id: str, rule_id: str) -> None:
|
|
96
|
+
self._http.delete(f"/v1/tenants/{tenant_id}/policies/{rule_id}")
|
|
97
|
+
|
|
98
|
+
def create_api_key(
|
|
99
|
+
self,
|
|
100
|
+
tenant_id: str,
|
|
101
|
+
*,
|
|
102
|
+
name: str,
|
|
103
|
+
scope: ApiKeyScope,
|
|
104
|
+
) -> ApiKeyResult:
|
|
105
|
+
body = self._http.post_json(
|
|
106
|
+
f"/v1/tenants/{tenant_id}/api-keys",
|
|
107
|
+
{"name": name, "scope": scope},
|
|
108
|
+
)
|
|
109
|
+
return _api_key_from_body(body, include_secret=True)
|
|
110
|
+
|
|
111
|
+
def list_api_keys(self, tenant_id: str) -> list[ApiKeyResult]:
|
|
112
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}/api-keys")
|
|
113
|
+
return [_api_key_from_body(item) for item in body]
|
|
114
|
+
|
|
115
|
+
def revoke_api_key(self, tenant_id: str, key_id: str) -> ApiKeyResult:
|
|
116
|
+
response = self._http.request("DELETE", f"/v1/tenants/{tenant_id}/api-keys/{key_id}")
|
|
117
|
+
return _api_key_from_body(response.json())
|
|
118
|
+
|
|
119
|
+
def create_publishable_key(
|
|
120
|
+
self,
|
|
121
|
+
tenant_id: str,
|
|
122
|
+
*,
|
|
123
|
+
name: str,
|
|
124
|
+
allowed_origins: list[str],
|
|
125
|
+
rate_limit_rpm: int = 60,
|
|
126
|
+
integration_mode: Literal["secure", "lite"] = "secure",
|
|
127
|
+
risk_acknowledged: bool = False,
|
|
128
|
+
) -> PublishableKeyResult:
|
|
129
|
+
payload: dict[str, Any] = {
|
|
130
|
+
"name": name,
|
|
131
|
+
"allowed_origins": allowed_origins,
|
|
132
|
+
"rate_limit_rpm": rate_limit_rpm,
|
|
133
|
+
"integration_mode": integration_mode,
|
|
134
|
+
}
|
|
135
|
+
if integration_mode == "lite":
|
|
136
|
+
payload["risk_acknowledged"] = risk_acknowledged
|
|
137
|
+
body = self._http.post_json(
|
|
138
|
+
f"/v1/tenants/{tenant_id}/publishable-keys",
|
|
139
|
+
payload,
|
|
140
|
+
)
|
|
141
|
+
return _publishable_key_from_body(body)
|
|
142
|
+
|
|
143
|
+
def list_publishable_keys(self, tenant_id: str) -> list[PublishableKeyResult]:
|
|
144
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}/publishable-keys")
|
|
145
|
+
return [_publishable_key_from_body(item) for item in body]
|
|
146
|
+
|
|
147
|
+
def revoke_publishable_key(self, tenant_id: str, key_id: str) -> PublishableKeyResult:
|
|
148
|
+
response = self._http.request(
|
|
149
|
+
"DELETE", f"/v1/tenants/{tenant_id}/publishable-keys/{key_id}"
|
|
150
|
+
)
|
|
151
|
+
return _publishable_key_from_body(response.json())
|
|
152
|
+
|
|
153
|
+
def create_llm_config(
|
|
154
|
+
self,
|
|
155
|
+
tenant_id: str,
|
|
156
|
+
*,
|
|
157
|
+
provider: str = "openai",
|
|
158
|
+
base_url: str,
|
|
159
|
+
api_key: str,
|
|
160
|
+
) -> LlmConfigResult:
|
|
161
|
+
body = self._http.post_json(
|
|
162
|
+
f"/v1/tenants/{tenant_id}/llm-config",
|
|
163
|
+
{
|
|
164
|
+
"provider": provider,
|
|
165
|
+
"base_url": base_url,
|
|
166
|
+
"api_key": api_key,
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
return _llm_config_from_body(body)
|
|
170
|
+
|
|
171
|
+
def get_llm_config(self, tenant_id: str) -> LlmConfigResult | None:
|
|
172
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}/llm-config")
|
|
173
|
+
if body is None:
|
|
174
|
+
return None
|
|
175
|
+
return _llm_config_from_body(body)
|
|
176
|
+
|
|
177
|
+
def list_audit_events(
|
|
178
|
+
self,
|
|
179
|
+
tenant_id: str,
|
|
180
|
+
*,
|
|
181
|
+
limit: int = 50,
|
|
182
|
+
offset: int = 0,
|
|
183
|
+
order: Literal["asc", "desc"] = "desc",
|
|
184
|
+
event_type: str | None = None,
|
|
185
|
+
actor: str | None = None,
|
|
186
|
+
date_from: datetime | None = None,
|
|
187
|
+
date_to: datetime | None = None,
|
|
188
|
+
) -> AuditLogPageResult:
|
|
189
|
+
params: dict[str, Any] = {
|
|
190
|
+
"limit": limit,
|
|
191
|
+
"offset": offset,
|
|
192
|
+
"order": order,
|
|
193
|
+
}
|
|
194
|
+
if event_type is not None:
|
|
195
|
+
params["event_type"] = event_type
|
|
196
|
+
if actor is not None:
|
|
197
|
+
params["actor"] = actor
|
|
198
|
+
if date_from is not None:
|
|
199
|
+
params["date_from"] = date_from.isoformat()
|
|
200
|
+
if date_to is not None:
|
|
201
|
+
params["date_to"] = date_to.isoformat()
|
|
202
|
+
|
|
203
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}/audit", params=params)
|
|
204
|
+
return _audit_log_page_from_body(body)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _entity_type_from_body(body: dict[str, Any]) -> EntityTypeResult:
|
|
208
|
+
return EntityTypeResult(
|
|
209
|
+
id=body["id"],
|
|
210
|
+
name=body["name"],
|
|
211
|
+
description=body.get("description"),
|
|
212
|
+
deterministic=body["deterministic"],
|
|
213
|
+
match_pattern=body.get("match_pattern"),
|
|
214
|
+
created_at=parse_datetime(body["created_at"]),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _policy_from_body(body: dict[str, Any]) -> PolicyRuleResult:
|
|
219
|
+
return PolicyRuleResult(
|
|
220
|
+
id=body["id"],
|
|
221
|
+
principal=body["principal"],
|
|
222
|
+
entity_type=body["entity_type"],
|
|
223
|
+
action=body["action"],
|
|
224
|
+
created_at=parse_datetime(body["created_at"]),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _api_key_from_body(body: dict[str, Any], *, include_secret: bool = False) -> ApiKeyResult:
|
|
229
|
+
return ApiKeyResult(
|
|
230
|
+
id=body["id"],
|
|
231
|
+
name=body["name"],
|
|
232
|
+
prefix=body["prefix"],
|
|
233
|
+
scope=body["scope"],
|
|
234
|
+
revoked_at=parse_datetime(body["revoked_at"]) if body.get("revoked_at") else None,
|
|
235
|
+
created_at=parse_datetime(body["created_at"]),
|
|
236
|
+
secret=body.get("secret") if include_secret else None,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _publishable_key_from_body(body: dict[str, Any]) -> PublishableKeyResult:
|
|
241
|
+
return PublishableKeyResult(
|
|
242
|
+
id=body["id"],
|
|
243
|
+
name=body["name"],
|
|
244
|
+
key=body["key"],
|
|
245
|
+
allowed_origins=body["allowed_origins"],
|
|
246
|
+
rate_limit_rpm=body["rate_limit_rpm"],
|
|
247
|
+
integration_mode=body.get("integration_mode", "secure"),
|
|
248
|
+
revoked_at=parse_datetime(body["revoked_at"]) if body.get("revoked_at") else None,
|
|
249
|
+
created_at=parse_datetime(body["created_at"]),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _llm_config_from_body(body: dict[str, Any]) -> LlmConfigResult:
|
|
254
|
+
return LlmConfigResult(
|
|
255
|
+
id=body["id"],
|
|
256
|
+
provider=body["provider"],
|
|
257
|
+
base_url=body["base_url"],
|
|
258
|
+
is_active=body["is_active"],
|
|
259
|
+
created_at=parse_datetime(body["created_at"]),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _audit_event_from_body(body: dict[str, Any]) -> AuditEventResult:
|
|
264
|
+
return AuditEventResult(
|
|
265
|
+
id=body["id"],
|
|
266
|
+
tenant_id=body["tenant_id"],
|
|
267
|
+
event_type=body["event_type"],
|
|
268
|
+
actor=body["actor"],
|
|
269
|
+
resource=body.get("resource"),
|
|
270
|
+
outcome=body["outcome"],
|
|
271
|
+
metadata=body.get("metadata"),
|
|
272
|
+
created_at=parse_datetime(body["created_at"]),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _audit_log_page_from_body(body: dict[str, Any]) -> AuditLogPageResult:
|
|
277
|
+
return AuditLogPageResult(
|
|
278
|
+
items=[_audit_event_from_body(item) for item in body["items"]],
|
|
279
|
+
total=body["total"],
|
|
280
|
+
limit=body["limit"],
|
|
281
|
+
offset=body["offset"],
|
|
282
|
+
)
|
subzero/auth.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from subzero._http import HTTPClient
|
|
4
|
+
from subzero.exceptions import SubzeroAPIError
|
|
5
|
+
from subzero.types import (
|
|
6
|
+
AcceptInviteResult,
|
|
7
|
+
LoginResult,
|
|
8
|
+
MeResult,
|
|
9
|
+
MfaEnrollConfirmResult,
|
|
10
|
+
MfaEnrollStartResult,
|
|
11
|
+
TokenResult,
|
|
12
|
+
parse_datetime,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _totp_code(secret: str) -> str:
|
|
17
|
+
try:
|
|
18
|
+
import pyotp
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise SubzeroAPIError(
|
|
21
|
+
"pyotp is required to generate TOTP codes — pip install 'subzero[auth]' "
|
|
22
|
+
"or pass totp_code explicitly.",
|
|
23
|
+
) from exc
|
|
24
|
+
return pyotp.TOTP(secret).now()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthResource:
|
|
28
|
+
"""Dashboard human auth — login, MFA, refresh (cookie-based), password reset."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
31
|
+
self._http = http
|
|
32
|
+
|
|
33
|
+
def login(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
email: str,
|
|
37
|
+
password: str,
|
|
38
|
+
tenant_slug: str | None = None,
|
|
39
|
+
) -> LoginResult:
|
|
40
|
+
payload: dict[str, str] = {"email": email, "password": password}
|
|
41
|
+
if tenant_slug is not None:
|
|
42
|
+
payload["tenant_slug"] = tenant_slug
|
|
43
|
+
body = self._http.post_json("/v1/auth/login", payload, auth="none")
|
|
44
|
+
return LoginResult(
|
|
45
|
+
access_token=body.get("access_token"),
|
|
46
|
+
pre_auth_token=body.get("pre_auth_token"),
|
|
47
|
+
mfa_required=body.get("mfa_required", False),
|
|
48
|
+
mfa_enrollment_required=body.get("mfa_enrollment_required", False),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def mfa_verify(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
pre_auth_token: str,
|
|
55
|
+
code: str | None = None,
|
|
56
|
+
recovery_code: str | None = None,
|
|
57
|
+
) -> TokenResult:
|
|
58
|
+
payload: dict[str, str] = {"pre_auth_token": pre_auth_token}
|
|
59
|
+
if code is not None:
|
|
60
|
+
payload["code"] = code
|
|
61
|
+
if recovery_code is not None:
|
|
62
|
+
payload["recovery_code"] = recovery_code
|
|
63
|
+
response = self._http._client.post(
|
|
64
|
+
"/v1/auth/mfa/verify",
|
|
65
|
+
json=payload,
|
|
66
|
+
headers={"Content-Type": "application/json"},
|
|
67
|
+
)
|
|
68
|
+
self._http._raise_for_status(response)
|
|
69
|
+
body = response.json()
|
|
70
|
+
token = TokenResult(access_token=body["access_token"])
|
|
71
|
+
self._http.set_access_token(token.access_token)
|
|
72
|
+
return token
|
|
73
|
+
|
|
74
|
+
def mfa_enroll_start(self, *, pre_auth_token: str) -> MfaEnrollStartResult:
|
|
75
|
+
body = self._http.post_json(
|
|
76
|
+
"/v1/auth/mfa/enroll/start",
|
|
77
|
+
{"pre_auth_token": pre_auth_token},
|
|
78
|
+
auth="none",
|
|
79
|
+
)
|
|
80
|
+
return MfaEnrollStartResult(
|
|
81
|
+
otpauth_uri=body["otpauth_uri"],
|
|
82
|
+
secret=body.get("secret"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def mfa_enroll_confirm(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
pre_auth_token: str,
|
|
89
|
+
code: str,
|
|
90
|
+
) -> MfaEnrollConfirmResult:
|
|
91
|
+
response = self._http._client.post(
|
|
92
|
+
"/v1/auth/mfa/enroll/confirm",
|
|
93
|
+
json={"pre_auth_token": pre_auth_token, "code": code},
|
|
94
|
+
headers={"Content-Type": "application/json"},
|
|
95
|
+
)
|
|
96
|
+
self._http._raise_for_status(response)
|
|
97
|
+
body = response.json()
|
|
98
|
+
result = MfaEnrollConfirmResult(
|
|
99
|
+
access_token=body["access_token"],
|
|
100
|
+
recovery_codes=body["recovery_codes"],
|
|
101
|
+
)
|
|
102
|
+
self._http.set_access_token(result.access_token)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
def refresh(self) -> TokenResult:
|
|
106
|
+
response = self._http._client.post("/v1/auth/refresh")
|
|
107
|
+
self._http._raise_for_status(response)
|
|
108
|
+
body = response.json()
|
|
109
|
+
token = TokenResult(access_token=body["access_token"])
|
|
110
|
+
self._http.set_access_token(token.access_token)
|
|
111
|
+
return token
|
|
112
|
+
|
|
113
|
+
def logout(self) -> None:
|
|
114
|
+
self._http.post_json("/v1/auth/logout", {}, auth="access_token")
|
|
115
|
+
self._http.set_access_token(None)
|
|
116
|
+
|
|
117
|
+
def me(self) -> MeResult:
|
|
118
|
+
body = self._http.get_json("/v1/auth/me", auth="access_token")
|
|
119
|
+
return MeResult(
|
|
120
|
+
principal_type=body["principal_type"],
|
|
121
|
+
id=body["id"],
|
|
122
|
+
email=body["email"],
|
|
123
|
+
role=body["role"],
|
|
124
|
+
tenant_id=body.get("tenant_id"),
|
|
125
|
+
mfa_verified=body["mfa_verified"],
|
|
126
|
+
mfa_enrolled=body["mfa_enrolled"],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def accept_invite(self, *, token: str, password: str) -> AcceptInviteResult:
|
|
130
|
+
body = self._http.post_json(
|
|
131
|
+
"/v1/auth/accept-invite",
|
|
132
|
+
{"token": token, "password": password},
|
|
133
|
+
auth="none",
|
|
134
|
+
)
|
|
135
|
+
return AcceptInviteResult(
|
|
136
|
+
pre_auth_token=body["pre_auth_token"],
|
|
137
|
+
mfa_enrollment_required=body.get("mfa_enrollment_required", True),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def request_password_reset(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
email: str,
|
|
144
|
+
tenant_slug: str | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
payload: dict[str, str] = {"email": email}
|
|
147
|
+
if tenant_slug is not None:
|
|
148
|
+
payload["tenant_slug"] = tenant_slug
|
|
149
|
+
self._http.post_json("/v1/auth/password/reset/request", payload, auth="none")
|
|
150
|
+
|
|
151
|
+
def confirm_password_reset(self, *, token: str, new_password: str) -> None:
|
|
152
|
+
self._http.post_json(
|
|
153
|
+
"/v1/auth/password/reset/confirm",
|
|
154
|
+
{"token": token, "new_password": new_password},
|
|
155
|
+
auth="none",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def login_platform(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
email: str,
|
|
162
|
+
password: str,
|
|
163
|
+
totp_code: str | None = None,
|
|
164
|
+
totp_secret: str | None = None,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Log in as platform admin with mandatory MFA; returns and stores access JWT."""
|
|
167
|
+
return self._complete_login(
|
|
168
|
+
email=email,
|
|
169
|
+
password=password,
|
|
170
|
+
tenant_slug=None,
|
|
171
|
+
totp_code=totp_code,
|
|
172
|
+
totp_secret=totp_secret,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def login_tenant(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
email: str,
|
|
179
|
+
password: str,
|
|
180
|
+
tenant_slug: str,
|
|
181
|
+
totp_code: str | None = None,
|
|
182
|
+
totp_secret: str | None = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Log in as a tenant member/admin; returns and stores access JWT."""
|
|
185
|
+
return self._complete_login(
|
|
186
|
+
email=email,
|
|
187
|
+
password=password,
|
|
188
|
+
tenant_slug=tenant_slug,
|
|
189
|
+
totp_code=totp_code,
|
|
190
|
+
totp_secret=totp_secret,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _complete_login(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
email: str,
|
|
197
|
+
password: str,
|
|
198
|
+
tenant_slug: str | None,
|
|
199
|
+
totp_code: str | None,
|
|
200
|
+
totp_secret: str | None,
|
|
201
|
+
) -> str:
|
|
202
|
+
login = self.login(email=email, password=password, tenant_slug=tenant_slug)
|
|
203
|
+
if login.access_token:
|
|
204
|
+
self._http.set_access_token(login.access_token)
|
|
205
|
+
return login.access_token
|
|
206
|
+
|
|
207
|
+
pre_auth = login.pre_auth_token
|
|
208
|
+
if not pre_auth:
|
|
209
|
+
raise SubzeroAPIError("Login did not return a pre_auth_token")
|
|
210
|
+
|
|
211
|
+
code = totp_code
|
|
212
|
+
if code is None and totp_secret is not None:
|
|
213
|
+
code = _totp_code(totp_secret)
|
|
214
|
+
|
|
215
|
+
if login.mfa_enrollment_required:
|
|
216
|
+
if code is None:
|
|
217
|
+
enroll = self.mfa_enroll_start(pre_auth_token=pre_auth)
|
|
218
|
+
raise SubzeroAPIError(
|
|
219
|
+
"MFA enrollment required. Scan otpauth_uri and call mfa_enroll_confirm, "
|
|
220
|
+
f"or pass totp_code/totp_secret. Dev secret: {enroll.secret!r}"
|
|
221
|
+
)
|
|
222
|
+
result = self.mfa_enroll_confirm(pre_auth_token=pre_auth, code=code)
|
|
223
|
+
return result.access_token
|
|
224
|
+
|
|
225
|
+
if login.mfa_required:
|
|
226
|
+
if code is None:
|
|
227
|
+
raise SubzeroAPIError(
|
|
228
|
+
"MFA verification required — pass totp_code or totp_secret"
|
|
229
|
+
)
|
|
230
|
+
return self.mfa_verify(pre_auth_token=pre_auth, code=code).access_token
|
|
231
|
+
|
|
232
|
+
raise SubzeroAPIError("Unexpected login response — MFA expected")
|
subzero/client.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from subzero._http import HTTPClient
|
|
4
|
+
from subzero.admin import AdminResource
|
|
5
|
+
from subzero.auth import AuthResource
|
|
6
|
+
from subzero.members import MembersResource
|
|
7
|
+
from subzero.proxy import ProxyResource
|
|
8
|
+
from subzero.types import RevealGrantResult, RevealResult, SearchResult, TokenizeResult, parse_datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SubzeroClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
api_key: str | None = None,
|
|
15
|
+
*,
|
|
16
|
+
access_token: str | None = None,
|
|
17
|
+
base_url: str = "http://127.0.0.1:8000",
|
|
18
|
+
timeout: float = 60.0,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._base_url = base_url.rstrip("/")
|
|
21
|
+
self._http = HTTPClient(
|
|
22
|
+
api_key=api_key,
|
|
23
|
+
access_token=access_token,
|
|
24
|
+
base_url=self._base_url,
|
|
25
|
+
timeout=timeout,
|
|
26
|
+
)
|
|
27
|
+
self.auth = AuthResource(self._http)
|
|
28
|
+
self.admin = AdminResource(self._http)
|
|
29
|
+
self.members = MembersResource(self._http)
|
|
30
|
+
self.proxy = ProxyResource(self._http)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def access_token(self) -> str | None:
|
|
34
|
+
return self._http.access_token
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
self._http.close()
|
|
38
|
+
|
|
39
|
+
def __enter__(self) -> SubzeroClient:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *args: object) -> None:
|
|
43
|
+
self.close()
|
|
44
|
+
|
|
45
|
+
def health(self) -> dict:
|
|
46
|
+
return self._http.health()
|
|
47
|
+
|
|
48
|
+
def ready(self) -> dict:
|
|
49
|
+
return self._http.ready(self._base_url)
|
|
50
|
+
|
|
51
|
+
def tokenize(self, entity_type: str, value: str) -> TokenizeResult:
|
|
52
|
+
body = self._http.post_json(
|
|
53
|
+
"/v1/tokenize",
|
|
54
|
+
{"entity_type": entity_type, "value": value},
|
|
55
|
+
auth="api_key",
|
|
56
|
+
)
|
|
57
|
+
return TokenizeResult(token=body["token"], entity_type=body["entity_type"])
|
|
58
|
+
|
|
59
|
+
def search(self, entity_type: str, value: str) -> SearchResult:
|
|
60
|
+
body = self._http.post_json(
|
|
61
|
+
"/v1/search",
|
|
62
|
+
{"entity_type": entity_type, "value": value},
|
|
63
|
+
auth="api_key",
|
|
64
|
+
)
|
|
65
|
+
return SearchResult(token=body["token"])
|
|
66
|
+
|
|
67
|
+
def reveal(self, token: str) -> RevealResult:
|
|
68
|
+
body = self._http.post_json("/v1/reveal", {"token": token}, auth="api_key")
|
|
69
|
+
return RevealResult(
|
|
70
|
+
token=body["token"],
|
|
71
|
+
entity_type=body["entity_type"],
|
|
72
|
+
value=body["value"],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def create_reveal_grant(
|
|
76
|
+
self,
|
|
77
|
+
token: str,
|
|
78
|
+
*,
|
|
79
|
+
client_public_key_jwk: dict,
|
|
80
|
+
allowed_origin: str | None = None,
|
|
81
|
+
) -> RevealGrantResult:
|
|
82
|
+
payload: dict[str, object] = {
|
|
83
|
+
"token": token,
|
|
84
|
+
"client_public_key_jwk": client_public_key_jwk,
|
|
85
|
+
}
|
|
86
|
+
if allowed_origin is not None:
|
|
87
|
+
payload["allowed_origin"] = allowed_origin
|
|
88
|
+
body = self._http.post_json("/v1/reveal/grants", payload, auth="api_key")
|
|
89
|
+
return RevealGrantResult(
|
|
90
|
+
grant_id=body["grant_id"],
|
|
91
|
+
expires_at=parse_datetime(body["expires_at"]),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def delete_token(self, token: str) -> None:
|
|
95
|
+
encoded = HTTPClient.encode_token_path(token)
|
|
96
|
+
self._http.delete(f"/v1/tokens/{encoded}", auth="api_key")
|
subzero/exceptions.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SubzeroAPIError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: int | None = None,
|
|
12
|
+
reason: str | None = None,
|
|
13
|
+
response: Any | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.reason = reason
|
|
19
|
+
self.response = response
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthenticationError(SubzeroAPIError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthorizationError(SubzeroAPIError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PolicyDeniedError(SubzeroAPIError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotFoundError(SubzeroAPIError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConflictError(SubzeroAPIError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SubzeroNotReadyError(SubzeroAPIError):
|
|
43
|
+
pass
|
subzero/members.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from subzero._http import HTTPClient
|
|
6
|
+
from subzero.types import MemberResult, parse_datetime
|
|
7
|
+
|
|
8
|
+
MemberRole = Literal["admin", "member"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MembersResource:
|
|
12
|
+
"""Tenant member invites and management (tenant admin or platform admin JWT)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def invite(
|
|
18
|
+
self,
|
|
19
|
+
tenant_id: str,
|
|
20
|
+
*,
|
|
21
|
+
email: str,
|
|
22
|
+
role: MemberRole = "member",
|
|
23
|
+
) -> MemberResult:
|
|
24
|
+
body = self._http.post_json(
|
|
25
|
+
f"/v1/tenants/{tenant_id}/members",
|
|
26
|
+
{"email": email, "role": role},
|
|
27
|
+
auth="access_token",
|
|
28
|
+
)
|
|
29
|
+
return _member_from_body(body)
|
|
30
|
+
|
|
31
|
+
def list(self, tenant_id: str) -> list[MemberResult]:
|
|
32
|
+
body = self._http.get_json(f"/v1/tenants/{tenant_id}/members", auth="access_token")
|
|
33
|
+
return [_member_from_body(item) for item in body]
|
|
34
|
+
|
|
35
|
+
def update(
|
|
36
|
+
self,
|
|
37
|
+
tenant_id: str,
|
|
38
|
+
member_id: str,
|
|
39
|
+
*,
|
|
40
|
+
role: MemberRole | None = None,
|
|
41
|
+
disabled: bool | None = None,
|
|
42
|
+
) -> MemberResult:
|
|
43
|
+
payload: dict[str, Any] = {}
|
|
44
|
+
if role is not None:
|
|
45
|
+
payload["role"] = role
|
|
46
|
+
if disabled is not None:
|
|
47
|
+
payload["disabled"] = disabled
|
|
48
|
+
body = self._http.patch_json(
|
|
49
|
+
f"/v1/tenants/{tenant_id}/members/{member_id}",
|
|
50
|
+
payload,
|
|
51
|
+
auth="access_token",
|
|
52
|
+
)
|
|
53
|
+
return _member_from_body(body)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _member_from_body(body: dict[str, Any]) -> MemberResult:
|
|
57
|
+
return MemberResult(
|
|
58
|
+
id=body["id"],
|
|
59
|
+
email=body["email"],
|
|
60
|
+
role=body["role"],
|
|
61
|
+
status=body["status"],
|
|
62
|
+
mfa_enrolled_at=parse_datetime(body["mfa_enrolled_at"])
|
|
63
|
+
if body.get("mfa_enrolled_at")
|
|
64
|
+
else None,
|
|
65
|
+
invited_at=parse_datetime(body["invited_at"]) if body.get("invited_at") else None,
|
|
66
|
+
last_login_at=parse_datetime(body["last_login_at"])
|
|
67
|
+
if body.get("last_login_at")
|
|
68
|
+
else None,
|
|
69
|
+
created_at=parse_datetime(body["created_at"]),
|
|
70
|
+
invite_token=body.get("invite_token"),
|
|
71
|
+
)
|
subzero/openai.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_openai_client(
|
|
7
|
+
*,
|
|
8
|
+
api_key: str,
|
|
9
|
+
base_url: str,
|
|
10
|
+
detokenize: bool = False,
|
|
11
|
+
**kwargs: Any,
|
|
12
|
+
):
|
|
13
|
+
try:
|
|
14
|
+
from openai import OpenAI
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"Install the openai extra: pip install 'subzero[openai]'",
|
|
18
|
+
) from exc
|
|
19
|
+
|
|
20
|
+
default_headers: dict[str, str] = {}
|
|
21
|
+
if detokenize:
|
|
22
|
+
default_headers["X-Subzero-Detokenize"] = "true"
|
|
23
|
+
|
|
24
|
+
client_kwargs = dict(kwargs)
|
|
25
|
+
if default_headers:
|
|
26
|
+
existing = client_kwargs.pop("default_headers", {})
|
|
27
|
+
client_kwargs["default_headers"] = {**existing, **default_headers}
|
|
28
|
+
|
|
29
|
+
return OpenAI(api_key=api_key, base_url=base_url, **client_kwargs)
|
subzero/proxy.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from subzero._http import HTTPClient
|
|
6
|
+
from subzero.exceptions import SubzeroAPIError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProxyChatResource:
|
|
10
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
11
|
+
self._http = http
|
|
12
|
+
|
|
13
|
+
def completions(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
model: str,
|
|
17
|
+
messages: list[dict[str, Any]],
|
|
18
|
+
stream: bool = False,
|
|
19
|
+
detokenize: bool = False,
|
|
20
|
+
**extra: Any,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
if stream:
|
|
23
|
+
raise SubzeroAPIError(
|
|
24
|
+
"Streaming is not supported in subzero SDK v1 — pass stream=False",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
headers: dict[str, str] = {}
|
|
28
|
+
if detokenize:
|
|
29
|
+
headers["X-Subzero-Detokenize"] = "true"
|
|
30
|
+
|
|
31
|
+
payload: dict[str, Any] = {
|
|
32
|
+
"model": model,
|
|
33
|
+
"messages": messages,
|
|
34
|
+
"stream": False,
|
|
35
|
+
**extra,
|
|
36
|
+
}
|
|
37
|
+
body = self._http.post_json(
|
|
38
|
+
"/v1/chat/completions",
|
|
39
|
+
payload,
|
|
40
|
+
headers=headers or None,
|
|
41
|
+
)
|
|
42
|
+
if not isinstance(body, dict):
|
|
43
|
+
raise SubzeroAPIError("Expected JSON object from chat/completions")
|
|
44
|
+
return body
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProxyResource:
|
|
48
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
49
|
+
self.chat = ProxyChatResource(http)
|
subzero/types.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class TokenizeResult:
|
|
10
|
+
token: str
|
|
11
|
+
entity_type: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class SearchResult:
|
|
16
|
+
token: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RevealResult:
|
|
21
|
+
token: str
|
|
22
|
+
entity_type: str
|
|
23
|
+
value: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TenantResult:
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
slug: str
|
|
31
|
+
created_at: datetime
|
|
32
|
+
bootstrap_api_key: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class EntityTypeResult:
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
description: str | None
|
|
40
|
+
deterministic: bool
|
|
41
|
+
match_pattern: str | None
|
|
42
|
+
created_at: datetime
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PolicyRuleResult:
|
|
47
|
+
id: str
|
|
48
|
+
principal: str
|
|
49
|
+
entity_type: str
|
|
50
|
+
action: str
|
|
51
|
+
created_at: datetime
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ApiKeyResult:
|
|
56
|
+
id: str
|
|
57
|
+
name: str
|
|
58
|
+
prefix: str
|
|
59
|
+
scope: str
|
|
60
|
+
revoked_at: datetime | None
|
|
61
|
+
created_at: datetime
|
|
62
|
+
secret: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class AuditEventResult:
|
|
67
|
+
id: str
|
|
68
|
+
tenant_id: str
|
|
69
|
+
event_type: str
|
|
70
|
+
actor: str
|
|
71
|
+
resource: str | None
|
|
72
|
+
outcome: str
|
|
73
|
+
metadata: dict[str, Any] | None
|
|
74
|
+
created_at: datetime
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class AuditLogPageResult:
|
|
79
|
+
items: list[AuditEventResult]
|
|
80
|
+
total: int
|
|
81
|
+
limit: int
|
|
82
|
+
offset: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class LlmConfigResult:
|
|
87
|
+
id: str
|
|
88
|
+
provider: str
|
|
89
|
+
base_url: str
|
|
90
|
+
is_active: bool
|
|
91
|
+
created_at: datetime
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class RevealGrantResult:
|
|
96
|
+
grant_id: str
|
|
97
|
+
expires_at: datetime
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class PublishableKeyResult:
|
|
102
|
+
id: str
|
|
103
|
+
name: str
|
|
104
|
+
key: str
|
|
105
|
+
allowed_origins: list[str]
|
|
106
|
+
rate_limit_rpm: int
|
|
107
|
+
integration_mode: str
|
|
108
|
+
revoked_at: datetime | None
|
|
109
|
+
created_at: datetime
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
ApiKeyScope = Literal["tokenize", "reveal", "proxy", "admin"]
|
|
113
|
+
PolicyAction = Literal["reveal", "delete"]
|
|
114
|
+
MemberRole = Literal["admin", "member"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class LoginResult:
|
|
119
|
+
access_token: str | None
|
|
120
|
+
pre_auth_token: str | None
|
|
121
|
+
mfa_required: bool
|
|
122
|
+
mfa_enrollment_required: bool
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class TokenResult:
|
|
127
|
+
access_token: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class MfaEnrollStartResult:
|
|
132
|
+
otpauth_uri: str
|
|
133
|
+
secret: str | None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class MfaEnrollConfirmResult:
|
|
138
|
+
access_token: str
|
|
139
|
+
recovery_codes: list[str]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class AcceptInviteResult:
|
|
144
|
+
pre_auth_token: str
|
|
145
|
+
mfa_enrollment_required: bool
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class MeResult:
|
|
150
|
+
principal_type: Literal["platform_user", "member"]
|
|
151
|
+
id: str
|
|
152
|
+
email: str
|
|
153
|
+
role: str
|
|
154
|
+
tenant_id: str | None
|
|
155
|
+
mfa_verified: bool
|
|
156
|
+
mfa_enrolled: bool
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class MemberResult:
|
|
161
|
+
id: str
|
|
162
|
+
email: str
|
|
163
|
+
role: str
|
|
164
|
+
status: str
|
|
165
|
+
mfa_enrolled_at: datetime | None
|
|
166
|
+
invited_at: datetime | None
|
|
167
|
+
last_login_at: datetime | None
|
|
168
|
+
created_at: datetime
|
|
169
|
+
invite_token: str | None = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_datetime(value: str) -> datetime:
|
|
173
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def chat_completion_dict(**kwargs: Any) -> dict[str, Any]:
|
|
177
|
+
return dict(kwargs)
|