Skip to content

Commit 0009349

Browse files
committed
ENV-18 govern Gemini auth env
1 parent a7ba9e8 commit 0009349

13 files changed

Lines changed: 680 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Changed
11+
- **Governed authority mode**: HTTP and Live WebSocket calls can now use a `Gemini.GovernedAuthority` value that supplies authority-selected base URLs and materialized credential headers/query params while rejecting unmanaged request credentials in governed mode.
1112
- **Provider payload parsing**: service account keys, SSE fields, model list payloads, Live response modalities, and WebSocket redaction now use bounded field maps and deterministic string parsing.
1213

1314
### Tests
15+
- Added governed authority regression coverage for HTTP auth materialization, request override rejection, WebSocket query redaction, and standalone auth compatibility.
1416
- Added focused regression coverage for provider-authored service account keys and unknown SSE fields to ensure they are ignored instead of interned as new atoms.
1517

1618
## [0.13.0] - 2026-04-02

README.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Or set the environment variable:
8383
export GEMINI_API_KEY="your_api_key_here"
8484
```
8585

86-
For default Gemini auth resolution, `config :gemini_ex, api_key: ...` now takes precedence over `GEMINI_API_KEY`. Narrower overrides still win: pass `api_key:` directly on a request or on `Gemini.Live.Session.start_link/1` for session-scoped credentials.
86+
For default Gemini auth resolution, `config :gemini_ex, api_key: ...` now takes precedence over `GEMINI_API_KEY`. Narrower overrides still win in standalone mode: pass `api_key:` directly on a request or on `Gemini.Live.Session.start_link/1` for session-scoped credentials. Governed mode does not use these standalone sources; pass `Gemini.GovernedAuthority` so credentials and targets come from the selected authority materializer.
8787

8888
### Simple Content Generation
8989

@@ -1380,13 +1380,19 @@ The examples follow a consistent pattern:
13801380

13811381
## Authentication
13821382

1383-
### Gemini API Key (Recommended for Development)
1383+
### Gemini API Key (Standalone Compatibility)
13841384

13851385
Default Gemini key precedence is:
13861386
- Per-request or per-session `api_key:`
13871387
- Application config: `config :gemini_ex, api_key: ...`
13881388
- Environment variable: `GEMINI_API_KEY`
13891389

1390+
These sources are the standalone SDK compatibility path. They are not governed
1391+
authority. In governed runtime calls, pass a `Gemini.GovernedAuthority` instead;
1392+
the HTTP and Live WebSocket clients reject unmanaged `api_key`, Vertex token,
1393+
service account, project, location, ADC, and base URL overrides whenever a
1394+
governed authority is present.
1395+
13901396
```bash
13911397
export GEMINI_API_KEY="your_api_key"
13921398
```
@@ -1400,10 +1406,51 @@ Gemini.generate("Hello", api_key: "specific_key")
14001406
Gemini.Live.Session.start_link(
14011407
model: Gemini.Live.Models.resolve(:audio),
14021408
api_key: "session_specific_key"
1409+
)
1410+
```
1411+
1412+
### Governed Authority
1413+
1414+
Governed execution uses authority-materialized values selected outside the SDK:
1415+
1416+
```elixir
1417+
authority =
1418+
Gemini.GovernedAuthority.new!(
1419+
base_url: "https://generativelanguage.googleapis.com/v1beta",
1420+
credential_ref: "credential-123",
1421+
credential_lease_ref: "lease-123",
1422+
target_ref: "target-123",
1423+
redaction_ref: "redaction-123",
1424+
credential_headers: %{"x-goog-api-key" => "materialized-key"}
1425+
)
1426+
1427+
Gemini.Client.HTTP.post("models/gemini-2.5-flash:generateContent", %{contents: []},
1428+
governed_authority: authority
1429+
)
1430+
```
1431+
1432+
For Live WebSocket sessions, authority can materialize a redacted query
1433+
credential instead of a raw session `api_key:`:
1434+
1435+
```elixir
1436+
authority =
1437+
Gemini.GovernedAuthority.new!(
1438+
base_url: "wss://generativelanguage.googleapis.com",
1439+
websocket_path:
1440+
"/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent",
1441+
credential_ref: "credential-123",
1442+
credential_lease_ref: "lease-123",
1443+
target_ref: "target-123",
1444+
credential_query_params: [{"key", "materialized-key"}]
14031445
)
1446+
1447+
Gemini.Live.Session.start_link(
1448+
model: Gemini.Live.Models.resolve(:audio),
1449+
governed_authority: authority
1450+
)
14041451
```
14051452

1406-
### Vertex AI (Recommended for Production)
1453+
### Vertex AI (Standalone Compatibility)
14071454

14081455
```elixir
14091456
# Service Account JSON file
@@ -1424,6 +1471,7 @@ config :gemini_ex, :auth,
14241471
### Application Default Credentials (ADC)
14251472

14261473
Zero-config GCP authentication with automatic credential discovery and token refresh.
1474+
ADC remains standalone compatibility only and cannot satisfy governed authority.
14271475

14281476
```bash
14291477
# Configure ADC explicitly (optional)

config/config.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import Config
33
# Configure the Gemini client
44
config :gemini_ex,
55
# Default model is auto-detected based on authentication:
6-
# - Gemini API (GEMINI_API_KEY): "gemini-flash-lite-latest"
7-
# - Vertex AI (VERTEX_PROJECT_ID): "gemini-2.5-flash-lite"
6+
# - Standalone Gemini API (GEMINI_API_KEY): "gemini-flash-lite-latest"
7+
# - Standalone Vertex AI (VERTEX_PROJECT_ID): "gemini-2.5-flash-lite"
88
# Uncomment to override: default_model: "your-model-name",
99

1010
# HTTP timeout in milliseconds

config/dev.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ config :gemini_ex,
55
# Enable debug logging in development
66
log_level: :debug
77

8-
# You can set your API key here for development, or use environment variables
8+
# Standalone development can set an API key here or use environment variables.
9+
# Governed execution passes Gemini.GovernedAuthority instead.
910
# config :gemini_ex, api_key: "your_development_api_key"

guides/AUTHENTICATION_SYSTEM.md

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ The system is built on two core principles:
1313
1. **Dual-Strategy Support:** It natively supports both the simple **Gemini API Key** (`:gemini`) and the more complex **Vertex AI OAuth/JWT** (`:vertex_ai`) authentication methods.
1414
2. **Hierarchical Configuration:** It provides a clear and predictable order of precedence for sourcing credentials, prioritizing per-request overrides, then environment variables, and finally Elixir application configuration.
1515

16-
This design makes the library easy to use for simple scripts while being powerful enough for complex, multi-tenant production applications.
16+
This design makes the library easy to use for standalone scripts while keeping
17+
governed runtime effects separate. Env, app config, ADC, native Google
18+
credential discovery, and direct request/session overrides are standalone
19+
compatibility only. Governed execution passes `Gemini.GovernedAuthority`, which
20+
contains the authority-selected base URL, credential reference, lease reference,
21+
target reference, redaction reference, and materialized credential headers or
22+
query params for one bounded effect.
1723

1824
### 2. Core Concepts: Authentication Strategies
1925

@@ -207,9 +213,11 @@ opts = [
207213
{:ok, stream_id} = Gemini.APIs.Coordinator.stream_generate_content("Hello", opts)
208214
```
209215

210-
#### Method 2: Environment Variables (Recommended for Production)
216+
#### Method 2: Environment Variables (Standalone Compatibility)
211217

212-
This is the most secure and standard way to provide credentials in production, staging, and CI/CD environments.
218+
This is the standard standalone way to provide credentials in development,
219+
staging, CI, and direct SDK deployments. It is not governed authority and cannot
220+
satisfy governed runtime claims.
213221

214222
* **For Gemini API Key:**
215223
```bash
@@ -231,11 +239,13 @@ This is the most secure and standard way to provide credentials in production, s
231239
export VERTEX_QUOTA_PROJECT_ID="your-quota-project-id"
232240
```
233241

234-
The library will automatically detect and use these variables.
242+
The library will automatically detect and use these variables only in
243+
standalone mode.
235244

236245
#### Method 3: Application Configuration (Standard Elixir Way)
237246

238-
Configure the library in your project's `config/runtime.exs` file. This is the canonical approach for managing secrets within an Elixir application.
247+
Configure the library in your project's `config/runtime.exs` file for
248+
standalone application defaults. This path is not governed authority.
239249
240250
When both are present for Gemini auth, application config now takes precedence over `GEMINI_API_KEY`.
241251
@@ -264,6 +274,32 @@ When both are present for Gemini auth, application config now takes precedence o
264274
}
265275
```
266276
277+
#### Governed Authority
278+
279+
Governed callers do not pass `api_key:`, `access_token:`, `service_account:`,
280+
`project_id:`, `location:`, app env, env vars, ADC, or native credential files
281+
as authority. They pass a `Gemini.GovernedAuthority` value:
282+
283+
```elixir
284+
authority =
285+
Gemini.GovernedAuthority.new!(
286+
base_url: "https://generativelanguage.googleapis.com/v1beta",
287+
credential_ref: "credential-123",
288+
credential_lease_ref: "lease-123",
289+
target_ref: "target-123",
290+
redaction_ref: "redaction-123",
291+
credential_headers: %{"x-goog-api-key" => "materialized-key"}
292+
)
293+
294+
Gemini.Client.HTTP.post("models/gemini-2.5-flash:generateContent", %{contents: []},
295+
governed_authority: authority
296+
)
297+
```
298+
299+
For Live WebSocket calls, authority can materialize the query credential and
300+
the WebSocket path. GeminiEx redacts `key`, `access_token`, and `token` query
301+
params in path logs and test helpers.
302+
267303
### 6. Live API Authentication (New in v0.9.0)
268304
269305
The Live API uses WebSocket connections for bidirectional streaming. Authentication works similarly to HTTP requests but with some specific considerations:

guides/live_api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ model = Models.resolve(:audio)
283283
# Session is now ready for messages
284284
```
285285

286-
For Gemini sessions, you can also pass `api_key:` directly to `Session.start_link/1`. When `api_key:` is present and `auth:` is omitted, the session uses Gemini auth for that connection only.
286+
For standalone Gemini sessions, you can also pass `api_key:` directly to `Session.start_link/1`. When `api_key:` is present and `auth:` is omitted, the session uses Gemini auth for that connection only. Governed sessions use `Gemini.GovernedAuthority` instead and reject direct per-session credentials.
287287

288288
```elixir
289289
{:ok, session} = Gemini.Live.Session.start_link(

lib/gemini.ex

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ defmodule Gemini do
7878
7979
### 1. Gemini API Key (Simple)
8080
81-
Best for development and simple applications:
81+
Standalone compatibility for development and simple applications:
8282
8383
```elixir
84-
# Environment variable (recommended)
84+
# Environment variable for standalone use
8585
export GEMINI_API_KEY="your_api_key"
8686
8787
# Application config
@@ -91,9 +91,9 @@ defmodule Gemini do
9191
Gemini.generate("Hello", api_key: "specific_key")
9292
```
9393
94-
### 2. Vertex AI (Production)
94+
### 2. Vertex AI (Standalone)
9595
96-
Best for production Google Cloud applications:
96+
Standalone compatibility for Google Cloud applications:
9797
9898
```elixir
9999
# Service Account JSON file
@@ -111,6 +111,13 @@ defmodule Gemini do
111111
}
112112
```
113113
114+
### Governed Authority
115+
116+
Governed execution does not use env, app config, ADC, native Google
117+
credential files, or direct request/session credential overrides as
118+
authority. Pass `Gemini.GovernedAuthority` with the authority-selected base
119+
URL and materialized credential headers or query params instead.
120+
114121
## Error Handling
115122
116123
The client provides detailed error information with recovery suggestions:

lib/gemini/client/http.ex

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,24 @@ defmodule Gemini.Client.HTTP do
2121
alias Gemini.Auth
2222
alias Gemini.Config
2323
alias Gemini.Error
24+
alias Gemini.GovernedAuthority
2425
alias Gemini.RateLimiter
2526
alias Gemini.Telemetry
2627

28+
@governed_forbidden_options [
29+
:auth,
30+
:api_key,
31+
:project_id,
32+
:location,
33+
:access_token,
34+
:service_account,
35+
:service_account_key,
36+
:service_account_data,
37+
:quota_project_id,
38+
:base_url,
39+
:headers
40+
]
41+
2742
@vertex_auth_override_keys [
2843
:project_id,
2944
:location,
@@ -84,19 +99,24 @@ defmodule Gemini.Client.HTTP do
8499
- `:max_concurrency_per_model` - Override concurrency limit
85100
"""
86101
def request(method, path, body, auth_config, opts \\ []) do
87-
Config.validate!()
88-
89102
case auth_config do
90103
nil ->
91104
{:error, Error.config_error("No authentication configured")}
92105

106+
%{type: :governed_authority, credentials: %GovernedAuthority{} = authority} ->
107+
execute_governed_request(method, path, body, authority, opts)
108+
93109
%{type: auth_type, credentials: credentials} ->
94110
execute_authenticated_request(method, path, body, auth_type, credentials, opts)
95111
end
96112
end
97113

98114
# Execute the actual HTTP request with telemetry
99-
defp execute_request(method, url, headers, body, opts) do
115+
defp execute_request(method, url, headers, body, opts, validate_config? \\ true) do
116+
if validate_config? do
117+
Config.validate!()
118+
end
119+
100120
start_time = System.monotonic_time()
101121
metadata = Telemetry.build_request_metadata(url, method, opts)
102122
measurements = %{system_time: System.system_time()}
@@ -152,16 +172,27 @@ defmodule Gemini.Client.HTTP do
152172

153173
@spec resolve_auth_config(keyword()) :: Config.auth_config() | nil
154174
defp resolve_auth_config(opts) when is_list(opts) do
155-
case normalize_auth_strategy(Keyword.get(opts, :auth)) || infer_auth_strategy(opts) do
175+
case Keyword.get(opts, :governed_authority) do
156176
nil ->
157-
Config.auth_config()
177+
case normalize_auth_strategy(Keyword.get(opts, :auth)) || infer_auth_strategy(opts) do
178+
nil ->
179+
Config.auth_config()
158180

159-
strategy ->
160-
credentials =
161-
Config.get_auth_config(strategy)
162-
|> apply_auth_overrides(strategy, opts)
181+
strategy ->
182+
credentials =
183+
Config.get_auth_config(strategy)
184+
|> apply_auth_overrides(strategy, opts)
163185

164-
%{type: strategy, credentials: credentials}
186+
%{type: strategy, credentials: credentials}
187+
end
188+
189+
authority ->
190+
validate_governed_request_opts!(opts)
191+
192+
%{
193+
type: :governed_authority,
194+
credentials: GovernedAuthority.new!(authority)
195+
}
165196
end
166197
end
167198

@@ -198,6 +229,28 @@ defmodule Gemini.Client.HTTP do
198229
defp maybe_put_cred(credentials, _key, ""), do: credentials
199230
defp maybe_put_cred(credentials, key, value), do: Map.put(credentials, key, value)
200231

232+
defp validate_governed_request_opts!(opts) do
233+
case Enum.find(@governed_forbidden_options, &Keyword.has_key?(opts, &1)) do
234+
nil ->
235+
:ok
236+
237+
key ->
238+
raise ArgumentError, "governed authority forbids unmanaged #{key}"
239+
end
240+
end
241+
242+
defp execute_governed_request(method, path, body, authority, opts) do
243+
url = build_governed_url(path, authority)
244+
headers = GovernedAuthority.headers(authority)
245+
model = extract_model_from_path(path)
246+
247+
request_fn = fn ->
248+
execute_request(method, url, headers, body, opts, false)
249+
end
250+
251+
maybe_rate_limited_request(request_fn, model, opts)
252+
end
253+
201254
defp execute_authenticated_request(method, path, body, auth_type, credentials, opts) do
202255
url = build_authenticated_url(auth_type, path, credentials)
203256

@@ -250,6 +303,20 @@ defmodule Gemini.Client.HTTP do
250303
end
251304
end
252305

306+
defp build_governed_url(path, %GovernedAuthority{base_url: base_url}) do
307+
if String.starts_with?(path, "https://") or String.starts_with?(path, "http://") do
308+
raise ArgumentError, "governed authority forbids unmanaged absolute request URLs"
309+
end
310+
311+
normalized_base = String.trim_trailing(base_url, "/")
312+
313+
if String.starts_with?(path, "/") do
314+
normalized_base <> path
315+
else
316+
normalized_base <> "/" <> path
317+
end
318+
end
319+
253320
defp build_absolute_url(base_url, absolute_path) do
254321
uri = URI.parse(base_url)
255322

0 commit comments

Comments
 (0)