Skip to content

Multi-tenant mode: per-tenant account pools + path-prefix tenant keys - #36

Merged
lawrencecchen merged 9 commits into
mainfrom
feat-multi-tenant
Jul 30, 2026
Merged

lawrencecchen merged 9 commits into
mainfrom
feat-multi-tenant

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

One hosted subrouter can now serve many isolated users, each with their own Codex/Claude account pool. Built for cmux Cloud VMs: each user's VM gets a tenant-scoped base URL and routes only through that user's accounts.

Tenant state lives under the server state dir: tenants/<id>/ mirrors the single-tenant layout (codex/accounts/*.json, codex/claude/, sessions.json), and tenants.json maps tenant id to name, createdAt, and key hashes. Keys are srt_<32 hex>, shown once at creation; only SHA-256 hashes (plus a short display prefix for listing/revoking) are stored.

Data-plane auth is a URL path prefix, since agent CLIs can only override base URLs: https://host/t/<key>/v1/..., /t/<key>/backend-api/..., and /t/<key>/ for Claude. The key is also accepted as Authorization Bearer or x-api-key, which is where Claude Code puts ANTHROPIC_AUTH_TOKEN. Unknown or revoked keys get 401. Keyless requests keep today's single-tenant behavior; header-shaped srt_ tokens only get strict 401 semantics once tenants exist or serve --multi-tenant is set.

Isolation comes from instantiating the existing single-tenant proxy.Server per tenant (lazy, cached): each tenant gets its own account stores, session store, scheduler ref, active-session tracking, read cache, and transcript recorder (under <transcripts>/tenants/<id>), rather than threading tenant ids through the proxy internals. The global loopback reload-accounts also reloads instantiated tenants so uploads hot-reload.

Admin plane: the existing global admin token gates GET/POST /_subrouter/tenants, POST /_subrouter/tenants/<id>/keys, and DELETE /_subrouter/tenants/<id>/keys/<prefix>. Tenant-scoped reads (/t/<key>/_subrouter/{accounts,account-status,usage-status,sessions} plus whoami) are authorized by key possession, so existing sr server client code works by pointing at the tenant base URL. Other _subrouter endpoints 404 on tenant URLs.

CLI: sr tenant create/list, sr tenant key create/revoke talk to a named or default server's admin endpoints and fall back to the local state dir when run on the server host. sr server add --tenant-key srt_... stores the key on a server entry; the Codex config writer and Claude proxy env writer then emit /t/<key> base URLs (Claude env also sets the key as ANTHROPIC_AUTH_TOKEN), and account/Claude-profile uploads land in tenants/<id>/ on the server, with the id resolved via the tenant whoami endpoint.

Deviations from the sketch: tenants.json stores key objects (hash, display prefix, createdAt) instead of bare hash strings, because revoke-by-prefix and listing need the prefix; key revocation is DELETE /_subrouter/tenants/<id>/keys/<prefix> (no tenant delete endpoint yet); --multi-tenant only controls strict handling of header-borne keys, since path routing and admin CRUD are inert until a tenant exists anyway.

Tests: httptest coverage for two-tenant pool isolation, bad-key 401 (path and header), key rotation plus revocation through the admin API, sticky sessions scoped per tenant, legacy fallthrough, tenant-scoped control endpoints, and admin auth; registry unit tests; CLI tests for tenant-key storage/validation, tenant-scoped base URLs, Claude env, and local tenant CRUD. go build ./... && go vet ./... && go test ./... green.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Adds multi-tenant mode so one Subrouter serves many isolated tenants, each with its own accounts, sessions, scheduler, and transcripts. Tenant keys work via path or headers, are scrubbed before proxying, and invalid keys return 401 (enforced early with --multi-tenant).

  • New Features

    • Per-tenant pools under <state-dir>/tenants/<id>/ with tenants.json holding SHA-256 key hashes and display prefixes; exact-prefix or full-hash revocation; interprocess .lock and deep-copied cache to avoid lost or stale writes.
    • Routing via /t/<key>/... or Authorization: Bearer/X-Api-Key; unknown/revoked keys return 401. Keyless requests keep legacy behavior; --multi-tenant rejects header-borne srt_ keys even before the first tenant exists.
    • Isolation by instantiating a separate proxy.Server per tenant (accounts, sessions, scheduler, active sessions, read cache, transcripts under <transcripts>/tenants/<id>). Global loopback /_subrouter/reload-accounts also reloads tenant servers. Tenant keys are stripped from Authorization/X-Api-Key before dispatch so they never reach upstreams.
    • Admin API: GET/POST /_subrouter/tenants, POST /_subrouter/tenants/<id>/keys, DELETE /_subrouter/tenants/<id>/keys/<prefix> (admin token required). Tenant-scoped reads (/_subrouter/{accounts,account-status,usage-status,sessions}) plus /_subrouter/whoami are authorized by the tenant key.
    • CLI + URLs: sr tenant create/list, sr tenant key create/revoke; sr server add --tenant-key srt_... stores the key so Codex/Claude use /t/<key> base URLs automatically. Claude sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN to the tenant, and clears a stale srt_ token when switching back to a non-tenant server. Account/Claude uploads land in the tenant dir, resolved via whoami. Control and data URLs are normalized to the server root by stripping any /v1 or /backend-api suffix.
  • Migration

    • Create a tenant: sr tenant create <name> (store the printed key).
    • Add a server entry with the tenant key: sr server add <name> --url <url> --tenant-key srt_....
    • Point clients at the tenant base URL:
      • Codex: https://host/t/<key>/v1
      • Claude: ANTHROPIC_BASE_URL=https://host/t/<key>, ANTHROPIC_AUTH_TOKEN=<key>
    • Optional: run sr serve --multi-tenant to reject unknown srt_ header keys even before tenants exist.

Written for commit f7cd575. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a “multi-tenant mode” with tenant-scoped server access, isolated state directories, and tenant admin/key management.
    • Introduced sr tenant / sr tenants CLI commands and enhanced server setup with --tenant-key.
    • Updated Codex and Claude proxy handling (including base URL selection and tenant-scoped Claude uploads).
  • Bug Fixes

    • Unknown or revoked tenant keys now return 401 in multi-tenant mode, while tenantless traffic preserves prior single-tenant behavior.
    • Improved tenant-prefixed routing for control URLs across common base URL shapes.
  • Tests

    • Expanded CLI and multi-tenant/tenant-registry test coverage.

lawrencecchen and others added 4 commits July 1, 2026 22:34
Tenants live in <state-dir>/tenants.json with per-tenant state dirs under
<state-dir>/tenants/<id>/ mirroring the single-tenant layout. Keys are
srt_<32 hex>; only SHA-256 hashes plus a display prefix are stored, so a
key is shown once at creation. Reads are cached on file modtime+size so
per-request resolution is one stat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MultiTenant wraps the legacy handler. A tenant key arrives as a
/t/<key>/... path prefix (agent CLIs can only override base URLs) or as
an Authorization Bearer / x-api-key value; the wrapper resolves it,
strips the prefix, and dispatches to a lazily built per-tenant Server
whose codex/claude stores, session store, scheduler, active sessions,
read cache, and transcripts all live under the tenant dir, so isolation
falls out of instantiating the existing single-tenant machinery per
tenant. Unknown or revoked keys get 401; keyless requests keep today's
behavior. Global admin gains /_subrouter/tenants CRUD, and tenant keys
authorize a small read-only /_subrouter allowlist plus whoami on the
tenant base URL. A global loopback reload-accounts also reloads
instantiated tenants so uploads hot-reload.

Tests cover two-tenant pool isolation over httptest, bad-key 401s,
key rotation and revocation through the admin API, sticky sessions
scoped per tenant, legacy fallthrough, and admin auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serve wires the MultiTenant wrapper over the state dir registry and adds
--multi-tenant to reject unknown srt_ header keys before the first
tenant exists. sr tenant create/list/key create/key revoke talk to a
named or default server's admin endpoints and fall back to the local
state dir on the server host. sr server add --tenant-key stores the key
on a server entry (preserved across metadata updates like the admin
token); with a key set, codex/claude base URLs gain the /t/<key> prefix,
Claude proxy env uses the key as ANTHROPIC_AUTH_TOKEN, _subrouter reads
go through the tenant-scoped endpoints, and account/claude uploads land
in tenants/<id>/ on the server, resolved via the tenant whoami endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds multi-tenant support through a hashed-key tenant registry, tenant-aware HTTP routing and admin APIs, sr tenant commands, tenant-scoped server URLs and upload paths, multi-tenant server wiring, and README documentation.

Changes

Multi-tenant mode implementation

Layer / File(s) Summary
Tenant registry and locking
internal/tenant/tenant.go, internal/tenant/lock_*.go, internal/tenant/tenant_test.go
Adds persisted tenant and hashed-key lifecycle management, tenant directory provisioning, cached registry access, atomic saves, cross-process locking, and tests.
MultiTenant routing and admin API
internal/proxy/multitenant.go, internal/proxy/multitenant_test.go
Routes path- or header-authenticated requests to isolated tenant handlers, restricts tenant control endpoints, implements admin CRUD, and tests isolation, revocation, sticky sessions, authorization, and credential scrubbing.
Server startup and command routing
cmd/subrouter/main.go, cmd/subrouter/main_test.go
Adds --multi-tenant, wraps the HTTP handler, routes tenant commands directly, updates usage text, and adds startup/team-mode regression tests.
Tenant CLI operations
cmd/subrouter/sr_tenant.go, cmd/subrouter/sr.go, cmd/subrouter/sr_tenant_test.go
Implements local and remote tenant creation, listing, key creation/revocation, admin requests, dispatch, and validation tests.
Tenant-scoped client URLs and uploads
cmd/subrouter/codex.go, cmd/subrouter/sr_server.go, cmd/subrouter/sr_claude_upload.go, cmd/subrouter/*_test.go
Adds tenant-key server configuration, tenant-prefixed control/data URLs, tenant state resolution, scoped account and Claude uploads, dynamic ownership handling, and Codex authentication fallback behavior.
Multi-tenant mode documentation
README.md
Documents tenant state, keys, authentication, scoped behavior, CLI association, and administrative endpoints.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MultiTenant
  participant Registry
  participant TenantServer
  Client->>MultiTenant: Send tenant key in path or header
  MultiTenant->>Registry: Resolve key
  Registry-->>MultiTenant: Tenant or unknown
  MultiTenant->>TenantServer: Forward to tenant-scoped handler
  TenantServer-->>Client: Return response
Loading
sequenceDiagram
  participant User
  participant srRunner
  participant AdminAPI
  participant Registry
  User->>srRunner: Run tenant command
  alt remote mode
    srRunner->>AdminAPI: Execute tenant operation
    AdminAPI-->>srRunner: Return result
  else local mode
    srRunner->>Registry: Execute tenant operation
    Registry-->>srRunner: Return result
  end
  srRunner-->>User: Print result or one-time key
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: multi-tenant support with per-tenant pools and path-prefix tenant key routing.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-multi-tenant

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a48fc3410

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cmd/subrouter/sr_server.go Outdated
Comment on lines +32 to +34
base := strings.TrimRight(server.URL, "/")
if key := strings.TrimSpace(server.TenantKey); key != "" {
return base + "/t/" + key

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip API suffix before tenant control URLs

When a tenant-scoped server is stored with a /v1 suffix (which codexBaseURLForServer accepts and the new test covers for data-plane URLs), this builds control URLs like http://host:31415/v1/t/<key>/_subrouter/.... The multi-tenant router only recognizes tenant prefixes at /t/<key>/..., so sr server status and upload flows that call serverTenantID/whoami for that entry get routed to the upstream or fail instead of reaching the tenant control handler. Normalize server.URL with the same root-stripping logic before appending /t/<key>.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6718aa5: serverControlBaseURL (and tenantAdminRequest) now normalize the stored URL through codexProxyRootURL before appending /t/, so /v1- or /backend-api-suffixed entries reach control endpoints at the server root. Covered in TestTenantScopedBaseURLs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
internal/tenant/tenant_test.go (1)

38-44: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add a regression test for short revoke refs.

The current revoke test only covers the exact stored prefix. Add a case proving RevokeKey(created.ID, "srt_") does not revoke all keys.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tenant/tenant_test.go` around lines 38 - 44, The existing revoke
coverage only verifies the exact stored prefix, so extend the tenant test to
cover short revoke refs as a regression case. In the same test around RevokeKey
and Resolve, add a check that calling registry.RevokeKey(created.ID, "srt_")
does not revoke all keys and that the key still resolves afterward. Use the
existing test flow and registry.RevokeKey/registry.Resolve symbols to keep the
assertion close to the current revoke behavior.
internal/proxy/multitenant_test.go (1)

25-29: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert header tenant keys are not forwarded upstream.

The X-Api-Key routing test only validates the selected Authorization header. Capture upstream X-Api-Key too and assert it is empty or provider-replaced, so an srt_... leak cannot pass unnoticed.

Also applies to: 118-123

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/proxy/multitenant_test.go` around lines 25 - 29, The routing test
only checks the upstream Authorization header, so it can miss leaking tenant
header keys. In the multitenant test setup around the upstream handler, capture
the incoming X-Api-Key alongside the existing Authorization value, and assert it
is empty or has been replaced by the provider before the request reaches
upstream. Apply the same update to the related test block referenced in the diff
so any srt_... forwarding issue is caught by both cases.
cmd/subrouter/sr_tenant_test.go (1)

115-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the remote admin-API branch too.

Local create/list/revoke is tested, but tenantAdminRequest/resolveRemoteTenant (the --server path building /_subrouter/tenants... requests) has no test coverage here.

An httptest.Server stub returning the admin JSON shapes (tenantAdminView, {tenant, key}) would exercise tenantCreate/tenantList/tenantKeyCreate/tenantKeyRevoke with remote=true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/subrouter/sr_tenant_test.go` around lines 115 - 153, Add test coverage
for the remote admin-API path in sr_tenant_test by exercising the --server flow
instead of only the local store path. Create an httptest.Server stub that serves
the admin JSON responses used by tenantAdminRequest and resolveRemoteTenant,
then drive srRunner through tenantCreate, tenantList, tenantKeyCreate, and
tenantKeyRevoke with remote=true so the /_subrouter/tenants requests are
covered. Use the existing tenantAdminView and key response shapes in the test to
verify the remote branch behaves the same as the local one.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/subrouter/sr_server.go`:
- Around line 27-38: serverControlBaseURL currently only strips trailing
slashes, which can leave a stored base path like /v1 in place and build
incorrect tenant-scoped control URLs. Update serverControlBaseURL to use the
same normalization as serverProxyRootURL by delegating to
serverProxyRootURL(server) or codexProxyRootURL(server.URL), then append
/t/<key> only after that normalized root so whoami, accounts, and status still
resolve correctly for tenant entries.

In `@cmd/subrouter/sr_tenant.go`:
- Around line 241-276: In tenantKeyRevoke, both the remote and local revoke
flows currently treat a zero-match result as success; update the logic after
tenantAdminRequest and registry.RevokeKey so that a revoked count of 0 returns
an error instead of printing a success message. Use the existing identifiers
tenantKeyRevoke, tenantAdminRequest, registry.RevokeKey, and the
revoked/result.Revoked value to locate the two branches, and apply the same
zero-matches check in both paths.

In `@internal/proxy/multitenant.go`:
- Around line 139-143: The cloned request in multitenant dispatch still carries
tenant credentials, so scrub them before calling handler.ServeHTTP. In
internal/proxy/multitenant.go, update the flow around r.Clone, cloneURL, and the
inner proxy handoff to remove any tenant-formatted values from Authorization and
X-Api-Key after tenant resolution, using a helper like
stripTenantCredentialHeaders so downstream proxy/logging paths never see the
srt_... credential.

In `@internal/tenant/tenant.go`:
- Around line 194-223: `Registry` operations are only protected by `r.mu`, which
does not prevent concurrent processes from clobbering the same `tenants.json`
state. Add an interprocess file lock around the full load/mutate/save flow in
`Registry.Create`, `Registry.CreateKey`, and `Registry.RevokeKey`, so the
critical sections stay serialized across processes. Keep the lock held from
`r.load()` through `r.save()` (including tenant/key validation and updates) to
avoid stale reads and lost writes.
- Line 279: The revocation match in tenant key lookup is too loose because the
`strings.HasPrefix(key.Prefix, keyRef)` check in the tenant key matching logic
can revoke unrelated keys for short refs. Update the matching condition in the
tenant key revocation path to require an exact match against the stored display
prefix, while still allowing a match on the full plaintext key hash; use the
relevant tenant key matching/revocation helper around the `key.Hash` and
`key.Prefix` comparison to locate the change.

---

Nitpick comments:
In `@cmd/subrouter/sr_tenant_test.go`:
- Around line 115-153: Add test coverage for the remote admin-API path in
sr_tenant_test by exercising the --server flow instead of only the local store
path. Create an httptest.Server stub that serves the admin JSON responses used
by tenantAdminRequest and resolveRemoteTenant, then drive srRunner through
tenantCreate, tenantList, tenantKeyCreate, and tenantKeyRevoke with remote=true
so the /_subrouter/tenants requests are covered. Use the existing
tenantAdminView and key response shapes in the test to verify the remote branch
behaves the same as the local one.

In `@internal/proxy/multitenant_test.go`:
- Around line 25-29: The routing test only checks the upstream Authorization
header, so it can miss leaking tenant header keys. In the multitenant test setup
around the upstream handler, capture the incoming X-Api-Key alongside the
existing Authorization value, and assert it is empty or has been replaced by the
provider before the request reaches upstream. Apply the same update to the
related test block referenced in the diff so any srt_... forwarding issue is
caught by both cases.

In `@internal/tenant/tenant_test.go`:
- Around line 38-44: The existing revoke coverage only verifies the exact stored
prefix, so extend the tenant test to cover short revoke refs as a regression
case. In the same test around RevokeKey and Resolve, add a check that calling
registry.RevokeKey(created.ID, "srt_") does not revoke all keys and that the key
still resolves afterward. Use the existing test flow and
registry.RevokeKey/registry.Resolve symbols to keep the assertion close to the
current revoke behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aafd8a1b-e788-4694-b807-937708014922

📥 Commits

Reviewing files that changed from the base of the PR and between 0cde97f and 7a48fc3.

📒 Files selected for processing (14)
  • README.md
  • cmd/subrouter/codex.go
  • cmd/subrouter/main.go
  • cmd/subrouter/main_test.go
  • cmd/subrouter/sr.go
  • cmd/subrouter/sr_claude_upload.go
  • cmd/subrouter/sr_claude_upload_test.go
  • cmd/subrouter/sr_server.go
  • cmd/subrouter/sr_tenant.go
  • cmd/subrouter/sr_tenant_test.go
  • internal/proxy/multitenant.go
  • internal/proxy/multitenant_test.go
  • internal/tenant/tenant.go
  • internal/tenant/tenant_test.go

Comment thread cmd/subrouter/sr_server.go
Comment thread cmd/subrouter/sr_tenant.go
Comment thread internal/proxy/multitenant.go
Comment thread internal/tenant/tenant.go
Comment thread internal/tenant/tenant.go Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 issues found across 14 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread internal/proxy/multitenant.go
Comment thread internal/tenant/tenant.go
Comment thread cmd/subrouter/sr_tenant.go Outdated
Comment thread cmd/subrouter/sr_claude_upload.go Outdated
Comment thread cmd/subrouter/sr_server.go Outdated
Comment thread internal/tenant/tenant.go Outdated
Comment thread internal/tenant/tenant.go Outdated
Comment thread internal/proxy/multitenant.go
Comment thread cmd/subrouter/sr_tenant.go Outdated
lawrencecchen and others added 4 commits July 2, 2026 17:12
Review fixes: RevokeKey now matches the stored display prefix exactly
(or the full plaintext key's hash), so a loose ref like srt_ cannot wipe
every key on a tenant. Create/CreateKey/RevokeKey take an exclusive
flock on tenants.json.lock (same pattern as accounts.lockStoredAccount)
and re-read the file under the lock, so the server's admin API and a
local sr tenant run on the same host cannot lose each other's writes or
resurrect a revoked key. load/save hand out deep copies of the cached
registry so a failed save cannot leave mutated key state in memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review fixes: after tenant resolution the wrapper strips key-shaped
credentials from Authorization/X-Api-Key on the scoped request.
setAccountAuthHeaders already overwrites Authorization, but Codex-routed
forwarding leaves X-Api-Key untouched, so a tenant key parked there
would have been forwarded upstream; regression test asserts the upstream
never sees the key. The tenant-pool side of a global reload-accounts
POST is now gated on the same loopback check as the endpoint itself, so
a rejected non-loopback caller triggers no reload work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review fix (Codex P2, CodeRabbit, cubic): serverControlBaseURL and
tenantAdminRequest now strip a stored /v1 or /backend-api suffix via
codexProxyRootURL before appending /t/<key> or /_subrouter/..., so a
server entry saved with a Codex-style URL still reaches whoami, status,
and tenant admin endpoints at the server root instead of building
/v1/t/<key>/_subrouter/... paths the router never matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review fixes (cubic): switching a Claude profile from a tenant-scoped
server back to a plain one now replaces a leftover srt_ token in
ANTHROPIC_AUTH_TOKEN with the dummy value instead of sending the old
tenant key to the new server; unrelated custom tokens stay untouched.
Tenant creation output shows the plaintext key on one line only, with a
placeholder in the base-URL hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 515d9032b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


func (r srRunner) fetchServerUsageStatuses(ctx context.Context, server srServerConfig) ([]remoteServerUsageStatus, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/_subrouter/usage-status", nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverControlBaseURL(server)+"/_subrouter/usage-status", nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep tenant resets out of the global pool

When the selected server has a TenantKey, pickSmartResetCandidateRemote calls this tenant-scoped usage fetch and may choose an account from that tenant, but the actual reset path in resetRemoteSweep still POSTs to server.URL + "/_subrouter/rate-limit-reset" on the global handler. In tenant-scoped defaults, sr reset or sr reset <email> can therefore fail against an empty global pool or, worse, redeem the reset credit for a same-email account in the global pool rather than the tenant; route or disable reset consistently for tenant-scoped servers.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/tenant/lock_unix.go">

<violation number="1" location="internal/tenant/lock_unix.go:26">
P1: A blocked flock wait here can hold the in-process registry mutex and pause tenant auth/resolve work until another process releases the lock. Using a non-blocking lock attempt (and returning/retrying) avoids freezing request-path lookups during cross-process contention.</violation>
</file>

<file name="internal/tenant/lock_windows.go">

<violation number="1" location="internal/tenant/lock_windows.go:15">
P1: lockRegistry on Windows opens the .lock file but never acquires an exclusive lock. Without syscall.LockFileEx (the Windows equivalent of flock(LOCK_EX)), concurrent writes from the admin API and CLI can corrupt tenants.json. Add syscall.LockFileEx(syscall.Handle(file.Fd()), syscall.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &syscall.Overlapped{}) after OpenFile, and syscall.UnlockFileEx in Close before file.Close.</violation>
</file>

<file name="cmd/subrouter/sr_server.go">

<violation number="1" location="cmd/subrouter/sr_server.go:34">
P2: The `serverControlBaseURL` helper now correctly routes control requests through the tenant path prefix, but the rate-limit reset flow (`resetRemoteSweep` / `pickSmartResetCandidateRemote`) appears to POST directly to `server.URL + "/_subrouter/rate-limit-reset"` without going through `serverControlBaseURL`. For tenant-scoped server entries this would hit the global pool rather than the tenant pool, potentially failing against an empty global pool or redeeming the reset credit for a same-email account in the wrong pool. The reset path should use `serverControlBaseURL(server)` to ensure it's routed to the correct tenant scope.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

if err != nil {
return nil, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A blocked flock wait here can hold the in-process registry mutex and pause tenant auth/resolve work until another process releases the lock. Using a non-blocking lock attempt (and returning/retrying) avoids freezing request-path lookups during cross-process contention.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/tenant/lock_unix.go, line 26:

<comment>A blocked flock wait here can hold the in-process registry mutex and pause tenant auth/resolve work until another process releases the lock. Using a non-blocking lock attempt (and returning/retrying) avoids freezing request-path lookups during cross-process contention.</comment>

<file context>
@@ -0,0 +1,40 @@
+	if err != nil {
+		return nil, err
+	}
+	if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
+		_ = file.Close()
+		return nil, err
</file context>


// lockRegistry mirrors the unix flock variant; like
// accounts.lockStoredAccount, Windows gets a best-effort lock file only.
func (r *Registry) lockRegistry() (*registryLock, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: lockRegistry on Windows opens the .lock file but never acquires an exclusive lock. Without syscall.LockFileEx (the Windows equivalent of flock(LOCK_EX)), concurrent writes from the admin API and CLI can corrupt tenants.json. Add syscall.LockFileEx(syscall.Handle(file.Fd()), syscall.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &syscall.Overlapped{}) after OpenFile, and syscall.UnlockFileEx in Close before file.Close.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/tenant/lock_windows.go, line 15:

<comment>lockRegistry on Windows opens the .lock file but never acquires an exclusive lock. Without syscall.LockFileEx (the Windows equivalent of flock(LOCK_EX)), concurrent writes from the admin API and CLI can corrupt tenants.json. Add syscall.LockFileEx(syscall.Handle(file.Fd()), syscall.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &syscall.Overlapped{}) after OpenFile, and syscall.UnlockFileEx in Close before file.Close.</comment>

<file context>
@@ -0,0 +1,28 @@
+
+// lockRegistry mirrors the unix flock variant; like
+// accounts.lockStoredAccount, Windows gets a best-effort lock file only.
+func (r *Registry) lockRegistry() (*registryLock, error) {
+	if err := os.MkdirAll(r.stateDir, 0o700); err != nil {
+		return nil, err
</file context>

// same root-stripping as data-plane URLs, so an entry saved with a /v1 or
// /backend-api suffix still reaches control endpoints at the server root.
func serverControlBaseURL(server srServerConfig) string {
base := codexProxyRootURL(server.URL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The serverControlBaseURL helper now correctly routes control requests through the tenant path prefix, but the rate-limit reset flow (resetRemoteSweep / pickSmartResetCandidateRemote) appears to POST directly to server.URL + "/_subrouter/rate-limit-reset" without going through serverControlBaseURL. For tenant-scoped server entries this would hit the global pool rather than the tenant pool, potentially failing against an empty global pool or redeeming the reset credit for a same-email account in the wrong pool. The reset path should use serverControlBaseURL(server) to ensure it's routed to the correct tenant scope.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/subrouter/sr_server.go, line 34:

<comment>The `serverControlBaseURL` helper now correctly routes control requests through the tenant path prefix, but the rate-limit reset flow (`resetRemoteSweep` / `pickSmartResetCandidateRemote`) appears to POST directly to `server.URL + "/_subrouter/rate-limit-reset"` without going through `serverControlBaseURL`. For tenant-scoped server entries this would hit the global pool rather than the tenant pool, potentially failing against an empty global pool or redeeming the reset credit for a same-email account in the wrong pool. The reset path should use `serverControlBaseURL(server)` to ensure it's routed to the correct tenant scope.</comment>

<file context>
@@ -27,9 +27,11 @@ import (
+// /backend-api suffix still reaches control endpoints at the server root.
 func serverControlBaseURL(server srServerConfig) string {
-	base := strings.TrimRight(server.URL, "/")
+	base := codexProxyRootURL(server.URL)
 	if key := strings.TrimSpace(server.TenantKey); key != "" {
 		return base + "/t/" + key
</file context>

138 commits of drift since this branch was opened. Twelve conflict hunks across
five files, resolved as follows.

Additive on both sides, kept both: the tenant/tenants and team command names,
the --multi-tenant flag alongside the Bedrock and --cloud-config flags, the
tenant CLI case alongside daemon/setup, and the tenant and broker imports.

Combined rather than chosen: main made remote deploy commands use dynamic
"$sr_owner"/"$sr_group" instead of a hardcoded subrouter:subrouter, while this
branch made the same commands tenant-aware via remoteStatePath(stateSubdir, …).
Both properties are kept, with shell-quoted paths.

Repointed: main moved selectacct and session out of internal/, so
internal/proxy/multitenant.go and its test now import
subrouter/selectacct and subrouter/session.

Usage text takes main's serve and supervise lines with --multi-tenant
re-inserted. The sr passthrough case takes main's longer verb list with
tenant/tenants added.

go build ./..., go vet ./... and go test ./... green.
@lawrencecchen
lawrencecchen merged commit 8a466ae into main Jul 30, 2026
5 of 6 checks passed
@lawrencecchen
lawrencecchen deleted the feat-multi-tenant branch July 30, 2026 03:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
cmd/subrouter/sr.go (1)

1842-1853: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace deprecated strings.Title.

Provider values come from account.Provider, and the current constants are all lowercase ASCII. Use strings.ToUpper(string(provider)) for the fallback, and update the nearby identical strings.Title(string(usageProvider(row))) label path if it should not emit "Codex accounts" for providers like kimi/zai.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/subrouter/sr.go` around lines 1842 - 1853, Replace the deprecated
strings.Title fallback in providerCountNoun with
strings.ToUpper(string(provider)). Also update the nearby label path using
strings.Title(string(usageProvider(row))) to use uppercase provider values,
preserving the existing account-label formatting for all providers.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/subrouter/sr_server.go`:
- Line 1511: Reverse the stat fallback order in the owner/group shell commands:
update cmd/subrouter/sr_server.go lines 1511 and 1536 and
cmd/subrouter/sr_claude_upload.go line 182 to try GNU stat -c '%U'/'%G' first,
then BSD stat -f '%Su'/'%Sg'. Update cmd/subrouter/sr_claude_upload_test.go
lines 54-56 to assert the GNU stat -c prefix.

In `@internal/proxy/multitenant.go`:
- Around line 306-377: Update handleTenantAdmin to fail closed when the admin
token is unset, rejecting the request before authorizeAdmin can allow it; retain
authorizeAdmin for validating configured tokens and preserve the existing
unauthorized response.

---

Nitpick comments:
In `@cmd/subrouter/sr.go`:
- Around line 1842-1853: Replace the deprecated strings.Title fallback in
providerCountNoun with strings.ToUpper(string(provider)). Also update the nearby
label path using strings.Title(string(usageProvider(row))) to use uppercase
provider values, preserving the existing account-label formatting for all
providers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b7a90e9-2214-4a8e-bbfa-581f7aa5137f

📥 Commits

Reviewing files that changed from the base of the PR and between 7a48fc3 and f7cd575.

📒 Files selected for processing (14)
  • cmd/subrouter/codex.go
  • cmd/subrouter/main.go
  • cmd/subrouter/main_test.go
  • cmd/subrouter/sr.go
  • cmd/subrouter/sr_claude_upload.go
  • cmd/subrouter/sr_claude_upload_test.go
  • cmd/subrouter/sr_server.go
  • cmd/subrouter/sr_tenant.go
  • cmd/subrouter/sr_tenant_test.go
  • internal/proxy/multitenant.go
  • internal/proxy/multitenant_test.go
  • internal/tenant/lock_unix.go
  • internal/tenant/lock_windows.go
  • internal/tenant/tenant.go

@@ -1417,10 +1511,10 @@ func (r srRunner) uploadServerAccount(ctx context.Context, server srServerConfig
"sr_owner=subrouter; sr_group=subrouter; if [ -e /var/lib/subrouter ]; then sr_owner=$(stat -f '%Su' /var/lib/subrouter 2>/dev/null || stat -c '%U' /var/lib/subrouter); sr_group=$(stat -f '%Sg' /var/lib/subrouter 2>/dev/null || stat -c '%G' /var/lib/subrouter); elif id -u _subrouter >/dev/null 2>&1; then sr_owner=_subrouter; sr_group=_subrouter; fi",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'sr_server.go|sr_claude_upload.go|sr_claude_upload_test.go' . || true

echo "== relevant lines =="
for f in cmd/subrouter/sr_server.go cmd/subrouter/sr_claude_upload.go cmd/subrouter/sr_claude_upload_test.go; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    wc -l "$f"
    rg -n "stat -f|stat -c|sr_owner|sr_group|install|chown" "$f" -C 2 || true
  else
    echo "missing $f"
  fi
done

echo "== behavioral probe with system stat semantics if present =="
if command -v stat >/dev/null 2>&1 && command -v useradd >/dev/null 2>&1 && command -v chown >/dev/null 2>&1; then
  tmpdir="$(mktemp -d)"
  trap 'rm -rf "$tmpdir"' EXIT
  touch "$tmpdir/x"
  echo "gnu/fmt -f '%Su': " "$(stat -f '%Su' "$tmpdir/x" 2>&1 || true)"
  echo "gnu/fmt -c '%U': " "$(stat -c '%U' "$tmpdir/x" 2>&1 || true)"
else
  echo "not enough system utilities to probe stat semantics"
fi

Repository: manaflow-ai/subrouter

Length of output: 8242


🌐 Web query:

GNU stat format -c %U %G owner group vs BSD stat -f %Su %Sg owner group

💡 Result:

The stat command exists in two primary, incompatible implementations: the GNU Coreutils version (commonly found on Linux) and the BSD version (found on macOS, FreeBSD, NetBSD, and OpenBSD). Because these utilities have different codebases and origin histories, their format specifiers for retrieving file owner and group information are not interchangeable [1][2]. GNU stat (Coreutils) The GNU implementation uses the following specifiers to print the owner and group names [3][4]: %U: User name of the file owner [3][5]. %G: Group name of the file owner [3][5]. BSD stat The BSD implementation (including macOS) uses a different syntax, where the 'S' prefix is generally used to indicate that the field should be formatted as a string rather than a raw ID [6][7]: %Su: User name of the file owner [6][7]. %Sg: Group name of the file owner [6][7]. Summary Table Feature | GNU stat (Linux) | BSD stat (macOS/FreeBSD/etc.) --- | --- | --- Owner Name | %U [3] | %Su [6] Group Name | %G [3] | %Sg [6] When writing portable shell scripts, you cannot rely on these specifiers working across both platforms [1][2]. If your script must run on both, you may need to detect the OS or the version of the stat command being used [2][5].

Citations:


🌐 Web query:

GNU stat %S format specifier block size string %Su invalid on GNU stat

💡 Result:

The %Su format specifier is invalid in GNU stat because it is a feature specific to BSD/macOS stat, not GNU coreutils stat [1]. GNU stat and BSD stat are different implementations of the stat utility with distinct syntax and supported format specifiers [1]. The confusion typically arises when scripts written for macOS (which uses BSD stat) are run on Linux systems (which use GNU stat) [1]. Key differences include: 1. BSD/macOS stat: Uses the -f flag to specify a format string and supports specifiers like %Su (User name of owner) and %Sg (Group name of owner) [1][2][3]. 2. GNU stat: Uses the -c (or --format) flag to specify a format string and uses %U for user name and %G for group name [4][5][1]. If you see an error indicating that %Su is invalid or that GNU stat cannot find a file named %Su, it is because GNU stat is interpreting the literal string %Su as a filename rather than a format directive [1]. To fix this in a script, you should detect the type of stat being used or use compatible alternatives [1]. A common way to check for GNU stat is to test if it supports the -c flag [1]. For your reference, here are the correct GNU stat equivalents for the common BSD %S-prefixed specifiers: - BSD %Su (User name): Use GNU %U [4][1] - BSD %Sg (Group name): Use GNU %G [4] - BSD %Sp (Permissions): Use GNU %A (human readable) or %a (octal) [4] - BSD %Sm (Modification time): Use GNU %y [4] Note: While GNU stat does support the %S format specifier, it is only valid when using the --file-system flag, where it represents the fundamental block size of the file system [5][6][7]. It does not function as a prefix for other specifiers like %Su [4][5].

Citations:


Use GNU stat formatting before the BSD fallback.

On GNU/Linux, stat -f '%Su' does not print the file owner; use stat -c '%U' / '%G' first, then fall back to stat -f '%Su' / '%Sg' for BSD hosts. Otherwise install -o receives data from a filesystem-size format rather than the owner name.

  • cmd/subrouter/sr_server.go#L1511 and cmd/subrouter/sr_server.go#L1536
  • cmd/subrouter/sr_claude_upload.go#L182
  • Update cmd/subrouter/sr_claude_upload_test.go#L54-56 to assert the GNU stat -c prefix.
📍 Affects 3 files
  • cmd/subrouter/sr_server.go#L1511-L1511 (this comment)
  • cmd/subrouter/sr_server.go#L1536-L1536
  • cmd/subrouter/sr_claude_upload.go#L182-L182
  • cmd/subrouter/sr_claude_upload_test.go#L54-L56
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/subrouter/sr_server.go` at line 1511, Reverse the stat fallback order in
the owner/group shell commands: update cmd/subrouter/sr_server.go lines 1511 and
1536 and cmd/subrouter/sr_claude_upload.go line 182 to try GNU stat -c '%U'/'%G'
first, then BSD stat -f '%Su'/'%Sg'. Update
cmd/subrouter/sr_claude_upload_test.go lines 54-56 to assert the GNU stat -c
prefix.

Comment on lines +306 to +377
// handleTenantAdmin serves the global-admin tenant CRUD:
//
// GET /_subrouter/tenants list tenants (key prefixes only)
// POST /_subrouter/tenants {"name":..} create tenant, returns key once
// POST /_subrouter/tenants/<id>/keys mint an extra key, returns it once
// DELETE /_subrouter/tenants/<id>/keys/<prefix> revoke keys matching prefix
func (m *MultiTenant) handleTenantAdmin(w http.ResponseWriter, r *http.Request) {
if !m.Base.authorizeAdmin(r) {
http.Error(w, "admin token required", http.StatusUnauthorized)
return
}
rest := strings.TrimPrefix(r.URL.Path, "/_subrouter/tenants")
rest = strings.Trim(rest, "/")
switch {
case rest == "":
switch r.Method {
case http.MethodGet:
tenants, err := m.Registry.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
views := make([]tenantView, 0, len(tenants))
for _, t := range tenants {
views = append(views, viewOf(t))
}
writeJSON(w, views)
case http.MethodPost:
var payload struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, tenantAdminMaxBodyBytes)).Decode(&payload); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
created, key, err := m.Registry.Create(payload.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"tenant": viewOf(created), "key": key})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
default:
parts := strings.Split(rest, "/")
if len(parts) >= 2 && parts[1] == "keys" {
tenantID := parts[0]
switch {
case len(parts) == 2 && r.Method == http.MethodPost:
updated, key, err := m.Registry.CreateKey(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"tenant": viewOf(updated), "key": key})
return
case len(parts) == 3 && r.Method == http.MethodDelete:
revoked, err := m.Registry.RevokeKey(tenantID, parts[2])
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"ok": true, "revoked": revoked})
return
}
}
http.NotFound(w, r)
}
}

const tenantAdminMaxBodyBytes = 1 << 16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B2 -A15 'func.*authorizeAdmin' --type=go

Repository: manaflow-ai/subrouter

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -e
printf 'go files:\n'
git ls-files '*.go' | sed -n '1,120p'
printf '\nproxy-related files:\n'
git ls-files | rg '(^|/)proxy[^/]*(\.(go|md)|$)|internal/proxy|multitenant' || true
printf '\nSearch authorizeAdmin/authAdmin/admin token symbols:\n'
rg -n 'authorizeAdmin|Auth(enticate|oriz)|AdminToken|admin-token|SUBROUTER_ADMIN_TOKEN|multi-tenant' --glob '*.go' .

Repository: manaflow-ai/subrouter

Length of output: 30616


🏁 Script executed:

#!/bin/bash
set -e
printf 'internal/proxy/proxy.go authorizeAdmin section:\n'
sed -n '1680,1725p' internal/proxy/proxy.go | cat -n
printf '\nlocalProxyAuthorized:\n'
sed -n '2058,2075p' internal/proxy/proxy.go | cat -n
printf '\ninternal/proxy/multitenant.go relevant code:\n'
sed -n '300,360p' internal/proxy/multitenant.go | cat -n
printf '\nadmin token behavior tests:\n'
sed -n '120,155p' internal/proxy/lifecycle_test.go | cat -n
sed -n '294,350p' internal/proxy/multitenant_test.go | cat -n

Repository: manaflow-ai/subrouter

Length of output: 9022


🏁 Script executed:

#!/bin/bash
set -e
printf 'internal/proxy/proxy.go authorizeAdmin section:\n'
sed -n '1680,

Repository: manaflow-ai/subrouter

Length of output: 267


🏁 Script executed:

#!/bin/bash
set -e
printf 'isLoopbackRemote definition:\n'
rg -n -B3 -A10 'func isLoopbackRemote|localProxyAuthorized' internal/proxy/proxy.go
printf '\nBehavior probe for authorizeAdmin predicate if AdminToken is empty:\n'
python3 - <<'PY'
from urllib.parse import urlparse
import socket
def is_loopback_remote(remote_addr):
    host, _, _ = remote_addr.partition(':')
    try:
        ip = socket.gethostbyname(host)
    except socket.gaierror:
        return False
    return ip.startswith("127.") or ip == "::1"
def authorize_admin(admin_token, remote_addr, headers):
    if is_loopback_remote(remote_addr):
        return True
    token = admin_token.strip()
    if token == "":
        return True
    got = headers.get("X-Subrouter-Admin-Token", "").strip()
    if not got:
        auth = headers.get("Authorization", "").strip()
        before, sep, after = auth.partition(" ")
        if sep and before.lower() == "bearer":
            got = after.strip()
    if not got or len(got) != len(token):
        return False
    return got == token
for remote in ("127.0.0.1:54321", "100.64.0.2:12345"):
    for auth_header in ("", "Bearer secret"):
        print({"remote": remote, "admin_token": "", "auth": auth_header, "authorized": authorize_admin("", remote, {"Authorization": auth_header})})
PY

Repository: manaflow-ai/subrouter

Length of output: 2106


Require an explicit admin token for tenant admin CRUD.

authorizeAdmin accepts all remote requests when AdminToken is empty, so handleTenantAdmin lets unauthenticated clients list tenants, create tenants, mint keys, and revoke keys. This CRUD surface exits the legacy “no admin token is OK for read-only/drainer” posture by exposing credential issuance and tenant-wide denial-of-service.

🔒 Fail-closed guard
 func (m *MultiTenant) handleTenantAdmin(w http.ResponseWriter, r *http.Request) {
-	if !m.Base.authorizeAdmin(r) {
+	if strings.TrimSpace(m.Base.AdminToken) == "" || !m.Base.authorizeAdmin(r) {
 		http.Error(w, "admin token required", http.StatusUnauthorized)
 		return
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// handleTenantAdmin serves the global-admin tenant CRUD:
//
// GET /_subrouter/tenants list tenants (key prefixes only)
// POST /_subrouter/tenants {"name":..} create tenant, returns key once
// POST /_subrouter/tenants/<id>/keys mint an extra key, returns it once
// DELETE /_subrouter/tenants/<id>/keys/<prefix> revoke keys matching prefix
func (m *MultiTenant) handleTenantAdmin(w http.ResponseWriter, r *http.Request) {
if !m.Base.authorizeAdmin(r) {
http.Error(w, "admin token required", http.StatusUnauthorized)
return
}
rest := strings.TrimPrefix(r.URL.Path, "/_subrouter/tenants")
rest = strings.Trim(rest, "/")
switch {
case rest == "":
switch r.Method {
case http.MethodGet:
tenants, err := m.Registry.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
views := make([]tenantView, 0, len(tenants))
for _, t := range tenants {
views = append(views, viewOf(t))
}
writeJSON(w, views)
case http.MethodPost:
var payload struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, tenantAdminMaxBodyBytes)).Decode(&payload); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
created, key, err := m.Registry.Create(payload.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"tenant": viewOf(created), "key": key})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
default:
parts := strings.Split(rest, "/")
if len(parts) >= 2 && parts[1] == "keys" {
tenantID := parts[0]
switch {
case len(parts) == 2 && r.Method == http.MethodPost:
updated, key, err := m.Registry.CreateKey(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"tenant": viewOf(updated), "key": key})
return
case len(parts) == 3 && r.Method == http.MethodDelete:
revoked, err := m.Registry.RevokeKey(tenantID, parts[2])
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"ok": true, "revoked": revoked})
return
}
}
http.NotFound(w, r)
}
}
const tenantAdminMaxBodyBytes = 1 << 16
func (m *MultiTenant) handleTenantAdmin(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(m.Base.AdminToken) == "" || !m.Base.authorizeAdmin(r) {
http.Error(w, "admin token required", http.StatusUnauthorized)
return
}
rest := strings.TrimPrefix(r.URL.Path, "/_subrouter/tenants")
rest = strings.Trim(rest, "/")
switch {
case rest == "":
switch r.Method {
case http.MethodGet:
tenants, err := m.Registry.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
views := make([]tenantView, 0, len(tenants))
for _, t := range tenants {
views = append(views, viewOf(t))
}
writeJSON(w, views)
case http.MethodPost:
var payload struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, tenantAdminMaxBodyBytes)).Decode(&payload); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
created, key, err := m.Registry.Create(payload.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"tenant": viewOf(created), "key": key})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
default:
parts := strings.Split(rest, "/")
if len(parts) >= 2 && parts[1] == "keys" {
tenantID := parts[0]
switch {
case len(parts) == 2 && r.Method == http.MethodPost:
updated, key, err := m.Registry.CreateKey(tenantID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"tenant": viewOf(updated), "key": key})
return
case len(parts) == 3 && r.Method == http.MethodDelete:
revoked, err := m.Registry.RevokeKey(tenantID, parts[2])
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"ok": true, "revoked": revoked})
return
}
}
http.NotFound(w, r)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/proxy/multitenant.go` around lines 306 - 377, Update
handleTenantAdmin to fail closed when the admin token is unset, rejecting the
request before authorizeAdmin can allow it; retain authorizeAdmin for validating
configured tokens and preserve the existing unauthorized response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant