Skip to content

Commit fffd246

Browse files
committed
fix(upstreams): distinguish upstream credentials by subject
1 parent c1d8921 commit fffd246

20 files changed

Lines changed: 1247 additions & 44 deletions

File tree

docs-site/src/content/_docs-contract.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Use only these hosts in public examples:
1616
- `https://codex-pooler.example.com`, for deployed product examples
1717
- `https://docs.codex-pooler.com`, for the public docs site canonical URL
1818

19-
Do not use private hostnames, cluster names, pod names, tenant names, real account identifiers, real repository evidence paths, or private service URLs in public docs.
19+
Do not use private hostnames, cluster names, pod names, tenant names, real account identifiers, raw OpenAI user subjects, real repository evidence paths, or private service URLs in public docs.
2020

2121
## Route Vocabulary
2222

@@ -99,6 +99,7 @@ Allowed public claims:
9999
- `upstream`: A Codex account identity or assignment that Codex Pooler can route eligible work to
100100
- `Pool API key`: A bearer credential used by runtime clients for `/backend-api` and `/v1` requests. It represents a Pool, not one upstream account
101101
- `MCP token`: An operator-owned bearer credential used only for `/mcp`. It is separate from Pool API keys and browser sessions
102+
- `subject reference`: A sanitized fingerprint of an upstream OpenAI user subject. Public docs may describe this reference, but must never include raw subject values
102103
- `backend API`: The Codex backend compatibility surface rooted at `/backend-api`, especially `/backend-api/codex/*`
103104
- `/v1`: The narrow OpenAI-compatible SDK surface rooted at `/v1`. It is compatibility over Codex routing, not full OpenAI parity
104105
- `metadata-only logging`: Request, route, accounting, audit, and MCP records may keep identifiers, route names, counts, statuses, timings, model names, safe error codes, and sanitized summaries. They must not store or show raw payloads or credentials
@@ -114,7 +115,7 @@ Use placeholders that are clearly fake and generic:
114115
- Email-like examples: `operator@example.com`
115116
- Model ids: use documented sample ids only when the surrounding page explains that the Pool must expose them
116117

117-
Never include raw tokens, raw prompts, request bodies, response bodies, file bodies, audio bodies, image bodies, cookies, `auth.json`, access tokens, refresh tokens, raw idempotency keys, raw upload URLs, internal evidence snippets, internal logs, private hostnames, real account ids, or real user identifiers.
118+
Never include raw tokens, raw prompts, request bodies, response bodies, file bodies, audio bodies, image bodies, cookies, `auth.json`, access tokens, refresh tokens, raw idempotency keys, raw upload URLs, internal evidence snippets, internal logs, private hostnames, callback URLs, real account ids, raw OpenAI user subjects, or real user identifiers.
118119

119120
If a docs example needs an Authorization header, write `Authorization: Bearer <pool-api-key>` for runtime routes or `Authorization: Bearer <operator-mcp-token>` for `/mcp`.
120121

@@ -142,6 +143,7 @@ Safe fields to mention:
142143
- HTTP method and status class
143144
- Pool label or placeholder
144145
- Upstream label or placeholder
146+
- Sanitized upstream subject reference or fingerprint
145147
- Model name
146148
- Request-log id only when synthetic
147149
- Error code, retry count, duration, token count, and timestamp examples
@@ -151,7 +153,7 @@ Forbidden fields and examples:
151153
- Raw prompts and completions
152154
- Request bodies, response bodies, multipart bodies, websocket frames, file bytes, audio bytes, image bytes, data URLs, and transcripts
153155
- Bearer tokens, Pool API keys, MCP tokens, cookies, access tokens, refresh tokens, `auth.json`, TOTP secrets, SMTP secrets, signing secrets, and raw idempotency keys
154-
- Internal incident procedures, cluster names, pod names, private hostnames, real account identifiers, raw emails, and private IP addresses
156+
- Internal incident procedures, cluster names, pod names, private hostnames, real account identifiers, raw OpenAI user subjects, raw emails, and private IP addresses
155157

156158
## Source Map For Public Route Claims
157159

@@ -167,6 +169,7 @@ Use these tracked sources as the source of truth for public route claims. Do not
167169
| Unsupported `/v1` routes | `lib/codex_pooler_web/controllers/v1/unsupported_routes.ex`, `test/support/compatibility_matrix.ex`, `test/codex_pooler_web/controllers/v1/route_auth_test.exs`, `test/codex_pooler_web/controllers/runtime/compatibility_contract_test.exs` | Explicit unsupported `/v1` routes return deterministic OpenAI-shaped unsupported endpoint errors before gateway dispatch |
168170
| Realtime exclusion | `lib/codex_pooler_web/router.ex`, `test/support/compatibility_matrix.ex`, `test/codex_pooler_web/route_surface_test.exs`, `test/codex_pooler_web/controllers/v1/route_auth_test.exs` | `/v1/realtime` and OpenAI Realtime SDK websocket or session routes are not supported |
169171
| MCP endpoint | `lib/codex_pooler_web/router.ex`, `test/codex_pooler_web/route_surface_test.exs`, `test/codex_pooler_web/controllers/mcp_contract_test.exs`, `test/codex_pooler_web/controllers/mcp_controller_test.exs` | `/mcp` is a root metadata-only, read-only operator endpoint using operator MCP bearer tokens, not Pool API keys or browser sessions |
172+
| Upstream identity linking | `lib/codex_pooler/upstreams/lifecycle/identity_lifecycle.ex`, `lib/codex_pooler/upstreams/token_linking.ex`, `lib/codex_pooler/upstreams/auth/codex_auth.ex`, `lib/codex_pooler/upstreams/auth/codex_auth_json.ex`, `lib/codex_pooler_web/live/admin/read_models/upstream_accounts_read_model.ex`, `lib/codex_pooler_web/live/admin/read_models/upstream_cockpit_read_model.ex`, `test/codex_pooler/upstreams/oauth_browser_linking_test.exs`, `test/codex_pooler/upstreams/oauth_device_linking_test.exs`, `test/codex_pooler/upstreams/oauth_relink_test.exs`, `test/codex_pooler/upstreams_test.exs`, `test/codex_pooler_web/live/admin/pages/upstreams_live_test.exs`, `test/codex_pooler_web/live/admin/pages/upstream_cockpit_live_test.exs` | OAuth links, relinks, and auth.json imports can use an OpenAI user subject, when returned, to separate same-account and same-workspace upstream credentials. Public docs may mention only sanitized subject references or fingerprints, never raw subjects |
170173
| Privacy and redaction | `README.md`, `test/codex_pooler_web/controllers/runtime/compatibility_contract_test.exs`, `test/codex_pooler_web/controllers/mcp_contract_test.exs`, `test/codex_pooler_web/controllers/mcp_controller_test.exs` | Public docs must keep prompts, bodies, bearer tokens, cookies, `auth.json`, upstream secrets, and private identifiers out of examples and evidence |
171174

172175
## Author Checklist
@@ -178,5 +181,5 @@ Before publishing or editing a public page:
178181
3. Include narrow `/v1` compatibility language when mentioning OpenAI SDKs
179182
4. Keep Codex app-server helper routes outside the supported runtime surface
180183
5. Keep `/mcp` token language separate from Pool API key language
181-
6. Remove raw payloads, secrets, private hosts, and internal evidence from examples
184+
6. Remove raw payloads, secrets, callback URLs, raw OpenAI user subjects, private hosts, and internal evidence from examples
182185
7. If the route claim isn't in the tracked sources above, don't publish it yet

docs-site/src/content/docs/operators/upstreams.mdx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Use this page when you need to answer:
1414
5. how recent token usage compares with observed quota windows
1515
6. which account actions are available without exposing raw credentials
1616

17-
The page is metadata-only after linking or import. It shows operator labels, workspace and plan badges, safe status labels, quota-window evidence, token counters, assignment counts, and links to related logs. It does not show prompts, responses, files, websocket frames, bearer tokens, refresh tokens, provider cookies, OAuth provider payloads, or stored Codex `auth.json` contents.
17+
The page is metadata-only after linking or import. It shows operator labels, workspace and plan badges, safe status labels, quota-window evidence, token counters, assignment counts, sanitized subject references when needed, and links to related logs. It does not show prompts, responses, files, websocket frames, bearer tokens, refresh tokens, provider cookies, OAuth provider payloads, raw OpenAI user subjects, or stored Codex `auth.json` contents.
1818

1919
![Upstreams page overview](/operators/upstreams/upstreams-overview.png)
2020

@@ -167,6 +167,8 @@ The upload accepts small auth files only. The current UI states a 64 KB limit.
167167

168168
After import, Codex Pooler becomes the refresh-token authority for that account lineage. Do not keep using the same `auth.json` from another Codex install, machine, or automation. Provider refresh-token rotation can invalidate one copy and move the account into `reauth_required`.
169169

170+
Export `auth.json` from the intended signed-in account, then import each user lineage separately. When the auth file returns an OpenAI user subject, Codex Pooler uses it with the account and workspace identifiers to separate multiple credentials from the same account and workspace. The admin UI may show a sanitized subject reference so operators can distinguish those rows, but it never renders the raw subject.
171+
170172
The dialog footer keeps docs on the left and the submit actions on the right. `Cancel` closes without importing. `Import auth.json` stores the credential material through encrypted upstream secret storage.
171173

172174
## OpenAI OAuth Upstream Linking
@@ -175,28 +177,36 @@ OpenAI OAuth upstream linking is an authenticated admin workflow. Use
175177
`OAuth` on `/admin/upstreams` to link a new OpenAI upstream account to a selected Pool. Use
176178
`/admin/upstreams/:id` to relink or reconnect the exact upstream identity shown
177179
in the cockpit. Relink checks the returned account and workspace claims against
178-
the target identity before replacing encrypted credential material.
180+
the target identity before replacing encrypted credential material. When OpenAI
181+
returns a user subject, Codex Pooler also uses that subject to keep same-account,
182+
same-workspace upstream credentials distinct.
183+
184+
Browser account choice is controlled by the active OpenAI or Google browser
185+
session. Codex Pooler can start the authorization flow, but it cannot force the
186+
provider account chooser by itself. Sign in to the intended provider account, or
187+
use a separate browser profile, before completing the link or relink.
179188

180189
The browser manual callback workflow is:
181190

182191
1. open the OAuth link or relink dialog
183192
2. choose `Browser`
184193
3. open the generated OpenAI authorization URL
185194
4. complete OpenAI authorization in the browser
186-
5. paste the resulting local callback URL back into the admin dialog
195+
5. paste the resulting returned browser URL back into the admin dialog
187196
6. submit the callback form and wait for the success or safe error state
188197
7. use `Close` after the dialog reports that the account was linked or relinked
189198

190-
The local callback URL is consumed only by the already-authenticated admin page,
191-
and there is no hosted OAuth callback route, no public `/auth/callback`, and no
199+
The returned browser URL is consumed only by the already-authenticated admin page,
200+
and there is no hosted OAuth callback route, no public callback endpoint, and no
192201
`/api/admin/*` or dashboard JSON API for this workflow.
193202

194203
Use the device-code fallback when browser authorization is not practical. The
195204
dialog shows the user code and verification URL while the flow is pending, and
196205
the page polls only while the operator keeps the dialog open. Device-code
197-
authorization must be enabled for Codex on the OpenAI account or workspace. If
198-
device authorization is unavailable or denied, use the browser workflow or ask
199-
the workspace admin to enable Codex device-code login.
206+
authorization must be enabled for Codex on the OpenAI account or workspace. It
207+
may be unavailable or denied by account or workspace policy. If device
208+
authorization is unavailable or denied, use the browser workflow or ask the
209+
workspace admin to enable Codex device-code login.
200210

201211
Immediately after a successful link, the new account is not guaranteed to route
202212
traffic. Codex Pooler creates the initial Pool assignment and enqueues account
@@ -206,7 +216,7 @@ before any user request is routed, and runtime routing excludes it until token
206216
refresh succeeds or the credentials are relinked.
207217

208218
Operators should never paste callback URLs, authorization codes, tokens, cookies,
209-
raw auth.json, OpenAI provider payloads, or local credential files into docs,
219+
returned browser URLs, raw auth.json, OpenAI provider payloads, raw user subjects, or local credential files into docs,
210220
tickets, logs, or evidence. The admin UI may show the one-time authorization URL
211221
or device user code during a pending flow, but persisted flow summaries, audit
212222
metadata, request logs, tests, and docs stay metadata-only.
@@ -227,7 +237,7 @@ Safe OAuth troubleshooting codes:
227237
</tr>
228238
<tr>
229239
<td><code>invalid_callback_origin</code></td>
230-
<td>Use only the <code>http://localhost:1455/auth/callback</code> URL produced by the OpenAI flow.</td>
240+
<td>Use only the local browser URL produced by the OpenAI flow.</td>
231241
</tr>
232242
<tr>
233243
<td><code>missing_state</code></td>
@@ -267,7 +277,7 @@ Safe OAuth troubleshooting codes:
267277
</tr>
268278
<tr>
269279
<td><code>identity_mismatch</code></td>
270-
<td>Relink with the same OpenAI account and workspace shown in the cockpit.</td>
280+
<td>Relink with the same OpenAI account, workspace, and sanitized subject reference shown in the cockpit. If a different person in the same workspace should be added, link or import that user lineage as a separate upstream identity.</td>
271281
</tr>
272282
<tr>
273283
<td><code>identity_conflict</code></td>

lib/codex_pooler/upstreams/auth/codex_auth_json.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ defmodule CodexPooler.Upstreams.Auth.CodexAuthJson do
1919
@type import_attrs :: %{
2020
required(:account_identifier) => String.t(),
2121
required(:account_email) => String.t() | nil,
22+
required(:chatgpt_user_id) => String.t() | nil,
2223
required(:account_label) => String.t(),
2324
required(:workspace_id) => String.t() | nil,
2425
required(:workspace_label) => String.t() | nil,
@@ -47,6 +48,7 @@ defmodule CodexPooler.Upstreams.Auth.CodexAuthJson do
4748
%{
4849
account_identifier: account_identifier,
4950
account_email: account_email(id_claims),
51+
chatgpt_user_id: auth_claim(id_claims, "chatgpt_user_id"),
5052
account_label: account_label(id_claims),
5153
workspace_id: workspace_id(id_claims),
5254
workspace_label: workspace_label(id_claims),

lib/codex_pooler/upstreams/import.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ defmodule CodexPooler.Upstreams.Import do
7676
chatgpt_account_id: chatgpt_account_id,
7777
account_identifier: chatgpt_account_id || account_email,
7878
account_email: account_email,
79+
chatgpt_user_id: attrs |> import_value(:chatgpt_user_id) |> present_string(),
7980
account_label: attrs |> import_value(:account_label) |> present_string(),
8081
workspace_id: attrs |> import_value(:workspace_id) |> present_string(),
8182
workspace_label: attrs |> import_value(:workspace_label) |> present_string(),

lib/codex_pooler/upstreams/lifecycle/identity_lifecycle.ex

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ defmodule CodexPooler.Upstreams.Lifecycle.IdentityLifecycle do
5858
attrs = atomize_attrs(attrs)
5959
chatgpt_account_id = attrs |> Map.get(:chatgpt_account_id) |> present_string()
6060
workspace_id = attrs |> Map.get(:workspace_id) |> present_string()
61+
chatgpt_user_id = attrs |> Map.get(:chatgpt_user_id) |> present_string()
6162

6263
case chatgpt_account_id do
6364
nil -> select_email_fallback_identity(attrs, workspace_id)
64-
account_id -> select_account_slot_identity(account_id, workspace_id)
65+
account_id -> select_account_slot_identity(account_id, workspace_id, chatgpt_user_id, attrs)
6566
end
6667
end
6768

@@ -181,25 +182,57 @@ defmodule CodexPooler.Upstreams.Lifecycle.IdentityLifecycle do
181182
defp concrete_workspace?(%UpstreamIdentity{} = identity),
182183
do: not is_nil(present_string(identity.workspace_id))
183184

184-
defp select_account_slot_identity(account_id, workspace_id) do
185+
defp select_account_slot_identity(account_id, workspace_id, nil, attrs) do
185186
identities = identities_for_account(account_id)
186187

187-
case identity_for_workspace(identities, workspace_id) do
188+
case subjectless_identity_for_workspace(identities, workspace_id) do
188189
%UpstreamIdentity{} = identity ->
189190
{:ok, identity}
190191

191192
nil ->
192-
legacy_identity = identity_for_workspace(identities, nil)
193-
concrete_identities = Enum.reject(identities, &is_nil(&1.workspace_id))
193+
select_subjectless_account_fallback(identities, workspace_id, attrs)
194+
end
195+
end
196+
197+
defp select_account_slot_identity(account_id, workspace_id, subject, _attrs) do
198+
identities = identities_for_account(account_id)
199+
200+
with nil <- identity_for_workspace_and_subject(identities, workspace_id, subject),
201+
nil <- subjectless_identity_for_workspace(identities, workspace_id) do
202+
{:ok, claimable_subjectless_legacy_identity(identities, workspace_id)}
203+
else
204+
%UpstreamIdentity{} = identity -> {:ok, identity}
205+
end
206+
end
207+
208+
defp select_subjectless_account_fallback(identities, workspace_id, attrs) do
209+
case claimable_subjectless_legacy_identity(identities, workspace_id) do
210+
%UpstreamIdentity{} = identity ->
211+
{:ok, identity}
212+
213+
nil ->
214+
case subject_bound_identity_for_workspace(identities, workspace_id) do
215+
%UpstreamIdentity{} = conflict -> {:error, identity_conflict(attrs, conflict)}
216+
nil -> {:ok, nil}
217+
end
218+
end
219+
end
194220

195-
if (is_binary(workspace_id) and legacy_identity) && concrete_identities == [] do
196-
{:ok, legacy_identity}
197-
else
198-
{:ok, nil}
221+
defp claimable_subjectless_legacy_identity(identities, workspace_id)
222+
when is_binary(workspace_id) do
223+
case subjectless_identity_for_workspace(identities, nil) do
224+
%UpstreamIdentity{} = legacy_identity ->
225+
if subjectless_concrete_identities(identities) == [] do
226+
legacy_identity
199227
end
228+
229+
nil ->
230+
nil
200231
end
201232
end
202233

234+
defp claimable_subjectless_legacy_identity(_identities, _workspace_id), do: nil
235+
203236
defp select_email_fallback_identity(attrs, workspace_id) do
204237
account_email = attrs |> Map.get(:account_email) |> normalize_email()
205238

@@ -331,8 +364,35 @@ defmodule CodexPooler.Upstreams.Lifecycle.IdentityLifecycle do
331364
defp where_workspace(query, workspace_id),
332365
do: where(query, [identity], identity.workspace_id == ^workspace_id)
333366

334-
defp identity_for_workspace(identities, workspace_id) do
335-
Enum.find(identities, &(&1.workspace_id == workspace_id))
367+
defp subjectless?(%UpstreamIdentity{} = identity) do
368+
is_nil(present_string(identity.chatgpt_user_id))
369+
end
370+
371+
defp subject_bound?(%UpstreamIdentity{} = identity), do: not subjectless?(identity)
372+
373+
defp identity_for_workspace_and_subject(identities, workspace_id, subject) do
374+
Enum.find(identities, fn identity ->
375+
identity.workspace_id == workspace_id and
376+
present_string(identity.chatgpt_user_id) == subject
377+
end)
378+
end
379+
380+
defp subjectless_identity_for_workspace(identities, workspace_id) do
381+
Enum.find(identities, fn identity ->
382+
identity.workspace_id == workspace_id and subjectless?(identity)
383+
end)
384+
end
385+
386+
defp subjectless_concrete_identities(identities) do
387+
Enum.filter(identities, fn identity ->
388+
subjectless?(identity) and not is_nil(present_string(identity.workspace_id))
389+
end)
390+
end
391+
392+
defp subject_bound_identity_for_workspace(identities, workspace_id) do
393+
Enum.find(identities, fn identity ->
394+
identity.workspace_id == workspace_id and subject_bound?(identity)
395+
end)
336396
end
337397

338398
defp sibling_concrete_slot?(%UpstreamIdentity{chatgpt_account_id: nil}), do: false

lib/codex_pooler/upstreams/oauth_flows/completion.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ defmodule CodexPooler.Upstreams.OAuthFlows.Completion do
249249
defp oauth_link_attrs(tokens, token_info, config) do
250250
%{
251251
chatgpt_account_id: token_info.chatgpt_account_id,
252+
chatgpt_user_id: token_info.chatgpt_user_id,
252253
account_email: token_info.email,
253254
account_label: token_info.email || token_info.chatgpt_account_id || "Codex account",
254255
workspace_id: token_info.workspace_id,

0 commit comments

Comments
 (0)