scitrera-aether-client 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/PKG-INFO +7 -5
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/pyproject.toml +13 -5
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/__init__.py +10 -1
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/_common.py +83 -5
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/admin.py +318 -3
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/admin_async.py +320 -4
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/client.py +40 -5
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/client_async.py +269 -30
- scitrera_aether_client-0.2.2/scitrera_aether_client/proto/aether_pb2.py +561 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proto/aether_pb2.pyi +431 -67
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proto/aether_pb2_grpc.py +4 -4
- scitrera_aether_client-0.2.2/scitrera_aether_client/proto/sandbox_relay_tunnel_pb2.py +46 -0
- scitrera_aether_client-0.2.2/scitrera_aether_client/proto/sandbox_relay_tunnel_pb2.pyi +37 -0
- scitrera_aether_client-0.2.2/scitrera_aether_client/proto/sandbox_relay_tunnel_pb2_grpc.py +161 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proxy.py +24 -4
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proxy_terminator.py +26 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/PKG-INFO +7 -5
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/SOURCES.txt +3 -0
- scitrera_aether_client-0.2.2/scitrera_aether_client.egg-info/requires.txt +13 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_admin.py +222 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_client.py +70 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_proxy.py +70 -1
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_proxy_terminator.py +49 -0
- scitrera_aether_client-0.2.1/scitrera_aether_client/proto/aether_pb2.py +0 -517
- scitrera_aether_client-0.2.1/scitrera_aether_client.egg-info/requires.txt +0 -10
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/README.md +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/authority.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/authority_cache.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/__init__.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/echo_agent.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/echo_agent_async.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/echo_orchestrator.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/exceptions.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/httpx_transport.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/metrics.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/orchestrator/__init__.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/orchestrator/multiprocess.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proto/__init__.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/py.typed +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/requests_adapter.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/tunnel.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/types.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/dependency_links.txt +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/top_level.txt +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/setup.cfg +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_authority.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_authority_cache.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_client_async.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_orchestrator.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_proxy_streaming.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_requests_adapter.py +0 -0
- {scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/tests/test_tunnel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scitrera-aether-client
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Python client SDK for Scitrera Aether distributed control plane
|
|
5
5
|
Author-email: "scitrera.ai" <open-source-team@scitrera.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -22,12 +22,14 @@ Classifier: Typing :: Typed
|
|
|
22
22
|
Classifier: Framework :: AsyncIO
|
|
23
23
|
Requires-Python: >=3.11
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
|
-
Requires-Dist: grpcio==1.
|
|
26
|
-
Requires-Dist: protobuf
|
|
25
|
+
Requires-Dist: grpcio==1.81.1
|
|
26
|
+
Requires-Dist: protobuf>=6.33.5
|
|
27
27
|
Provides-Extra: dev
|
|
28
|
-
Requires-Dist: grpcio==1.
|
|
29
|
-
Requires-Dist: pytest==9.0.3; extra == "dev"
|
|
28
|
+
Requires-Dist: grpcio==1.81.1; extra == "dev"
|
|
30
29
|
Requires-Dist: pytest==9.0.3; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
31
|
+
Requires-Dist: requests>=2.32; extra == "dev"
|
|
32
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
31
33
|
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
32
34
|
Requires-Dist: types-protobuf>=4.24.0; extra == "dev"
|
|
33
35
|
Requires-Dist: grpc-stubs>=1.53.0; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scitrera-aether-client"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Python client SDK for Scitrera Aether distributed control plane"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -36,15 +36,23 @@ classifiers = [
|
|
|
36
36
|
"Framework :: AsyncIO",
|
|
37
37
|
]
|
|
38
38
|
dependencies = [
|
|
39
|
-
"grpcio==1.
|
|
40
|
-
"protobuf
|
|
39
|
+
"grpcio==1.81.1",
|
|
40
|
+
"protobuf>=6.33.5",
|
|
41
41
|
]
|
|
42
42
|
|
|
43
43
|
[project.optional-dependencies]
|
|
44
44
|
dev = [
|
|
45
|
-
"grpcio==1.
|
|
46
|
-
"pytest==9.0.3",
|
|
45
|
+
"grpcio==1.81.1",
|
|
47
46
|
"pytest==9.0.3",
|
|
47
|
+
# Required, not optional: asyncio_mode = "auto" below plus ~170 async tests.
|
|
48
|
+
"pytest-asyncio>=0.23.0",
|
|
49
|
+
# requests and httpx are optional at runtime — requests_adapter.py and
|
|
50
|
+
# httpx_transport.py both guard the import and raise a clear install hint —
|
|
51
|
+
# but their test modules import them unconditionally, so the suite cannot run
|
|
52
|
+
# without them. Absent from the extra, these passed locally only because a
|
|
53
|
+
# shared virtualenv happened to have both installed.
|
|
54
|
+
"requests>=2.32",
|
|
55
|
+
"httpx>=0.27",
|
|
48
56
|
"mypy>=1.8.0",
|
|
49
57
|
"types-protobuf>=4.24.0",
|
|
50
58
|
"grpc-stubs>=1.53.0",
|
{scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/__init__.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "0.2.
|
|
1
|
+
__version__ = "0.2.2"
|
|
2
2
|
|
|
3
3
|
# Import the proxy module for its side effect: installs the
|
|
4
4
|
# ``ProxyHttpResponse`` / ``ProxyHttpBodyChunk`` dispatcher hook on
|
|
@@ -41,9 +41,15 @@ from ._common import (
|
|
|
41
41
|
KV_SCOPE_USER_SHARED,
|
|
42
42
|
KV_SCOPE_USER_WORKSPACE_SHARED,
|
|
43
43
|
|
|
44
|
+
# Canonical principal-type strings (mirror server models.PrincipalType)
|
|
45
|
+
PrincipalType,
|
|
46
|
+
|
|
44
47
|
# Auth credentials builder
|
|
45
48
|
Credentials,
|
|
46
49
|
|
|
50
|
+
# Service-addressing wildcard sentinel
|
|
51
|
+
SERVICE_WILDCARD,
|
|
52
|
+
|
|
47
53
|
# internal
|
|
48
54
|
_logging_lite_hook,
|
|
49
55
|
)
|
|
@@ -394,6 +400,9 @@ __all__ = (
|
|
|
394
400
|
# Types - Other
|
|
395
401
|
'Credentials',
|
|
396
402
|
|
|
403
|
+
# Service-addressing wildcard sentinel
|
|
404
|
+
'SERVICE_WILDCARD',
|
|
405
|
+
|
|
397
406
|
# Metric builder
|
|
398
407
|
'MetricBuilder',
|
|
399
408
|
'new_metric',
|
{scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/_common.py
RENAMED
|
@@ -197,6 +197,7 @@ def create_service_init(implementation: str, specifier: str,
|
|
|
197
197
|
credentials: Optional[Dict[str, str]] = None,
|
|
198
198
|
resume_session_id: str = "",
|
|
199
199
|
extensions: Optional[List["aether_pb2.ExtensionDeclaration"]] = None,
|
|
200
|
+
no_pool_consumer: bool = False,
|
|
200
201
|
) -> aether_pb2.InitConnection:
|
|
201
202
|
"""Create an InitConnection message for a Service principal (workspace-less).
|
|
202
203
|
|
|
@@ -205,11 +206,17 @@ def create_service_init(implementation: str, specifier: str,
|
|
|
205
206
|
privileged work on behalf of users via ``AuthorizationContext``.
|
|
206
207
|
|
|
207
208
|
Canonical identity string: ``sv::{implementation}::{specifier}``.
|
|
209
|
+
|
|
210
|
+
``no_pool_consumer`` opts this connection OUT of pool-task routing — the
|
|
211
|
+
gateway will not add it to the implementation worker index, so it is never
|
|
212
|
+
targeted for POOL-mode task assignments. Set it for serve-only instances
|
|
213
|
+
that register no task handlers (default False = consumer, back-compat).
|
|
208
214
|
"""
|
|
209
215
|
return _apply_client_version_meta(aether_pb2.InitConnection(
|
|
210
216
|
service=aether_pb2.ServiceIdentity(
|
|
211
217
|
implementation=implementation,
|
|
212
218
|
specifier=specifier,
|
|
219
|
+
no_pool_consumer=no_pool_consumer,
|
|
213
220
|
),
|
|
214
221
|
credentials=credentials or {},
|
|
215
222
|
resume_session_id=resume_session_id,
|
|
@@ -230,18 +237,57 @@ def create_service_init(implementation: str, specifier: str,
|
|
|
230
237
|
IDENTITY_SEP = "::"
|
|
231
238
|
|
|
232
239
|
|
|
240
|
+
class _ServiceWildcard:
|
|
241
|
+
"""Sentinel type for the service-addressing wildcard specifier.
|
|
242
|
+
|
|
243
|
+
A single module-level instance (:data:`SERVICE_WILDCARD`) is the only
|
|
244
|
+
value that should ever exist; identity comparison (``is``) is the
|
|
245
|
+
intended check. ``repr`` is descriptive so it reads clearly in logs
|
|
246
|
+
and assertion failures.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
__slots__ = ()
|
|
250
|
+
|
|
251
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
252
|
+
return "SERVICE_WILDCARD"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# Explicit, opt-in wildcard for service addressing. Pass this as the
|
|
256
|
+
# ``specifier`` to :func:`create_topic_service` (or to a client's
|
|
257
|
+
# ``send_message_to_service``) to emit the bare ``sv::{impl}`` wildcard
|
|
258
|
+
# target. The gateway (``models.topics.ParseSendTarget``) resolves a bare
|
|
259
|
+
# 2-segment ``sv::{impl}`` to any registered ``sv::{impl}::{spec}`` instance.
|
|
260
|
+
#
|
|
261
|
+
# This MUST be opted into deliberately: a normal non-empty specifier emits
|
|
262
|
+
# the canonical 3-segment ``sv::{impl}::{spec}`` and an empty string ``""``
|
|
263
|
+
# STAYS 3-segment (``sv::{impl}::``) — only this sentinel produces the
|
|
264
|
+
# 2-segment wildcard, so "" can never silently become a wildcard.
|
|
265
|
+
SERVICE_WILDCARD = _ServiceWildcard()
|
|
266
|
+
|
|
267
|
+
|
|
233
268
|
def create_topic_agent(workspace: str, implementation: str, specifier: str) -> str:
|
|
234
269
|
"""Create a topic string for a specific agent."""
|
|
235
270
|
return f"ag{IDENTITY_SEP}{workspace}{IDENTITY_SEP}{implementation}{IDENTITY_SEP}{specifier}"
|
|
236
271
|
|
|
237
272
|
|
|
238
|
-
def create_topic_service(implementation
|
|
239
|
-
"""Create a topic string for a
|
|
273
|
+
def create_topic_service(implementation, specifier) -> str:
|
|
274
|
+
"""Create a topic string for a service principal.
|
|
275
|
+
|
|
276
|
+
Two shapes, selected by ``specifier``:
|
|
240
277
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
278
|
+
* A concrete ``specifier`` (any ``str``, including the empty string)
|
|
279
|
+
emits the canonical 3-segment ``sv::{implementation}::{specifier}``.
|
|
280
|
+
An empty string therefore yields ``sv::{implementation}::`` (a
|
|
281
|
+
concrete, trailing-``::`` target) — it is NOT treated as a wildcard.
|
|
282
|
+
* The :data:`SERVICE_WILDCARD` sentinel emits the bare 2-segment
|
|
283
|
+
``sv::{implementation}`` wildcard, which the gateway resolves to any
|
|
284
|
+
registered ``sv::{implementation}::{spec}`` instance.
|
|
285
|
+
|
|
286
|
+
Mirrors :func:`models.topics.ServiceTopic` (concrete) and
|
|
287
|
+
:func:`models.topics.ParseSendTarget` (wildcard) on the gateway side.
|
|
244
288
|
"""
|
|
289
|
+
if specifier is SERVICE_WILDCARD:
|
|
290
|
+
return f"sv{IDENTITY_SEP}{implementation}"
|
|
245
291
|
return f"sv{IDENTITY_SEP}{implementation}{IDENTITY_SEP}{specifier}"
|
|
246
292
|
|
|
247
293
|
|
|
@@ -267,6 +313,18 @@ def create_topic_user_workspace(user_id: str, workspace: str) -> str:
|
|
|
267
313
|
return f"uw{IDENTITY_SEP}{user_id}{IDENTITY_SEP}{workspace}"
|
|
268
314
|
|
|
269
315
|
|
|
316
|
+
def create_topic_user_broadcast(user_id: str) -> str:
|
|
317
|
+
"""Create a per-user broadcast topic (uu::{user_id}).
|
|
318
|
+
|
|
319
|
+
Reaches every one of a user's open windows regardless of which workspace
|
|
320
|
+
each window is currently viewing -- the workspace-agnostic, non-progress
|
|
321
|
+
complement to the per-user progress topic. Only platform principals
|
|
322
|
+
(service / workflow-engine / bridge) may publish; the gateway rejects
|
|
323
|
+
sends from workspace-scoped principals.
|
|
324
|
+
"""
|
|
325
|
+
return f"uu{IDENTITY_SEP}{user_id}"
|
|
326
|
+
|
|
327
|
+
|
|
270
328
|
def create_topic_global_agents(workspace: str) -> str:
|
|
271
329
|
"""Create a global broadcast topic for all agents in a workspace."""
|
|
272
330
|
return f"ga{IDENTITY_SEP}{workspace}"
|
|
@@ -311,6 +369,26 @@ KV_SCOPE_WORKSPACE_EXCLUSIVE = "workspace-exclusive"
|
|
|
311
369
|
KV_SCOPE_USER_SHARED = "user-shared"
|
|
312
370
|
KV_SCOPE_USER_WORKSPACE_SHARED = "user-workspace-shared"
|
|
313
371
|
|
|
372
|
+
|
|
373
|
+
class PrincipalType:
|
|
374
|
+
"""Canonical Aether principal-type strings.
|
|
375
|
+
|
|
376
|
+
These MUST match the server's ``models.PrincipalType`` constants verbatim
|
|
377
|
+
(server/pkg/models/identity.go) — the gateway compares them exactly (e.g.
|
|
378
|
+
api-key ``parsePrincipalType``), so a case/spelling mismatch like ``"user"``
|
|
379
|
+
vs ``"User"`` is rejected. Always reference these constants instead of
|
|
380
|
+
hardcoding the string when minting tokens / asserting an identity type.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
AGENT = "Agent"
|
|
384
|
+
TASK = "Task"
|
|
385
|
+
USER = "User"
|
|
386
|
+
SERVICE = "Service"
|
|
387
|
+
WORKFLOW_ENGINE = "WorkflowEngine"
|
|
388
|
+
METRICS_BRIDGE = "MetricsBridge"
|
|
389
|
+
ORCHESTRATOR = "Orchestrator"
|
|
390
|
+
BRIDGE = "Bridge"
|
|
391
|
+
|
|
314
392
|
_SCOPE_MAP = {
|
|
315
393
|
"global": aether_pb2.KVOperation.GLOBAL,
|
|
316
394
|
"workspace": aether_pb2.KVOperation.WORKSPACE,
|
{scitrera_aether_client-0.2.1 → scitrera_aether_client-0.2.2}/scitrera_aether_client/admin.py
RENAMED
|
@@ -141,12 +141,17 @@ class AdminClient:
|
|
|
141
141
|
granted_by: str = "",
|
|
142
142
|
reason: str = "",
|
|
143
143
|
expires_at: int = 0,
|
|
144
|
+
authorization: Optional[aether_pb2.AuthorizationContext] = None,
|
|
144
145
|
timeout: float = 10.0):
|
|
145
146
|
"""Create an ACL rule granting access.
|
|
146
147
|
|
|
147
148
|
``access_level`` uses the numeric tiers from ``internal/acl/types.go``:
|
|
148
149
|
``0=NONE, 10=READ, 20=READWRITE, 30=MANAGE, 40=ADMIN, 50=SUPERADMIN``.
|
|
149
150
|
|
|
151
|
+
``authorization`` is an optional on-behalf-of authority context. When
|
|
152
|
+
set, the gateway runs the admin ACL check against the subject (the
|
|
153
|
+
user) rather than the actor (the platform-server).
|
|
154
|
+
|
|
150
155
|
Note:
|
|
151
156
|
The TS surface uses a string ``permission`` and a ``metadata``
|
|
152
157
|
map. The Python proto uses ``access_level`` (int) and ``granted_by``
|
|
@@ -165,14 +170,55 @@ class AdminClient:
|
|
|
165
170
|
reason=reason,
|
|
166
171
|
expires_at=expires_at,
|
|
167
172
|
),
|
|
173
|
+
authorization=authorization,
|
|
168
174
|
)
|
|
169
175
|
return self._client.acl_op(op, timeout=timeout)
|
|
170
176
|
|
|
171
|
-
def delete_acl_rule(self, rule_id: str,
|
|
172
|
-
|
|
177
|
+
def delete_acl_rule(self, rule_id: str,
|
|
178
|
+
authorization: Optional[aether_pb2.AuthorizationContext] = None,
|
|
179
|
+
timeout: float = 10.0):
|
|
180
|
+
"""Delete (revoke) an ACL rule by its UUID rule_id.
|
|
181
|
+
|
|
182
|
+
The gateway resolves the rule's principal+resource from the UUID and
|
|
183
|
+
then deletes by composite key. Returns an error if the rule_id is not
|
|
184
|
+
found.
|
|
185
|
+
|
|
186
|
+
``authorization`` is an optional on-behalf-of authority context (see
|
|
187
|
+
:meth:`create_acl_rule`).
|
|
188
|
+
"""
|
|
173
189
|
op = aether_pb2.ACLOperation(
|
|
174
190
|
op=aether_pb2.ACLOperation.REVOKE,
|
|
175
191
|
rule_id=rule_id,
|
|
192
|
+
authorization=authorization,
|
|
193
|
+
)
|
|
194
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
195
|
+
|
|
196
|
+
def revoke_acl_rule(self,
|
|
197
|
+
principal_type: str,
|
|
198
|
+
principal_id: str,
|
|
199
|
+
resource_type: str,
|
|
200
|
+
resource_id: str,
|
|
201
|
+
authorization: Optional[aether_pb2.AuthorizationContext] = None,
|
|
202
|
+
timeout: float = 10.0):
|
|
203
|
+
"""Revoke an ACL rule by its composite (principal + resource) key.
|
|
204
|
+
|
|
205
|
+
This is the direct revoke path: it sends ``ACLOperation(op=REVOKE,
|
|
206
|
+
rule_filter=ACLRuleFilter(...))`` which the gateway maps to a delete
|
|
207
|
+
by composite key without any UUID lookup. Prefer this method when you
|
|
208
|
+
already have the principal and resource identifiers.
|
|
209
|
+
|
|
210
|
+
``authorization`` is an optional on-behalf-of authority context (see
|
|
211
|
+
:meth:`create_acl_rule`).
|
|
212
|
+
"""
|
|
213
|
+
op = aether_pb2.ACLOperation(
|
|
214
|
+
op=aether_pb2.ACLOperation.REVOKE,
|
|
215
|
+
rule_filter=aether_pb2.ACLRuleFilter(
|
|
216
|
+
principal_type=principal_type,
|
|
217
|
+
principal_id=principal_id,
|
|
218
|
+
resource_type=resource_type,
|
|
219
|
+
resource_id=resource_id,
|
|
220
|
+
),
|
|
221
|
+
authorization=authorization,
|
|
176
222
|
)
|
|
177
223
|
return self._client.acl_op(op, timeout=timeout)
|
|
178
224
|
|
|
@@ -183,8 +229,13 @@ class AdminClient:
|
|
|
183
229
|
resource_id: str = "",
|
|
184
230
|
limit: int = 0,
|
|
185
231
|
offset: int = 0,
|
|
232
|
+
authorization: Optional[aether_pb2.AuthorizationContext] = None,
|
|
186
233
|
timeout: float = 10.0):
|
|
187
|
-
"""List ACL rules with optional filters.
|
|
234
|
+
"""List ACL rules with optional filters.
|
|
235
|
+
|
|
236
|
+
``authorization`` is an optional on-behalf-of authority context (see
|
|
237
|
+
:meth:`create_acl_rule`).
|
|
238
|
+
"""
|
|
188
239
|
op = aether_pb2.ACLOperation(
|
|
189
240
|
op=aether_pb2.ACLOperation.LIST_RULES,
|
|
190
241
|
rule_filter=aether_pb2.ACLRuleFilter(
|
|
@@ -195,6 +246,7 @@ class AdminClient:
|
|
|
195
246
|
limit=limit,
|
|
196
247
|
offset=offset,
|
|
197
248
|
),
|
|
249
|
+
authorization=authorization,
|
|
198
250
|
)
|
|
199
251
|
return self._client.acl_op(op, timeout=timeout)
|
|
200
252
|
|
|
@@ -229,6 +281,269 @@ class AdminClient:
|
|
|
229
281
|
)
|
|
230
282
|
return self._client.acl_op(op, timeout=timeout)
|
|
231
283
|
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Role / Group Operations
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
#
|
|
288
|
+
# A group is a named collection of principals; a role is a named
|
|
289
|
+
# permission bundle (its permissions are granted with
|
|
290
|
+
# :meth:`create_acl_rule` using ``principal_type="role"``,
|
|
291
|
+
# ``principal_id=<role name>``). Membership/assignment edges are resolved
|
|
292
|
+
# transitively and combined additively at evaluation time. These mirror
|
|
293
|
+
# the Go SDK helpers in ``sdk/go/aether/admin_roles.go``.
|
|
294
|
+
|
|
295
|
+
def create_role(self,
|
|
296
|
+
name: str,
|
|
297
|
+
description: str = "",
|
|
298
|
+
created_by: str = "",
|
|
299
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
300
|
+
timeout: float = 10.0):
|
|
301
|
+
"""Create a named role.
|
|
302
|
+
|
|
303
|
+
Grant its permissions with :meth:`create_acl_rule` using
|
|
304
|
+
``principal_type="role"``, ``principal_id=<role name>``.
|
|
305
|
+
|
|
306
|
+
Note:
|
|
307
|
+
Creating a role that already exists returns a response with
|
|
308
|
+
``success=False`` and an ``error`` like ``ErrRoleExists`` rather
|
|
309
|
+
than raising; callers decide how to treat that.
|
|
310
|
+
"""
|
|
311
|
+
op = aether_pb2.ACLOperation(
|
|
312
|
+
op=aether_pb2.ACLOperation.CREATE_ROLE,
|
|
313
|
+
role_request=aether_pb2.ACLRoleRequest(
|
|
314
|
+
name=name,
|
|
315
|
+
description=description,
|
|
316
|
+
created_by=created_by,
|
|
317
|
+
metadata=metadata or {},
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
321
|
+
|
|
322
|
+
def delete_role(self, name: str, timeout: float = 10.0):
|
|
323
|
+
"""Delete a role (and its permission rules) by name."""
|
|
324
|
+
op = aether_pb2.ACLOperation(
|
|
325
|
+
op=aether_pb2.ACLOperation.DELETE_ROLE,
|
|
326
|
+
name=name,
|
|
327
|
+
)
|
|
328
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
329
|
+
|
|
330
|
+
def get_role(self, name: str, timeout: float = 10.0):
|
|
331
|
+
"""Fetch a role by name."""
|
|
332
|
+
op = aether_pb2.ACLOperation(
|
|
333
|
+
op=aether_pb2.ACLOperation.GET_ROLE,
|
|
334
|
+
name=name,
|
|
335
|
+
)
|
|
336
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
337
|
+
|
|
338
|
+
def list_roles(self, timeout: float = 10.0):
|
|
339
|
+
"""List all roles."""
|
|
340
|
+
op = aether_pb2.ACLOperation(
|
|
341
|
+
op=aether_pb2.ACLOperation.LIST_ROLES,
|
|
342
|
+
)
|
|
343
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
344
|
+
|
|
345
|
+
def assign_role(self,
|
|
346
|
+
name: str,
|
|
347
|
+
assignee_type: str,
|
|
348
|
+
assignee_id: str,
|
|
349
|
+
granted_by: str = "",
|
|
350
|
+
expires_at: int = 0,
|
|
351
|
+
timeout: float = 10.0):
|
|
352
|
+
"""Assign (or refresh) a role to a principal or group.
|
|
353
|
+
|
|
354
|
+
``assignee_type`` is a principal type or ``"group"``. ``expires_at``
|
|
355
|
+
is Unix seconds; ``0`` means no expiry.
|
|
356
|
+
"""
|
|
357
|
+
op = aether_pb2.ACLOperation(
|
|
358
|
+
op=aether_pb2.ACLOperation.ASSIGN_ROLE,
|
|
359
|
+
name=name,
|
|
360
|
+
assignment_request=aether_pb2.ACLRoleAssignmentRequest(
|
|
361
|
+
assignee_type=assignee_type,
|
|
362
|
+
assignee_id=assignee_id,
|
|
363
|
+
granted_by=granted_by,
|
|
364
|
+
expires_at=expires_at,
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
368
|
+
|
|
369
|
+
def unassign_role(self,
|
|
370
|
+
name: str,
|
|
371
|
+
assignee_type: str,
|
|
372
|
+
assignee_id: str,
|
|
373
|
+
timeout: float = 10.0):
|
|
374
|
+
"""Remove a role assignment from a principal or group."""
|
|
375
|
+
op = aether_pb2.ACLOperation(
|
|
376
|
+
op=aether_pb2.ACLOperation.UNASSIGN_ROLE,
|
|
377
|
+
name=name,
|
|
378
|
+
principal=aether_pb2.PrincipalRef(
|
|
379
|
+
principal_type=assignee_type,
|
|
380
|
+
principal_id=assignee_id,
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
384
|
+
|
|
385
|
+
def list_role_assignments(self, name: str, timeout: float = 10.0):
|
|
386
|
+
"""List the direct assignees of a role."""
|
|
387
|
+
op = aether_pb2.ACLOperation(
|
|
388
|
+
op=aether_pb2.ACLOperation.LIST_ROLE_ASSIGNMENTS,
|
|
389
|
+
name=name,
|
|
390
|
+
)
|
|
391
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
392
|
+
|
|
393
|
+
def list_principal_roles(self,
|
|
394
|
+
principal_type: str,
|
|
395
|
+
principal_id: str,
|
|
396
|
+
timeout: float = 10.0):
|
|
397
|
+
"""List the roles directly assigned to a principal."""
|
|
398
|
+
op = aether_pb2.ACLOperation(
|
|
399
|
+
op=aether_pb2.ACLOperation.LIST_PRINCIPAL_ROLES,
|
|
400
|
+
principal=aether_pb2.PrincipalRef(
|
|
401
|
+
principal_type=principal_type,
|
|
402
|
+
principal_id=principal_id,
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
406
|
+
|
|
407
|
+
def create_group(self,
|
|
408
|
+
name: str,
|
|
409
|
+
description: str = "",
|
|
410
|
+
created_by: str = "",
|
|
411
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
412
|
+
timeout: float = 10.0):
|
|
413
|
+
"""Create a named group.
|
|
414
|
+
|
|
415
|
+
Note:
|
|
416
|
+
Creating a group that already exists returns a response with
|
|
417
|
+
``success=False`` and an ``error`` rather than raising; callers
|
|
418
|
+
decide how to treat that.
|
|
419
|
+
"""
|
|
420
|
+
op = aether_pb2.ACLOperation(
|
|
421
|
+
op=aether_pb2.ACLOperation.CREATE_GROUP,
|
|
422
|
+
group_request=aether_pb2.ACLGroupRequest(
|
|
423
|
+
name=name,
|
|
424
|
+
description=description,
|
|
425
|
+
created_by=created_by,
|
|
426
|
+
metadata=metadata or {},
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
430
|
+
|
|
431
|
+
def delete_group(self, name: str, timeout: float = 10.0):
|
|
432
|
+
"""Delete a group by name."""
|
|
433
|
+
op = aether_pb2.ACLOperation(
|
|
434
|
+
op=aether_pb2.ACLOperation.DELETE_GROUP,
|
|
435
|
+
name=name,
|
|
436
|
+
)
|
|
437
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
438
|
+
|
|
439
|
+
def get_group(self, name: str, timeout: float = 10.0):
|
|
440
|
+
"""Fetch a group by name."""
|
|
441
|
+
op = aether_pb2.ACLOperation(
|
|
442
|
+
op=aether_pb2.ACLOperation.GET_GROUP,
|
|
443
|
+
name=name,
|
|
444
|
+
)
|
|
445
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
446
|
+
|
|
447
|
+
def list_groups(self, timeout: float = 10.0):
|
|
448
|
+
"""List all groups."""
|
|
449
|
+
op = aether_pb2.ACLOperation(
|
|
450
|
+
op=aether_pb2.ACLOperation.LIST_GROUPS,
|
|
451
|
+
)
|
|
452
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
453
|
+
|
|
454
|
+
def add_group_member(self,
|
|
455
|
+
name: str,
|
|
456
|
+
member_type: str,
|
|
457
|
+
member_id: str,
|
|
458
|
+
granted_by: str = "",
|
|
459
|
+
expires_at: int = 0,
|
|
460
|
+
timeout: float = 10.0):
|
|
461
|
+
"""Add (or refresh) a member of a group.
|
|
462
|
+
|
|
463
|
+
``member_type`` is a principal type or ``"group"`` (for nesting).
|
|
464
|
+
``expires_at`` is Unix seconds; ``0`` means no expiry.
|
|
465
|
+
"""
|
|
466
|
+
op = aether_pb2.ACLOperation(
|
|
467
|
+
op=aether_pb2.ACLOperation.ADD_GROUP_MEMBER,
|
|
468
|
+
name=name,
|
|
469
|
+
member_request=aether_pb2.ACLGroupMemberRequest(
|
|
470
|
+
member_type=member_type,
|
|
471
|
+
member_id=member_id,
|
|
472
|
+
granted_by=granted_by,
|
|
473
|
+
expires_at=expires_at,
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
477
|
+
|
|
478
|
+
def remove_group_member(self,
|
|
479
|
+
name: str,
|
|
480
|
+
member_type: str,
|
|
481
|
+
member_id: str,
|
|
482
|
+
timeout: float = 10.0):
|
|
483
|
+
"""Remove a member from a group."""
|
|
484
|
+
op = aether_pb2.ACLOperation(
|
|
485
|
+
op=aether_pb2.ACLOperation.REMOVE_GROUP_MEMBER,
|
|
486
|
+
name=name,
|
|
487
|
+
principal=aether_pb2.PrincipalRef(
|
|
488
|
+
principal_type=member_type,
|
|
489
|
+
principal_id=member_id,
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
493
|
+
|
|
494
|
+
def list_group_members(self, name: str, timeout: float = 10.0):
|
|
495
|
+
"""List the direct members of a group."""
|
|
496
|
+
op = aether_pb2.ACLOperation(
|
|
497
|
+
op=aether_pb2.ACLOperation.LIST_GROUP_MEMBERS,
|
|
498
|
+
name=name,
|
|
499
|
+
)
|
|
500
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
501
|
+
|
|
502
|
+
def list_principal_groups(self,
|
|
503
|
+
principal_type: str,
|
|
504
|
+
principal_id: str,
|
|
505
|
+
timeout: float = 10.0):
|
|
506
|
+
"""List the groups a principal is a direct member of."""
|
|
507
|
+
op = aether_pb2.ACLOperation(
|
|
508
|
+
op=aether_pb2.ACLOperation.LIST_PRINCIPAL_GROUPS,
|
|
509
|
+
principal=aether_pb2.PrincipalRef(
|
|
510
|
+
principal_type=principal_type,
|
|
511
|
+
principal_id=principal_id,
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
515
|
+
|
|
516
|
+
def explain_access(self,
|
|
517
|
+
principal_type: str,
|
|
518
|
+
principal_id: str,
|
|
519
|
+
resource_type: str,
|
|
520
|
+
resource_id: str,
|
|
521
|
+
required_level: int = 0,
|
|
522
|
+
timeout: float = 10.0):
|
|
523
|
+
"""Explain how a principal's effective access to a resource is decided.
|
|
524
|
+
|
|
525
|
+
Resolves the subject set (self + transitive groups/roles), every rule
|
|
526
|
+
that matched, and the resulting decision. This does not gate access,
|
|
527
|
+
but the gateway records an ``explain_access`` audit event attributing
|
|
528
|
+
the call to the connected principal. ``required_level`` is the
|
|
529
|
+
threshold the decision is compared against (``0`` = NONE).
|
|
530
|
+
|
|
531
|
+
Read the result from the response's ``explanation`` field
|
|
532
|
+
(:class:`~aether_pb2.ACLAccessExplanationInfo`), whose ``allowed`` and
|
|
533
|
+
``effective_access_level`` fields carry the resolved decision.
|
|
534
|
+
"""
|
|
535
|
+
op = aether_pb2.ACLOperation(
|
|
536
|
+
op=aether_pb2.ACLOperation.EXPLAIN_ACCESS,
|
|
537
|
+
principal=aether_pb2.PrincipalRef(
|
|
538
|
+
principal_type=principal_type,
|
|
539
|
+
principal_id=principal_id,
|
|
540
|
+
),
|
|
541
|
+
resource_type=resource_type,
|
|
542
|
+
resource_id=resource_id,
|
|
543
|
+
required_level=required_level,
|
|
544
|
+
)
|
|
545
|
+
return self._client.acl_op(op, timeout=timeout)
|
|
546
|
+
|
|
232
547
|
# ------------------------------------------------------------------
|
|
233
548
|
# Workspace Operations
|
|
234
549
|
# ------------------------------------------------------------------
|