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