Skip to content

Commit 22ffa12

Browse files
committed
Harmonize skills: add a2a-workflow and build-and-release, standardize AGENTS.md and frontmatter
1 parent ce90787 commit 22ffa12

3 files changed

Lines changed: 540 additions & 121 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
---
2+
name: a2a-workflow
3+
description: Use when setting up or troubleshooting Safeguard A2A certificate registrations, API keys, credential retrieval, brokering, or A2A-backed event listeners.
4+
---
5+
6+
# A2A workflow
7+
8+
## 1. What A2A is
9+
10+
PySafeguard's A2A support is centered on `A2AContext` and `AsyncA2AContext`, thin wrappers over Safeguard's Application-to-Application APIs. The normal A2A credential calls (`retrieve_password`, `set_password`, `retrieve_private_key`, `set_private_key`, `retrieve_api_key_secret`) do **not** use a user bearer token. Instead, each request combines a trusted client certificate on the TLS connection with an `Authorization: A2A <api_key>` header that identifies the specific retrievable account registration. The one exception is `get_retrievable_accounts()`, which lazily logs in with `CertificateAuth` and queries Core API registration data.
11+
12+
### Relevant SDK surface
13+
14+
| Item | Location | Notes |
15+
|---|---|---|
16+
| `A2AContext` | `src/pysafeguard/a2a.py` | Sync A2A helper built on `SafeguardClient` |
17+
| `AsyncA2AContext` | `src/pysafeguard/async_a2a.py` | Async mirror built on `AsyncSafeguardClient` |
18+
| `A2AType` | `src/pysafeguard/data_types.py` | `PASSWORD`, `PRIVATEKEY`, `APIKEYSECRET` |
19+
| `SshKeyFormat` | `src/pysafeguard/data_types.py` | `OPENSSH`, `SSH2`, `PUTTY` |
20+
| `HiddenString` | `src/pysafeguard/hidden_string.py` | Returned for passwords/private keys |
21+
| `SafeguardEventListener` | `src/pysafeguard/event.py` | Used for A2A-backed SignalR listeners |
22+
23+
### Supported A2A credential types
24+
25+
| Enum value | Meaning | Primary method |
26+
|---|---|---|
27+
| `A2AType.PASSWORD` | Managed password retrieval | `retrieve_password()` / `set_password()` |
28+
| `A2AType.PRIVATEKEY` | SSH private key retrieval | `retrieve_private_key()` / `set_private_key()` |
29+
| `A2AType.APIKEYSECRET` | API key secret retrieval | `retrieve_api_key_secret()` |
30+
31+
## 2. Setup flow
32+
33+
The repository's most concrete A2A setup reference is `tests/integration/test_a2a.py`. That fixture builds a complete appliance-side A2A environment and is the best source of truth for how the SDK expects the feature to be provisioned.
34+
35+
### Appliance-side setup sequence
36+
37+
1. Acquire or generate a PEM client certificate and matching private key
38+
2. Upload the certificate to `Service.CORE`, endpoint `TrustedCertificates`
39+
3. Create a certificate-backed user whose `PrimaryAuthenticationProvider` is certificate auth (`Id: -2`) and whose `Identity` is the cert thumbprint
40+
4. Create or identify the asset account that should be retrievable
41+
5. Create an A2A registration with `POST Service.CORE, "A2ARegistrations"`
42+
6. Set `CertificateUserId` on that registration
43+
7. If write-back is required, enable `BidirectionalEnabled` on the registration before calling `set_password()` or `set_private_key()`
44+
8. Add retrievable accounts with `POST A2ARegistrations/{reg_id}/RetrievableAccounts`
45+
9. Persist the returned `ApiKey` securely; that value becomes the `api_key` argument passed to SDK methods
46+
47+
### What the integration tests actually provision
48+
49+
`tests/integration/test_a2a.py` creates:
50+
51+
- a trusted self-signed client certificate
52+
- a certificate user linked to the uploaded cert thumbprint
53+
- an `Other Managed` asset
54+
- a password-managed account
55+
- a second account with an SSH key
56+
- an A2A registration with both password and private-key retrievable accounts
57+
- `BidirectionalEnabled = True` so mutation APIs can be exercised
58+
59+
That makes the tests a good recipe when you need a disposable appliance setup for real validation.
60+
61+
### Minimal sync usage
62+
63+
```python
64+
from pysafeguard import A2AContext
65+
66+
with A2AContext(host, cert_file, key_file, verify=ca_file) as ctx:
67+
password = ctx.retrieve_password(api_key)
68+
print(password.value)
69+
```
70+
71+
### Minimal async usage
72+
73+
```python
74+
from pysafeguard import AsyncA2AContext
75+
76+
async with AsyncA2AContext(host, cert_file, key_file, verify=ca_file) as ctx:
77+
password = await ctx.retrieve_password(api_key)
78+
print(password.value)
79+
```
80+
81+
### Constructor rules
82+
83+
Both context classes enforce certificate input up front:
84+
85+
- `cert_file` is required
86+
- `key_file` is required
87+
- missing either raises `ValueError("cert_file and key_file are required for A2A context")`
88+
89+
`verify` accepts the same forms as the main client classes:
90+
91+
- `True` - use system trust
92+
- `False` - disable TLS verification
93+
- `str` - CA bundle path
94+
95+
## 3. Credential retrieval
96+
97+
### Core methods
98+
99+
| Method | Returns | Notes |
100+
|---|---|---|
101+
| `retrieve_password(api_key)` | `HiddenString` | GET `Service.A2A/Credentials?type=password` |
102+
| `set_password(api_key, password)` | `None` | PUT `Credentials/Password` with JSON body |
103+
| `retrieve_private_key(api_key, key_format=...)` | `HiddenString` | Adds `key_format` query param for private-key retrieval |
104+
| `set_private_key(api_key, private_key, passphrase="", key_format=...)` | `None` | PUT `Credentials/SshKey` with `PrivateKey` and `Passphrase` |
105+
| `retrieve_api_key_secret(api_key)` | `JsonType` | Raw JSON result, not `HiddenString` |
106+
| `get_retrievable_accounts(filter=None)` | `list[dict[str, JsonType]]` | Uses Core API, not direct A2A auth |
107+
108+
### One-shot helpers
109+
110+
`A2AContext` includes convenience helpers when you do not want to keep a context open:
111+
112+
- `A2AContext.quick_retrieve_password(...)`
113+
- `A2AContext.quick_retrieve_private_key(...)`
114+
- `AsyncA2AContext.quick_retrieve_password(...)`
115+
- `AsyncA2AContext.quick_retrieve_private_key(...)`
116+
117+
There is **no** quick helper for API key secret retrieval today; create a context and call `retrieve_api_key_secret()` directly.
118+
119+
### HiddenString handling
120+
121+
Passwords and private keys are wrapped in `HiddenString`.
122+
123+
- prefer `.value` to access plaintext
124+
- `.get_value()` still works, but is deprecated in favor of `.value`
125+
- `print(secret)` shows `***`
126+
- `repr(secret)` shows `HiddenString(***)`
127+
- `dispose()` zeroes the internal bytearray buffer
128+
- the object also works as a context manager for scoped secret use
129+
130+
Example:
131+
132+
```python
133+
with A2AContext(host, cert_file, key_file, verify=ca_file) as ctx:
134+
with ctx.retrieve_password(api_key) as password:
135+
use_password(password.value)
136+
```
137+
138+
### Retrievable account discovery
139+
140+
`get_retrievable_accounts()` is special:
141+
142+
- it lazily sets `self._conn._auth = CertificateAuth(...)`
143+
- it calls `login()` to obtain a user token
144+
- it lists `A2ARegistrations`
145+
- it queries each registration's `RetrievableAccounts`
146+
- it decorates each account with registration metadata:
147+
- `ApplicationName`
148+
- `Description`
149+
- `Disabled`
150+
151+
Use this when you need inventory/discovery behavior, not when you already have an API key.
152+
153+
## 4. Brokering
154+
155+
A2A brokering is supported in both sync and async contexts.
156+
157+
### API surface
158+
159+
- `A2AContext.broker_access_request(api_key, access_request)`
160+
- `AsyncA2AContext.broker_access_request(api_key, access_request)`
161+
162+
### How it works
163+
164+
The SDK sends:
165+
166+
- `POST Service.A2A, "AccessRequests"`
167+
- JSON body = the `access_request` dict you provide
168+
- headers include `Authorization: A2A <api_key>`
169+
- client cert tuple is passed through the request
170+
171+
On success, the method returns the created access request identifier as `str`.
172+
173+
### Important limitation
174+
175+
The SDK does **not** define a higher-level request model for brokering. You must construct the `access_request` payload yourself to match the Safeguard appliance API you are targeting. There is also no sample script or integration test for brokering in this repository, so validate the payload against a live appliance before depending on it.
176+
177+
## 5. Event listeners / SignalR
178+
179+
A2A contexts can produce SignalR listeners even though the listener implementation itself lives in `event.py`.
180+
181+
### Listener factory methods
182+
183+
| Method | Returns | Notes |
184+
|---|---|---|
185+
| `get_event_listener(api_key)` | `SafeguardEventListener` | Uses the A2A API key as the SignalR access token |
186+
| `get_persistent_event_listener(api_key)` | `PersistentSafeguardEventListener` | Reconnects with `token_factory=lambda: api_key` |
187+
188+
### Async behavior
189+
190+
`AsyncA2AContext.get_event_listener()` and `.get_persistent_event_listener()` are still **synchronous** methods that return the same thread-based listener classes used by the sync API. The integration tests explicitly verify this behavior.
191+
192+
### SignalR prerequisites
193+
194+
- install the `signalr` extra: `pip install pysafeguard[signalr]`
195+
- listener connects to `Service.EVENT/signalr`
196+
- if `verify` is a CA path, `event.py` builds an `ssl.SSLContext` for `signalrcore`
197+
- if `verify` is `False`, the listener disables WebSocket TLS verification
198+
199+
### A2A-specific event handling quirk
200+
201+
`EventHandlerRegistry.handle_event()` has an A2A workaround: if the incoming event `Name` is numeric, it extracts the real event name from `Data.EventName`. Preserve that behavior when changing listener code; it matches the SafeguardDotNet behavior expected by the appliance.
202+
203+
### General listener usage pattern
204+
205+
The sample files `samples/SignalRExample.py` and `samples/PersistentSignalRExample.py` show the standard listener lifecycle:
206+
207+
- call `.on("EventName", handler)` to register handlers
208+
- optionally call `.on_state_change(callback)`
209+
- call `.start()`
210+
- call `.stop()` or leave the context manager
211+
212+
A2A listeners use the same API; the only difference is that you obtain them from `A2AContext` with an API key instead of from an authenticated `SafeguardClient`.
213+
214+
## 6. Error scenarios and troubleshooting
215+
216+
### Common configuration failures
217+
218+
- Missing `cert_file` or `key_file` -> `ValueError`
219+
- Empty `api_key` -> `ValueError("api_key must not be empty")`
220+
- Untrusted or mismatched certificate -> request/login failures from the appliance
221+
- Cert user or registration not configured -> A2A or Core API authorization failures
222+
223+
### Authentication and authorization failures
224+
225+
Integration tests show that an invalid API key raises `AuthorizationError` with status code `403` for both sync and async retrieval methods.
226+
227+
If the error comes from `get_retrievable_accounts()`, remember that the failing path is certificate-backed user login against the Core API, not direct `Authorization: A2A ...` credential retrieval.
228+
229+
### Content-type gotcha for password updates
230+
231+
`set_password()` uses `json=` internally. If you bypass the helper and call the endpoint yourself with `data=`, Safeguard returns `415 Unsupported Media Type`. Use the SDK helper or send JSON manually.
232+
233+
### Extras and import failures
234+
235+
- `AsyncA2AContext` depends on the `async` extra (`aiohttp`)
236+
- SignalR listeners depend on the `signalr` extra (`signalrcore`)
237+
- `event.py` raises a helpful `SafeguardError` when `signalrcore` is missing
238+
239+
### SignalR handshake issue
240+
241+
`signalrcore` 1.0.2 has a protocol-version bug. PySafeguard works around it by forcing `JsonHubProtocol(version=1)`. If A2A event listeners suddenly start failing after listener refactors, verify that `_json_hub_protocol()` still forces version `1`.
242+
243+
### TLS troubleshooting
244+
245+
Use a CA bundle path instead of `verify=False` whenever possible.
246+
247+
Helpful environment variables for appliance environments that use an internal CA:
248+
249+
- `REQUESTS_CA_BUNDLE` for HTTP requests
250+
- `WEBSOCKET_CLIENT_CA_BUNDLE` for SignalR/WebSocket traffic
251+
252+
### Safe debugging rules
253+
254+
- never print or log `password.value` or private-key plaintext in committed samples/tests
255+
- prefer inspecting `HiddenString` redacted behavior unless plaintext is strictly required
256+
- dispose transient secrets promptly when writing new helpers
257+
- do not commit client certs, private keys, A2A API keys, or captured access-request payloads

0 commit comments

Comments
 (0)