querri 1.0.2__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.
- querri/__init__.py +51 -0
- querri/_auth.py +818 -0
- querri/_base_client.py +379 -0
- querri/_client.py +395 -0
- querri/_config.py +140 -0
- querri/_convenience.py +538 -0
- querri/_exceptions.py +180 -0
- querri/_pagination.py +278 -0
- querri/_streaming.py +663 -0
- querri/_user_client.py +193 -0
- querri/_version.py +1 -0
- querri/cli/__init__.py +21 -0
- querri/cli/_app.py +246 -0
- querri/cli/_context.py +216 -0
- querri/cli/_image.py +234 -0
- querri/cli/_output.py +293 -0
- querri/cli/audit.py +68 -0
- querri/cli/auth.py +556 -0
- querri/cli/chat.py +1255 -0
- querri/cli/chats.py +358 -0
- querri/cli/dashboards.py +294 -0
- querri/cli/embed.py +220 -0
- querri/cli/files.py +214 -0
- querri/cli/keys.py +304 -0
- querri/cli/policies.py +424 -0
- querri/cli/projects.py +916 -0
- querri/cli/sharing.py +403 -0
- querri/cli/sources.py +532 -0
- querri/cli/steps.py +123 -0
- querri/cli/usage.py +99 -0
- querri/cli/users.py +223 -0
- querri/cli/views.py +449 -0
- querri/cli/whoami.py +78 -0
- querri/py.typed +0 -0
- querri/resources/__init__.py +1 -0
- querri/resources/audit.py +119 -0
- querri/resources/dashboards.py +295 -0
- querri/resources/embed.py +268 -0
- querri/resources/files.py +145 -0
- querri/resources/keys.py +191 -0
- querri/resources/policies.py +492 -0
- querri/resources/projects.py +808 -0
- querri/resources/sharing.py +330 -0
- querri/resources/sources.py +511 -0
- querri/resources/usage.py +106 -0
- querri/resources/users.py +303 -0
- querri/resources/views.py +347 -0
- querri/types/__init__.py +104 -0
- querri/types/audit.py +21 -0
- querri/types/chat.py +51 -0
- querri/types/dashboard.py +51 -0
- querri/types/data.py +61 -0
- querri/types/embed.py +38 -0
- querri/types/file.py +18 -0
- querri/types/key.py +36 -0
- querri/types/policy.py +105 -0
- querri/types/project.py +158 -0
- querri/types/sharing.py +19 -0
- querri/types/usage.py +39 -0
- querri/types/user.py +33 -0
- querri-1.0.2.dist-info/METADATA +310 -0
- querri-1.0.2.dist-info/RECORD +64 -0
- querri-1.0.2.dist-info/WHEEL +4 -0
- querri-1.0.2.dist-info/entry_points.txt +2 -0
querri/_convenience.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""High-level convenience methods that compose multiple API calls.
|
|
2
|
+
|
|
3
|
+
The flagship method is `get_session()` which handles the complete white-label
|
|
4
|
+
embedding workflow: upsert user by external_id, apply access policies, and
|
|
5
|
+
create an embed session — all in one call.
|
|
6
|
+
|
|
7
|
+
These functions are called by resource classes (e.g., Embed.get_session())
|
|
8
|
+
and should not be imported directly by end users.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ._base_client import AsyncHTTPClient, SyncHTTPClient
|
|
19
|
+
from .types.policy import Policy
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("querri")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Internal helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _hash_access_spec(access: dict[str, Any]) -> str:
|
|
30
|
+
"""Create a deterministic 8-char hex hash from an access spec.
|
|
31
|
+
|
|
32
|
+
Used to generate stable policy names (``sdk_auto_<hash8>``) so that
|
|
33
|
+
repeated calls with the same inline spec reuse the same policy instead
|
|
34
|
+
of creating duplicates.
|
|
35
|
+
|
|
36
|
+
The input dict is JSON-serialised with sorted keys and compact separators
|
|
37
|
+
to guarantee determinism regardless of insertion order.
|
|
38
|
+
"""
|
|
39
|
+
# Only hash the parts that define the policy content — not meta-keys
|
|
40
|
+
# like ``policy_ids`` which reference existing policies.
|
|
41
|
+
hashable = {k: v for k, v in access.items() if k in ("sources", "filters")}
|
|
42
|
+
normalized = json.dumps(hashable, sort_keys=True, separators=(",", ":"))
|
|
43
|
+
return hashlib.sha256(normalized.encode()).hexdigest()[:8]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_policy_body(access: dict[str, Any], policy_name: str) -> dict[str, Any]:
|
|
47
|
+
"""Convert an inline access dict to a policy creation request body.
|
|
48
|
+
|
|
49
|
+
Maps the user-friendly SDK format::
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
"sources": ["src_abc", "src_def"],
|
|
53
|
+
"filters": {
|
|
54
|
+
"region": ["APAC", "EMEA"],
|
|
55
|
+
"department": "Sales",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
to the server's ``CreatePolicyRequest`` shape::
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
"name": "sdk_auto_a1b2c3d4",
|
|
63
|
+
"source_ids": ["src_abc", "src_def"],
|
|
64
|
+
"row_filters": [
|
|
65
|
+
{"column": "region", "values": ["APAC", "EMEA"]},
|
|
66
|
+
{"column": "department", "values": ["Sales"]},
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
"""
|
|
70
|
+
body: dict[str, Any] = {"name": policy_name}
|
|
71
|
+
|
|
72
|
+
sources = access.get("sources")
|
|
73
|
+
if sources:
|
|
74
|
+
body["source_ids"] = list(sources)
|
|
75
|
+
|
|
76
|
+
filters = access.get("filters")
|
|
77
|
+
if filters and isinstance(filters, dict):
|
|
78
|
+
row_filters: list[dict[str, Any]] = []
|
|
79
|
+
for column, values in filters.items():
|
|
80
|
+
if isinstance(values, list):
|
|
81
|
+
row_filters.append({"column": column, "values": values})
|
|
82
|
+
else:
|
|
83
|
+
# Single value — wrap in a list for the server
|
|
84
|
+
row_filters.append({"column": column, "values": [values]})
|
|
85
|
+
body["row_filters"] = row_filters
|
|
86
|
+
|
|
87
|
+
return body
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_user_param(
|
|
91
|
+
user: str | dict[str, Any],
|
|
92
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
93
|
+
"""Normalise the ``user`` parameter into (external_id, creation_body | None).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A tuple of ``(external_id, body_for_put_or_none)``.
|
|
97
|
+
When *user* is a plain string the body is ``None`` — the caller must
|
|
98
|
+
look up the user by external_id and raise if not found.
|
|
99
|
+
When *user* is a dict the body contains the fields needed for
|
|
100
|
+
``PUT /users/external/{external_id}``.
|
|
101
|
+
"""
|
|
102
|
+
if isinstance(user, str):
|
|
103
|
+
return user, None
|
|
104
|
+
|
|
105
|
+
if not isinstance(user, dict):
|
|
106
|
+
raise TypeError(
|
|
107
|
+
f"'user' must be a string (external_id) "
|
|
108
|
+
f"or a dict, got {type(user).__name__}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
external_id = user.get("external_id")
|
|
112
|
+
if not external_id:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"User dict must contain 'external_id'. "
|
|
115
|
+
"Example: {'external_id': 'cust-42', 'email': 'alice@example.com'}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
body: dict[str, Any] = {"role": user.get("role", "member")}
|
|
119
|
+
email = user.get("email")
|
|
120
|
+
if email:
|
|
121
|
+
body["email"] = email
|
|
122
|
+
if user.get("first_name"):
|
|
123
|
+
body["first_name"] = user["first_name"]
|
|
124
|
+
if user.get("last_name"):
|
|
125
|
+
body["last_name"] = user["last_name"]
|
|
126
|
+
|
|
127
|
+
return external_id, body
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Sync convenience methods
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sync_get_session(
|
|
136
|
+
http: SyncHTTPClient,
|
|
137
|
+
*,
|
|
138
|
+
user: str | dict[str, Any],
|
|
139
|
+
access: dict[str, Any] | None = None,
|
|
140
|
+
origin: str | None = None,
|
|
141
|
+
ttl: int = 3600,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Flagship convenience method — get-or-create user, apply policy, create session.
|
|
144
|
+
|
|
145
|
+
This is the single most important method in the SDK. It encapsulates the
|
|
146
|
+
entire white-label embedding workflow in one call:
|
|
147
|
+
|
|
148
|
+
1. **Resolve user** — look up or create a user by ``external_id``.
|
|
149
|
+
2. **Apply access** — assign existing policies or find-or-create an
|
|
150
|
+
inline policy from a spec dict.
|
|
151
|
+
3. **Create session** — mint an embed session token for the resolved user.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
http: The synchronous HTTP client (injected by the resource class).
|
|
155
|
+
user: Either a string ``external_id`` (user must already exist) or a
|
|
156
|
+
dict with at least ``external_id`` and ``email`` (get-or-create).
|
|
157
|
+
Optional keys: ``first_name``, ``last_name``, ``role``.
|
|
158
|
+
access: Access policy configuration. Accepts two shapes:
|
|
159
|
+
|
|
160
|
+
* ``{"policy_ids": ["pol_abc"]}`` — assign user to existing policies.
|
|
161
|
+
* ``{"sources": [...], "filters": {...}}`` — find-or-create an
|
|
162
|
+
auto-named policy matching this spec, then assign.
|
|
163
|
+
|
|
164
|
+
If ``None``, no policy changes are made (user keeps existing access).
|
|
165
|
+
origin: Allowed origin for the embed session (e.g. ``https://app.customer.com``).
|
|
166
|
+
ttl: Session time-to-live in seconds (default 3600, min 900, max 86400).
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Embed session dict with ``session_token``, ``expires_in``, ``user_id``,
|
|
170
|
+
and ``external_id``.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ValueError: If ``user`` is a string and no user with that external_id exists.
|
|
174
|
+
ValidationError: If request parameters are invalid.
|
|
175
|
+
APIError: On any HTTP error from the Querri API.
|
|
176
|
+
|
|
177
|
+
Example — string shorthand (user must exist)::
|
|
178
|
+
|
|
179
|
+
session = client.embed.get_session(user="customer-42", ttl=7200)
|
|
180
|
+
|
|
181
|
+
Example — full user dict (auto-creates if needed)::
|
|
182
|
+
|
|
183
|
+
session = client.embed.get_session(
|
|
184
|
+
user={
|
|
185
|
+
"external_id": "customer-42",
|
|
186
|
+
"email": "alice@customer.com",
|
|
187
|
+
"first_name": "Alice",
|
|
188
|
+
},
|
|
189
|
+
access={
|
|
190
|
+
"sources": ["src_sales"],
|
|
191
|
+
"filters": {"region": ["APAC"]},
|
|
192
|
+
},
|
|
193
|
+
origin="https://app.customer.com",
|
|
194
|
+
)
|
|
195
|
+
"""
|
|
196
|
+
external_id, creation_body = _resolve_user_param(user)
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Step 1: Resolve user
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
if creation_body is not None:
|
|
202
|
+
# Dict form — use idempotent PUT (get-or-create)
|
|
203
|
+
resp = http.put(f"/users/external/{external_id}", json=creation_body)
|
|
204
|
+
result = resp.json()
|
|
205
|
+
user_id: str = result["id"]
|
|
206
|
+
logger.debug(
|
|
207
|
+
"Resolved user external_id=%s → id=%s (created=%s)",
|
|
208
|
+
external_id,
|
|
209
|
+
user_id,
|
|
210
|
+
result.get("created", "unknown"),
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
# String shorthand — look up existing user by external_id
|
|
214
|
+
resp = http.get("/users", params={"external_id": external_id, "limit": 1})
|
|
215
|
+
data = resp.json().get("data", [])
|
|
216
|
+
if not data:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"User with external_id '{external_id}' not found. "
|
|
219
|
+
"Pass a dict with at least 'external_id' and 'email' to "
|
|
220
|
+
"create the user automatically. Example:\n"
|
|
221
|
+
" client.embed.get_session(\n"
|
|
222
|
+
" user={'external_id': 'cust-42', 'email': 'alice@example.com'},\n"
|
|
223
|
+
" ...\n"
|
|
224
|
+
" )"
|
|
225
|
+
)
|
|
226
|
+
user_id = data[0]["id"]
|
|
227
|
+
logger.debug(
|
|
228
|
+
"Looked up user external_id=%s → id=%s",
|
|
229
|
+
external_id,
|
|
230
|
+
user_id,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# Step 2: Handle access policies
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
if access is not None:
|
|
237
|
+
_sync_apply_access(http, user_id=user_id, access=access)
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Step 3: Create embed session
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
session_body: dict[str, Any] = {"user_id": user_id, "ttl": ttl}
|
|
243
|
+
if origin is not None:
|
|
244
|
+
session_body["origin"] = origin
|
|
245
|
+
|
|
246
|
+
resp = http.post("/embed/sessions", json=session_body)
|
|
247
|
+
session = resp.json()
|
|
248
|
+
|
|
249
|
+
# Enrich response with the external_id the caller passed in
|
|
250
|
+
session["external_id"] = external_id
|
|
251
|
+
|
|
252
|
+
logger.debug(
|
|
253
|
+
"Created embed session for user_id=%s, expires_in=%s",
|
|
254
|
+
user_id,
|
|
255
|
+
session.get("expires_in"),
|
|
256
|
+
)
|
|
257
|
+
return session # type: ignore[no-any-return]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _sync_apply_access(
|
|
261
|
+
http: SyncHTTPClient,
|
|
262
|
+
*,
|
|
263
|
+
user_id: str,
|
|
264
|
+
access: dict[str, Any],
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Apply access policies to a user (sync).
|
|
267
|
+
|
|
268
|
+
Handles both ``policy_ids`` references and inline specs.
|
|
269
|
+
Uses atomic replace (PUT) to prevent stale policy accumulation.
|
|
270
|
+
"""
|
|
271
|
+
all_policy_ids: list[str] = list(access.get("policy_ids") or [])
|
|
272
|
+
|
|
273
|
+
# Inline spec — find-or-create a policy by deterministic content hash
|
|
274
|
+
if access.get("sources") or access.get("filters"):
|
|
275
|
+
spec_hash = _hash_access_spec(access)
|
|
276
|
+
policy_name = f"sdk_auto_{spec_hash}"
|
|
277
|
+
|
|
278
|
+
# Try to find an existing policy with this name
|
|
279
|
+
resp = http.get("/access/policies", params={"name": policy_name})
|
|
280
|
+
policies = resp.json().get("data", [])
|
|
281
|
+
|
|
282
|
+
if policies:
|
|
283
|
+
policy_id = policies[0]["id"]
|
|
284
|
+
logger.debug(
|
|
285
|
+
"Reusing existing auto-policy %s (name=%s)", policy_id, policy_name
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
# Create a new policy
|
|
289
|
+
policy_body = _build_policy_body(access, policy_name)
|
|
290
|
+
resp = http.post("/access/policies", json=policy_body)
|
|
291
|
+
policy_id = resp.json()["id"]
|
|
292
|
+
logger.debug("Created auto-policy %s (name=%s)", policy_id, policy_name)
|
|
293
|
+
|
|
294
|
+
all_policy_ids.append(policy_id)
|
|
295
|
+
|
|
296
|
+
if not all_policy_ids:
|
|
297
|
+
logger.debug("Empty access spec, skipping policy assignment")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# Atomically replace all policy assignments for the user
|
|
301
|
+
http.put(
|
|
302
|
+
f"/access/users/{user_id}/policies",
|
|
303
|
+
json={"policy_ids": all_policy_ids},
|
|
304
|
+
)
|
|
305
|
+
logger.debug(
|
|
306
|
+
"Replaced policies for user %s with %s",
|
|
307
|
+
user_id,
|
|
308
|
+
all_policy_ids,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def sync_setup_policy(
|
|
313
|
+
http: SyncHTTPClient,
|
|
314
|
+
*,
|
|
315
|
+
name: str,
|
|
316
|
+
sources: list[str] | None = None,
|
|
317
|
+
row_filters: dict[str, Any] | None = None,
|
|
318
|
+
description: str | None = None,
|
|
319
|
+
users: list[str] | None = None,
|
|
320
|
+
) -> Policy:
|
|
321
|
+
"""Create a policy and optionally assign users in one call.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
http: The synchronous HTTP client.
|
|
325
|
+
name: Human-readable policy name.
|
|
326
|
+
sources: List of source IDs to include in the policy scope.
|
|
327
|
+
row_filters: Dict mapping column names to allowed values.
|
|
328
|
+
Values can be a single string or a list of strings.
|
|
329
|
+
description: Optional policy description.
|
|
330
|
+
users: Optional list of user IDs (WorkOS or external) to assign.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The created policy dict from the API.
|
|
334
|
+
|
|
335
|
+
Example::
|
|
336
|
+
|
|
337
|
+
policy = client.policies.setup(
|
|
338
|
+
name="APAC Sales Team",
|
|
339
|
+
sources=["src_abc", "src_def"],
|
|
340
|
+
row_filters={"region": ["APAC"], "department": "Sales"},
|
|
341
|
+
users=["user-1", "user-2"],
|
|
342
|
+
)
|
|
343
|
+
"""
|
|
344
|
+
body: dict[str, Any] = {"name": name}
|
|
345
|
+
if description is not None:
|
|
346
|
+
body["description"] = description
|
|
347
|
+
if sources:
|
|
348
|
+
body["source_ids"] = list(sources)
|
|
349
|
+
if row_filters:
|
|
350
|
+
filters: list[dict[str, Any]] = []
|
|
351
|
+
for col, val in row_filters.items():
|
|
352
|
+
if isinstance(val, list):
|
|
353
|
+
filters.append({"column": col, "values": val})
|
|
354
|
+
else:
|
|
355
|
+
filters.append({"column": col, "values": [val]})
|
|
356
|
+
body["row_filters"] = filters
|
|
357
|
+
|
|
358
|
+
resp = http.post("/access/policies", json=body)
|
|
359
|
+
policy = Policy.model_validate(resp.json())
|
|
360
|
+
|
|
361
|
+
if users:
|
|
362
|
+
http.post(
|
|
363
|
+
f"/access/policies/{policy.id}/users",
|
|
364
|
+
json={"user_ids": users},
|
|
365
|
+
)
|
|
366
|
+
logger.debug("Assigned %d users to policy %s", len(users), policy.id)
|
|
367
|
+
|
|
368
|
+
return policy
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
# Async convenience methods
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def async_get_session(
|
|
377
|
+
http: AsyncHTTPClient,
|
|
378
|
+
*,
|
|
379
|
+
user: str | dict[str, Any],
|
|
380
|
+
access: dict[str, Any] | None = None,
|
|
381
|
+
origin: str | None = None,
|
|
382
|
+
ttl: int = 3600,
|
|
383
|
+
) -> dict[str, Any]:
|
|
384
|
+
"""Async version of :func:`sync_get_session`.
|
|
385
|
+
|
|
386
|
+
Identical logic and parameters — see :func:`sync_get_session` for full
|
|
387
|
+
documentation.
|
|
388
|
+
"""
|
|
389
|
+
external_id, creation_body = _resolve_user_param(user)
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------
|
|
392
|
+
# Step 1: Resolve user
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
if creation_body is not None:
|
|
395
|
+
resp = await http.put(f"/users/external/{external_id}", json=creation_body)
|
|
396
|
+
result = resp.json()
|
|
397
|
+
user_id: str = result["id"]
|
|
398
|
+
logger.debug(
|
|
399
|
+
"Resolved user external_id=%s → id=%s (created=%s)",
|
|
400
|
+
external_id,
|
|
401
|
+
user_id,
|
|
402
|
+
result.get("created", "unknown"),
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
resp = await http.get("/users", params={"external_id": external_id, "limit": 1})
|
|
406
|
+
data = resp.json().get("data", [])
|
|
407
|
+
if not data:
|
|
408
|
+
raise ValueError(
|
|
409
|
+
f"User with external_id '{external_id}' not found. "
|
|
410
|
+
"Pass a dict with at least 'external_id' and 'email' to "
|
|
411
|
+
"create the user automatically. Example:\n"
|
|
412
|
+
" await client.embed.get_session(\n"
|
|
413
|
+
" user={'external_id': 'cust-42', 'email': 'alice@example.com'},\n"
|
|
414
|
+
" ...\n"
|
|
415
|
+
" )"
|
|
416
|
+
)
|
|
417
|
+
user_id = data[0]["id"]
|
|
418
|
+
logger.debug(
|
|
419
|
+
"Looked up user external_id=%s → id=%s",
|
|
420
|
+
external_id,
|
|
421
|
+
user_id,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
# Step 2: Handle access policies
|
|
426
|
+
# ------------------------------------------------------------------
|
|
427
|
+
if access is not None:
|
|
428
|
+
await _async_apply_access(http, user_id=user_id, access=access)
|
|
429
|
+
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
# Step 3: Create embed session
|
|
432
|
+
# ------------------------------------------------------------------
|
|
433
|
+
session_body: dict[str, Any] = {"user_id": user_id, "ttl": ttl}
|
|
434
|
+
if origin is not None:
|
|
435
|
+
session_body["origin"] = origin
|
|
436
|
+
|
|
437
|
+
resp = await http.post("/embed/sessions", json=session_body)
|
|
438
|
+
session = resp.json()
|
|
439
|
+
|
|
440
|
+
session["external_id"] = external_id
|
|
441
|
+
|
|
442
|
+
logger.debug(
|
|
443
|
+
"Created embed session for user_id=%s, expires_in=%s",
|
|
444
|
+
user_id,
|
|
445
|
+
session.get("expires_in"),
|
|
446
|
+
)
|
|
447
|
+
return session # type: ignore[no-any-return]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def _async_apply_access(
|
|
451
|
+
http: AsyncHTTPClient,
|
|
452
|
+
*,
|
|
453
|
+
user_id: str,
|
|
454
|
+
access: dict[str, Any],
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Apply access policies to a user (async).
|
|
457
|
+
|
|
458
|
+
Handles both ``policy_ids`` references and inline specs.
|
|
459
|
+
Uses atomic replace (PUT) to prevent stale policy accumulation.
|
|
460
|
+
"""
|
|
461
|
+
all_policy_ids: list[str] = list(access.get("policy_ids") or [])
|
|
462
|
+
|
|
463
|
+
# Inline spec — find-or-create a policy by deterministic content hash
|
|
464
|
+
if access.get("sources") or access.get("filters"):
|
|
465
|
+
spec_hash = _hash_access_spec(access)
|
|
466
|
+
policy_name = f"sdk_auto_{spec_hash}"
|
|
467
|
+
|
|
468
|
+
resp = await http.get("/access/policies", params={"name": policy_name})
|
|
469
|
+
policies = resp.json().get("data", [])
|
|
470
|
+
|
|
471
|
+
if policies:
|
|
472
|
+
policy_id = policies[0]["id"]
|
|
473
|
+
logger.debug(
|
|
474
|
+
"Reusing existing auto-policy %s (name=%s)", policy_id, policy_name
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
policy_body = _build_policy_body(access, policy_name)
|
|
478
|
+
resp = await http.post("/access/policies", json=policy_body)
|
|
479
|
+
policy_id = resp.json()["id"]
|
|
480
|
+
logger.debug("Created auto-policy %s (name=%s)", policy_id, policy_name)
|
|
481
|
+
|
|
482
|
+
all_policy_ids.append(policy_id)
|
|
483
|
+
|
|
484
|
+
if not all_policy_ids:
|
|
485
|
+
logger.debug("Empty access spec, skipping policy assignment")
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
# Atomically replace all policy assignments for the user
|
|
489
|
+
await http.put(
|
|
490
|
+
f"/access/users/{user_id}/policies",
|
|
491
|
+
json={"policy_ids": all_policy_ids},
|
|
492
|
+
)
|
|
493
|
+
logger.debug(
|
|
494
|
+
"Replaced policies for user %s with %s",
|
|
495
|
+
user_id,
|
|
496
|
+
all_policy_ids,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
async def async_setup_policy(
|
|
501
|
+
http: AsyncHTTPClient,
|
|
502
|
+
*,
|
|
503
|
+
name: str,
|
|
504
|
+
sources: list[str] | None = None,
|
|
505
|
+
row_filters: dict[str, Any] | None = None,
|
|
506
|
+
description: str | None = None,
|
|
507
|
+
users: list[str] | None = None,
|
|
508
|
+
) -> Policy:
|
|
509
|
+
"""Async version of :func:`sync_setup_policy`.
|
|
510
|
+
|
|
511
|
+
Identical logic and parameters — see :func:`sync_setup_policy` for full
|
|
512
|
+
documentation.
|
|
513
|
+
"""
|
|
514
|
+
body: dict[str, Any] = {"name": name}
|
|
515
|
+
if description is not None:
|
|
516
|
+
body["description"] = description
|
|
517
|
+
if sources:
|
|
518
|
+
body["source_ids"] = list(sources)
|
|
519
|
+
if row_filters:
|
|
520
|
+
filters: list[dict[str, Any]] = []
|
|
521
|
+
for col, val in row_filters.items():
|
|
522
|
+
if isinstance(val, list):
|
|
523
|
+
filters.append({"column": col, "values": val})
|
|
524
|
+
else:
|
|
525
|
+
filters.append({"column": col, "values": [val]})
|
|
526
|
+
body["row_filters"] = filters
|
|
527
|
+
|
|
528
|
+
resp = await http.post("/access/policies", json=body)
|
|
529
|
+
policy = Policy.model_validate(resp.json())
|
|
530
|
+
|
|
531
|
+
if users:
|
|
532
|
+
await http.post(
|
|
533
|
+
f"/access/policies/{policy.id}/users",
|
|
534
|
+
json={"user_ids": users},
|
|
535
|
+
)
|
|
536
|
+
logger.debug("Assigned %d users to policy %s", len(users), policy.id)
|
|
537
|
+
|
|
538
|
+
return policy
|