scitrera-aether-client 0.2.0__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.
Files changed (52) hide show
  1. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/PKG-INFO +8 -6
  2. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/README.md +1 -1
  3. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/pyproject.toml +13 -5
  4. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/__init__.py +10 -1
  5. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/_common.py +83 -5
  6. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/admin.py +318 -3
  7. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/admin_async.py +320 -4
  8. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/client.py +40 -5
  9. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/client_async.py +269 -30
  10. scitrera_aether_client-0.2.2/scitrera_aether_client/proto/aether_pb2.py +561 -0
  11. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proto/aether_pb2.pyi +431 -67
  12. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proto/aether_pb2_grpc.py +4 -4
  13. scitrera_aether_client-0.2.2/scitrera_aether_client/proto/sandbox_relay_tunnel_pb2.py +46 -0
  14. scitrera_aether_client-0.2.2/scitrera_aether_client/proto/sandbox_relay_tunnel_pb2.pyi +37 -0
  15. scitrera_aether_client-0.2.2/scitrera_aether_client/proto/sandbox_relay_tunnel_pb2_grpc.py +161 -0
  16. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proxy.py +24 -4
  17. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proxy_terminator.py +26 -0
  18. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/PKG-INFO +8 -6
  19. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/SOURCES.txt +3 -0
  20. scitrera_aether_client-0.2.2/scitrera_aether_client.egg-info/requires.txt +13 -0
  21. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_admin.py +222 -0
  22. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_client.py +70 -0
  23. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_proxy.py +70 -1
  24. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_proxy_terminator.py +49 -0
  25. scitrera_aether_client-0.2.0/scitrera_aether_client/proto/aether_pb2.py +0 -517
  26. scitrera_aether_client-0.2.0/scitrera_aether_client.egg-info/requires.txt +0 -10
  27. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/authority.py +0 -0
  28. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/authority_cache.py +0 -0
  29. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/__init__.py +0 -0
  30. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/echo_agent.py +0 -0
  31. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/echo_agent_async.py +0 -0
  32. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/echo_test/echo_orchestrator.py +0 -0
  33. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/exceptions.py +0 -0
  34. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/httpx_transport.py +0 -0
  35. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/metrics.py +0 -0
  36. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/orchestrator/__init__.py +0 -0
  37. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/orchestrator/multiprocess.py +0 -0
  38. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/proto/__init__.py +0 -0
  39. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/py.typed +0 -0
  40. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/requests_adapter.py +0 -0
  41. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/tunnel.py +0 -0
  42. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client/types.py +0 -0
  43. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/dependency_links.txt +0 -0
  44. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/scitrera_aether_client.egg-info/top_level.txt +0 -0
  45. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/setup.cfg +0 -0
  46. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_authority.py +0 -0
  47. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_authority_cache.py +0 -0
  48. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_client_async.py +0 -0
  49. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_orchestrator.py +0 -0
  50. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_proxy_streaming.py +0 -0
  51. {scitrera_aether_client-0.2.0 → scitrera_aether_client-0.2.2}/tests/test_requests_adapter.py +0 -0
  52. {scitrera_aether_client-0.2.0 → 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.0
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.80.0
26
- Requires-Dist: protobuf>6
25
+ Requires-Dist: grpcio==1.81.1
26
+ Requires-Dist: protobuf>=6.33.5
27
27
  Provides-Extra: dev
28
- Requires-Dist: grpcio==1.80.0; extra == "dev"
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"
@@ -259,7 +261,7 @@ Aether defines the following principal types. Each maps to a dedicated client cl
259
261
  | **WorkflowEngine** | `WorkflowEngineClient` / `AsyncWorkflowEngineClient` | `wfe::shard0` | Singleton invariant; `Implementation` reserved for future multi-shard. Processes `EVENT`-type messages and triggers downstream automation. Has the broadest send permissions — can target any principal type. |
260
262
  | **MetricsBridge** | `MetricsBridgeClient` / `AsyncMetricsBridgeClient` | `metrics::shard0` | Singleton invariant matching WFE sharding model; `Implementation` reserved for future multi-shard. Receive-only telemetry sink. Collects `METRIC`-type messages from agents and tasks; cannot send messages. |
261
263
  | **Service** | `ServiceClient` / `AsyncServiceClient` | `sv::{impl}::{spec}` | Trusted backend intermediary. Workspace-less; authenticates as itself and performs privileged operations on behalf of users via `AuthorizationContext`. Use for app/WebSocket backends proxying user actions. |
262
- | **Bridge** | *(not yet in Python SDK)* | `br::{impl}::{spec}` | Cross-workspace messaging integration (e.g., Discord, Teams, Email). Has no workspace component and can send to any workspace. Implemented as a standalone server (`cmd/msgbridge`). |
264
+ | **Bridge** | *(not yet in Python SDK)* | `br::{impl}::{spec}` | Cross-workspace messaging integration (e.g., Discord, Teams, Email). Has no workspace component and can send to any workspace. |
263
265
 
264
266
  ---
265
267
 
@@ -224,7 +224,7 @@ Aether defines the following principal types. Each maps to a dedicated client cl
224
224
  | **WorkflowEngine** | `WorkflowEngineClient` / `AsyncWorkflowEngineClient` | `wfe::shard0` | Singleton invariant; `Implementation` reserved for future multi-shard. Processes `EVENT`-type messages and triggers downstream automation. Has the broadest send permissions — can target any principal type. |
225
225
  | **MetricsBridge** | `MetricsBridgeClient` / `AsyncMetricsBridgeClient` | `metrics::shard0` | Singleton invariant matching WFE sharding model; `Implementation` reserved for future multi-shard. Receive-only telemetry sink. Collects `METRIC`-type messages from agents and tasks; cannot send messages. |
226
226
  | **Service** | `ServiceClient` / `AsyncServiceClient` | `sv::{impl}::{spec}` | Trusted backend intermediary. Workspace-less; authenticates as itself and performs privileged operations on behalf of users via `AuthorizationContext`. Use for app/WebSocket backends proxying user actions. |
227
- | **Bridge** | *(not yet in Python SDK)* | `br::{impl}::{spec}` | Cross-workspace messaging integration (e.g., Discord, Teams, Email). Has no workspace component and can send to any workspace. Implemented as a standalone server (`cmd/msgbridge`). |
227
+ | **Bridge** | *(not yet in Python SDK)* | `br::{impl}::{spec}` | Cross-workspace messaging integration (e.g., Discord, Teams, Email). Has no workspace component and can send to any workspace. |
228
228
 
229
229
  ---
230
230
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scitrera-aether-client"
7
- version = "0.2.0"
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.80.0",
40
- "protobuf>6",
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.80.0",
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",
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.0"
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',
@@ -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: str, specifier: str) -> str:
239
- """Create a topic string for a specific service principal.
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
- Service principals are workspace-less (canonical identity is
242
- ``sv::{implementation}::{specifier}``); the topic mirrors that shape.
243
- Mirrors :func:`models.topics.ServiceTopic` on the gateway side.
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,
@@ -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, timeout: float = 10.0):
172
- """Delete (revoke) an ACL rule by ID."""
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
  # ------------------------------------------------------------------