sovaria-sdk 0.2.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.
- sovaria/__init__.py +105 -0
- sovaria/auth.py +187 -0
- sovaria/client.py +2527 -0
- sovaria/complete_api.py +1066 -0
- sovaria/config.py +91 -0
- sovaria/data.py +198 -0
- sovaria/models.py +165 -0
- sovaria/project.py +374 -0
- sovaria/utils/__init__.py +1 -0
- sovaria/utils/crypto.py +272 -0
- sovaria_sdk-0.2.0.dist-info/METADATA +173 -0
- sovaria_sdk-0.2.0.dist-info/RECORD +13 -0
- sovaria_sdk-0.2.0.dist-info/WHEEL +4 -0
sovaria/__init__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sovaria — Python SDK for the Sovaria BaaS platform.
|
|
3
|
+
|
|
4
|
+
Phase 1 §9 Sprint 3.2
|
|
5
|
+
|
|
6
|
+
Quick start:
|
|
7
|
+
from sovaria import SovariaClient
|
|
8
|
+
|
|
9
|
+
async with SovariaClient("https://api.sovaria.in") as sov:
|
|
10
|
+
tokens = await sov.login("you@example.com", "password")
|
|
11
|
+
org = await sov.create_node("ORG", "Acme Corp")
|
|
12
|
+
proj = await sov.create_node("PROJECT", "Main App")
|
|
13
|
+
_ = await sov.create_edge(org.node_id, proj.node_id, "OWNS")
|
|
14
|
+
ctx = await sov.get_context(tokens.user_id, proj.node_id)
|
|
15
|
+
print(ctx.roles) # ['owner']
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .client import SovariaClient, SovariaSyncClient
|
|
22
|
+
from .complete_api import CompleteAPIMixin
|
|
23
|
+
from .config import SovariaPortalConfig
|
|
24
|
+
from .data import DataClient
|
|
25
|
+
from .models import (
|
|
26
|
+
ContextManifest,
|
|
27
|
+
EdgeResponse,
|
|
28
|
+
NodeResponse,
|
|
29
|
+
NodeType,
|
|
30
|
+
RelationType,
|
|
31
|
+
SovariaError,
|
|
32
|
+
TokenResponse,
|
|
33
|
+
)
|
|
34
|
+
from .project import (
|
|
35
|
+
DEFAULT_CONFIG_FILENAME,
|
|
36
|
+
SovariaProjectConfig,
|
|
37
|
+
SovariaProjectScaffoldResult,
|
|
38
|
+
client_from_project_config,
|
|
39
|
+
create_project_from_config,
|
|
40
|
+
create_project_from_config_file,
|
|
41
|
+
default_project_config,
|
|
42
|
+
load_project_config,
|
|
43
|
+
write_project_config_template,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_client(config: SovariaPortalConfig | dict[str, Any]) -> SovariaClient:
|
|
48
|
+
"""Create a Sovaria client from portal-style configuration."""
|
|
49
|
+
return SovariaClient.from_portal_config(config)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_sync_client(config: SovariaPortalConfig | dict[str, Any]) -> SovariaSyncClient:
|
|
53
|
+
"""Create a synchronous Sovaria client from portal-style configuration."""
|
|
54
|
+
return SovariaSyncClient.from_portal_config(config)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def client_from_config_file(
|
|
58
|
+
config_path: str | Path | None = None,
|
|
59
|
+
*,
|
|
60
|
+
access_token: str | None = None,
|
|
61
|
+
) -> SovariaClient:
|
|
62
|
+
"""Create a client from sovaria.config.json (walks up from cwd when path omitted)."""
|
|
63
|
+
bootstrap = client_from_project_config(config_path, access_token=access_token)
|
|
64
|
+
return create_client(bootstrap)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sync_client_from_config_file(
|
|
68
|
+
config_path: str | Path | None = None,
|
|
69
|
+
*,
|
|
70
|
+
access_token: str | None = None,
|
|
71
|
+
) -> SovariaSyncClient:
|
|
72
|
+
"""Synchronous variant of client_from_config_file."""
|
|
73
|
+
bootstrap = client_from_project_config(config_path, access_token=access_token)
|
|
74
|
+
return create_sync_client(bootstrap)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"SovariaClient",
|
|
79
|
+
"SovariaSyncClient",
|
|
80
|
+
"CompleteAPIMixin",
|
|
81
|
+
"SovariaPortalConfig",
|
|
82
|
+
"create_client",
|
|
83
|
+
"create_sync_client",
|
|
84
|
+
"client_from_config_file",
|
|
85
|
+
"sync_client_from_config_file",
|
|
86
|
+
"client_from_project_config",
|
|
87
|
+
"DataClient",
|
|
88
|
+
"TokenResponse",
|
|
89
|
+
"NodeResponse",
|
|
90
|
+
"EdgeResponse",
|
|
91
|
+
"ContextManifest",
|
|
92
|
+
"SovariaError",
|
|
93
|
+
"NodeType",
|
|
94
|
+
"RelationType",
|
|
95
|
+
"DEFAULT_CONFIG_FILENAME",
|
|
96
|
+
"SovariaProjectConfig",
|
|
97
|
+
"SovariaProjectScaffoldResult",
|
|
98
|
+
"create_project_from_config",
|
|
99
|
+
"create_project_from_config_file",
|
|
100
|
+
"default_project_config",
|
|
101
|
+
"load_project_config",
|
|
102
|
+
"write_project_config_template",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
__version__ = "0.2.0"
|
sovaria/auth.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sovaria.auth — Token storage and automatic refresh for the Python SDK.
|
|
3
|
+
|
|
4
|
+
Phase 1 §9 Sprint 3.2: "SDK stores tokens in memory; CLI stores them via keyring."
|
|
5
|
+
|
|
6
|
+
The AuthManager is injected into SovariaClient and handles:
|
|
7
|
+
1. Storing access_token / refresh_token in memory.
|
|
8
|
+
2. Checking expiry before each request (based on `expires_in`).
|
|
9
|
+
3. Calling `POST /api/v1/auth/refresh` to obtain a new pair when the
|
|
10
|
+
access token is within `_refresh_margin_seconds` of expiry.
|
|
11
|
+
|
|
12
|
+
Thread-safety: asyncio.Lock guards the refresh critical section so that
|
|
13
|
+
concurrent coroutines do not trigger multiple simultaneous refreshes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import time
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from sovaria.models import SovariaError, TokenResponse
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthManager:
|
|
31
|
+
"""
|
|
32
|
+
In-memory token manager for the SDK.
|
|
33
|
+
|
|
34
|
+
The `refresh_margin_seconds` window ensures we refresh before
|
|
35
|
+
expiry rather than on the first 401, avoiding a failed request.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_refresh_margin_seconds: int = 60 # refresh when < 60 s remain
|
|
39
|
+
|
|
40
|
+
def __init__(self, base_url: str) -> None:
|
|
41
|
+
self._base_url = base_url.rstrip("/")
|
|
42
|
+
self._access_token: str | None = None
|
|
43
|
+
self._refresh_token: str | None = None
|
|
44
|
+
self._expires_at: float = 0.0 # Unix timestamp
|
|
45
|
+
self._lock = asyncio.Lock()
|
|
46
|
+
# Phase 2 Sprint 1: KEK cache — derived once per session via Argon2id.
|
|
47
|
+
# Stays in RAM; never serialised, never sent over the wire.
|
|
48
|
+
self._kek: bytes | None = None # 256-bit Key Encryption Key
|
|
49
|
+
self._argon2_salt: bytes | None = None # 128-bit user salt (from server)
|
|
50
|
+
|
|
51
|
+
# ── Token lifecycle methods ────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def set_tokens(self, resp: TokenResponse) -> None:
|
|
54
|
+
"""Store token pair returned by login / refresh."""
|
|
55
|
+
if not resp.access_token:
|
|
56
|
+
raise SovariaError(400, "Missing access token in auth response")
|
|
57
|
+
|
|
58
|
+
self._access_token = resp.access_token
|
|
59
|
+
if resp.mfa_required:
|
|
60
|
+
self._refresh_token = None
|
|
61
|
+
ttl = resp.expires_in if resp.expires_in is not None else 300
|
|
62
|
+
self._expires_at = time.time() + ttl
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
self._refresh_token = resp.refresh_token
|
|
66
|
+
ttl = resp.expires_in if resp.expires_in is not None else 3600
|
|
67
|
+
self._expires_at = time.time() + ttl
|
|
68
|
+
|
|
69
|
+
def set_bearer_token(self, access_token: str, *, expires_in: int = 31_536_000) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Store a pre-issued access token (portal/publishable-key style bootstrap).
|
|
72
|
+
|
|
73
|
+
This mode intentionally omits a refresh token. Callers can still switch to
|
|
74
|
+
login()/set_tokens() later when they need refresh support.
|
|
75
|
+
"""
|
|
76
|
+
token = access_token.strip()
|
|
77
|
+
if not token:
|
|
78
|
+
raise SovariaError(400, "Access token cannot be empty")
|
|
79
|
+
self._access_token = token
|
|
80
|
+
self._refresh_token = None
|
|
81
|
+
self._expires_at = time.time() + max(int(expires_in), self._refresh_margin_seconds + 1)
|
|
82
|
+
|
|
83
|
+
def clear(self) -> None:
|
|
84
|
+
"""Remove all stored tokens and KEK (logout)."""
|
|
85
|
+
self._access_token = None
|
|
86
|
+
self._refresh_token = None
|
|
87
|
+
self._expires_at = 0.0
|
|
88
|
+
# Phase 2: zero KEK bytes then discard reference
|
|
89
|
+
if self._kek is not None:
|
|
90
|
+
try:
|
|
91
|
+
from sovaria.utils.crypto import _zero_bytes # noqa: PLC0415
|
|
92
|
+
|
|
93
|
+
_zero_bytes(self._kek)
|
|
94
|
+
except Exception: # noqa: BLE001
|
|
95
|
+
pass
|
|
96
|
+
self._kek = None
|
|
97
|
+
self._argon2_salt = None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def access_token(self) -> str | None:
|
|
101
|
+
return self._access_token
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def is_authenticated(self) -> bool:
|
|
105
|
+
return bool(self._access_token)
|
|
106
|
+
|
|
107
|
+
# ── Phase 2: KEK management ───────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def set_kek(self, kek: bytes, salt: bytes) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Cache the derived Argon2id KEK for the session lifetime.
|
|
112
|
+
|
|
113
|
+
Called once by SovariaClient.login() after a successful Argon2id
|
|
114
|
+
derivation. The KEK is reused for all document encrypt/decrypt calls
|
|
115
|
+
so users only pay the ~300ms Argon2id cost once per login.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
kek: 32-byte (256-bit) key derived by Argon2id.
|
|
119
|
+
salt: 16-byte salt returned by Axis on login (stored for logging/debug).
|
|
120
|
+
"""
|
|
121
|
+
self._kek = kek
|
|
122
|
+
self._argon2_salt = salt
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def kek(self) -> bytes | None:
|
|
126
|
+
"""Return the cached KEK, or None if derive_kek() has not been called yet."""
|
|
127
|
+
return self._kek
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def argon2_salt(self) -> bytes | None:
|
|
131
|
+
"""Return the cached Argon2id salt for this session."""
|
|
132
|
+
return self._argon2_salt
|
|
133
|
+
|
|
134
|
+
def _needs_refresh(self) -> bool:
|
|
135
|
+
"""True when the access token exists but is absent or close to expiry.
|
|
136
|
+
|
|
137
|
+
Returns ``False`` when not logged in (no access token) — in that case
|
|
138
|
+
``ensure_token_fresh`` is a no-op and the caller must call ``login``
|
|
139
|
+
explicitly before making authenticated requests.
|
|
140
|
+
"""
|
|
141
|
+
if not self._access_token:
|
|
142
|
+
return False # not logged in at all — can't refresh
|
|
143
|
+
return time.time() >= (self._expires_at - self._refresh_margin_seconds)
|
|
144
|
+
|
|
145
|
+
# ── Proactive refresh ─────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
async def ensure_token_fresh(self, client: httpx.AsyncClient) -> None:
|
|
148
|
+
"""
|
|
149
|
+
Called before every request. Uses a lock so that concurrent
|
|
150
|
+
coroutines don't each attempt their own refresh simultaneously.
|
|
151
|
+
"""
|
|
152
|
+
if not self._needs_refresh():
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
async with self._lock:
|
|
156
|
+
# Double-check inside the lock — another coroutine may have
|
|
157
|
+
# already refreshed while we were waiting.
|
|
158
|
+
if not self._needs_refresh():
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
await self._do_refresh(client)
|
|
162
|
+
|
|
163
|
+
async def _do_refresh(self, client: httpx.AsyncClient) -> None:
|
|
164
|
+
if not self._refresh_token:
|
|
165
|
+
raise SovariaError(401, "No refresh token available — please log in again")
|
|
166
|
+
|
|
167
|
+
response = await client.post(
|
|
168
|
+
f"{self._base_url}/api/v1/auth/refresh",
|
|
169
|
+
json={"refresh_token": self._refresh_token},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if response.status_code != 200:
|
|
173
|
+
self.clear()
|
|
174
|
+
raise SovariaError(
|
|
175
|
+
response.status_code,
|
|
176
|
+
response.json().get("detail", "Refresh failed"),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
self.set_tokens(TokenResponse(**response.json()))
|
|
180
|
+
|
|
181
|
+
# ── Request helper ────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
def inject_auth_header(self, headers: dict) -> dict:
|
|
184
|
+
"""Return headers dict with Authorization injected."""
|
|
185
|
+
if self._access_token:
|
|
186
|
+
return {**headers, "Authorization": f"Bearer {self._access_token}"}
|
|
187
|
+
return headers
|