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 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