From 7ab722e30d94be6ed70fe97b4de7be1d9faeeeb0 Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:04:16 +0200 Subject: [PATCH] Add connected_app to identity.create_api_key The sync and async clients take connected_app and send it only when given. Existing calls send the same body as before. Tests cover the request body and the flag on created and listed keys. The README and changelog describe the parameter. --- CHANGELOG.md | 5 +++++ README.md | 9 +++++++++ src/faxforagents/async_client.py | 11 +++++++++-- src/faxforagents/client.py | 11 +++++++++-- tests/conftest.py | 19 +++++++++++++++++++ tests/test_async.py | 24 +++++++++++++++++++++++- tests/test_client.py | 31 ++++++++++++++++++++++++++++++- 7 files changed, 104 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea9b40..948169c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +`identity.create_api_key` takes `connected_app` on both clients. A key made with `connected_app=True` is bound by the auto-send setting like an OAuth connection. +The field is sent only when given, so existing calls send the same body. Needs API 1.12.0. + ## 0.3.1 (2026-11-10) `faxes.list` takes `idempotency_key`, a filter that finds the send made with that key. diff --git a/README.md b/README.md index a2b091e..9d44c9f 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,15 @@ if req.status == "approved": An agent's own API key can never approve. `ffa.policies.evaluate(quote_id=...)` is a dry run that says whether a policy would allow the send. +A key made for a connected app used over stdio, such as the Gemini CLI extension, is created with +`connected_app=True`. The organization's auto-send setting (`ffa.policies.get_auto_send`) then binds it like +an OAuth connection. An agent's key cannot carry the flag. + +```python +key = ffa.identity.create_api_key("gemini", scopes=["fax:send"], connected_app=True) +key.connected_app # True, also on ffa.identity.list_api_keys() +``` + ## Webhooks ```python diff --git a/src/faxforagents/async_client.py b/src/faxforagents/async_client.py index e1435b0..08fdd68 100644 --- a/src/faxforagents/async_client.py +++ b/src/faxforagents/async_client.py @@ -54,15 +54,22 @@ async def create_api_key( environment: str = "test", scopes: list[str] | None = None, expires_in_days: int | None = None, + connected_app: bool | None = None, idempotency_key: str | None = None, ) -> m.ApiKeyWithSecret: - """Create a key. The secret is in the answer once. Scope: key:admin.""" - body = { + """Create a key. The secret is in the answer once. Scope: key:admin. + + ``connected_app=True`` marks the key as one a connected app uses over stdio, such as the Gemini CLI + extension: the organization's auto-send setting binds it like an OAuth connection. The answer and + ``list_api_keys`` carry the flag. Needs API 1.12.0; older servers refuse the field.""" + body: dict[str, Any] = { "label": label, "environment": environment, "scopes": scopes, "expires_in_days": expires_in_days, } + if connected_app is not None: + body["connected_app"] = connected_app return await self._h.request( "POST", "/v1/api-keys", model=m.ApiKeyWithSecret, json=body, idempotency_key=idempotency_key ) diff --git a/src/faxforagents/client.py b/src/faxforagents/client.py index de47599..0bc304a 100644 --- a/src/faxforagents/client.py +++ b/src/faxforagents/client.py @@ -69,15 +69,22 @@ def create_api_key( environment: str = "test", scopes: list[str] | None = None, expires_in_days: int | None = None, + connected_app: bool | None = None, idempotency_key: str | None = None, ) -> m.ApiKeyWithSecret: - """Create a key. The secret is in the answer once. Scope: key:admin.""" - body = { + """Create a key. The secret is in the answer once. Scope: key:admin. + + ``connected_app=True`` marks the key as one a connected app uses over stdio, such as the Gemini CLI + extension: the organization's auto-send setting binds it like an OAuth connection. The answer and + ``list_api_keys`` carry the flag. Needs API 1.12.0; older servers refuse the field.""" + body: dict[str, Any] = { "label": label, "environment": environment, "scopes": scopes, "expires_in_days": expires_in_days, } + if connected_app is not None: + body["connected_app"] = connected_app return self._h.request( "POST", "/v1/api-keys", model=m.ApiKeyWithSecret, json=body, idempotency_key=idempotency_key ) diff --git a/tests/conftest.py b/tests/conftest.py index edb987f..9a9048d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,3 +131,22 @@ def error(code: str, message: str = "refused", **extra: Any) -> dict[str, Any]: def live_key() -> str | None: return os.environ.get("FAXFORAGENTS_API_KEY") or None + + +def api_key_with_secret(**over: Any) -> dict[str, Any]: + base = { + "id": "key_" + "5" * 32, + "label": "gemini", + "prefix": "ffa_test_aaaaaaaa", + "environment": "test", + "project_id": "prj_1", + "agent_id": None, + "scopes": ["fax:send"], + "created_at": "2026-11-10T10:00:00Z", + "last_used_at": None, + "expires_at": None, + "revoked_at": None, + "rotated_from_id": None, + "secret": "ffa_test_" + "a" * 43, + } + return {**base, **over} diff --git a/tests/test_async.py b/tests/test_async.py index d499181..a41310a 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -6,7 +6,7 @@ import respx from faxforagents import AsyncClient -from tests.conftest import fax_job, quote +from tests.conftest import api_key_with_secret, fax_job, quote async def test_async_send_and_wait(aclient: AsyncClient, api: respx.MockRouter) -> None: @@ -85,3 +85,25 @@ async def test_async_receipt_pdf_and_auto_send(aclient: AsyncClient, api: respx. async def test_async_oauth_grants_iter(aclient: AsyncClient, api: respx.MockRouter) -> None: api.get("/v1/oauth/grants").respond(200, json={"items": [], "next_cursor": None}) assert [g.id async for g in aclient.oauth.iter_grants()] == [] + + +async def test_async_create_api_key_sends_connected_app_only_when_given( + aclient: AsyncClient, api: respx.MockRouter +) -> None: + import json + + route = api.post("/v1/api-keys") + route.side_effect = [ + httpx.Response(201, json=api_key_with_secret(connected_app=True)), + httpx.Response(201, json=api_key_with_secret()), + ] + made = await aclient.identity.create_api_key("gemini", connected_app=True, idempotency_key="k1") + assert made.connected_app is True + assert json.loads(route.calls.last.request.content) == { + "label": "gemini", + "environment": "test", + "connected_app": True, + } + assert route.calls.last.request.headers["idempotency-key"] == "k1" + assert (await aclient.identity.create_api_key("plain")).connected_app is False + assert "connected_app" not in json.loads(route.calls.last.request.content) diff --git a/tests/test_client.py b/tests/test_client.py index fe16eff..fec7b9b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,7 +20,7 @@ ScopeError, ServerError, ) -from tests.conftest import BASE, KEY, document, error, fax_job, quote +from tests.conftest import BASE, KEY, api_key_with_secret, document, error, fax_job, quote def test_bearer_header_and_user_agent(client: Client, api: respx.MockRouter) -> None: @@ -343,3 +343,32 @@ def test_effective_policies_can_ask_about_another_principal(client: Client, api: with pytest.raises(Exception): # noqa: B017, PT011 Client(api_key=KEY, base_url=BASE, max_retries=0).policies.effective() assert str(route.calls.last.request.url) == f"{BASE}/v1/policies/effective" + + +def test_create_api_key_sends_connected_app_only_when_given(client: Client, api: respx.MockRouter) -> None: + route = api.post("/v1/api-keys") + route.side_effect = [ + httpx.Response(201, json=api_key_with_secret(connected_app=True)), + httpx.Response(201, json=api_key_with_secret(connected_app=False)), + httpx.Response(201, json=api_key_with_secret()), + ] + made = client.identity.create_api_key("gemini", scopes=["fax:send"], connected_app=True) + assert made.connected_app is True + assert json.loads(route.calls.last.request.content) == { + "label": "gemini", + "environment": "test", + "scopes": ["fax:send"], + "connected_app": True, + } + assert client.identity.create_api_key("plain", connected_app=False).connected_app is False + assert json.loads(route.calls.last.request.content)["connected_app"] is False + # Left out, the field is not sent and an answer without it reads as a plain key. + assert client.identity.create_api_key("older").connected_app is False + assert json.loads(route.calls.last.request.content) == {"label": "older", "environment": "test"} + + +def test_list_api_keys_reads_connected_app(client: Client, api: respx.MockRouter) -> None: + flagged = {k: v for k, v in api_key_with_secret(connected_app=True).items() if k != "secret"} + plain = {k: v for k, v in api_key_with_secret(id="key_plain").items() if k != "secret"} + api.get("/v1/api-keys").respond(200, json={"items": [flagged, plain]}) + assert [k.connected_app for k in client.identity.list_api_keys().items] == [True, False]