Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions control-plane/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ paths:
responses:
"200": {$ref: "#/components/responses/Session"}
"401": {$ref: "#/components/responses/Error"}
"429": {$ref: "#/components/responses/Error"}
"500": {$ref: "#/components/responses/Error"}
/auth/logout:
post:
summary: End the current session
Expand All @@ -42,6 +44,7 @@ paths:
responses:
"200": {$ref: "#/components/responses/Session"}
"401": {$ref: "#/components/responses/Error"}
"503": {$ref: "#/components/responses/Error"}
/overview:
get:
summary: Scoped usage, trend and model availability
Expand Down
2 changes: 1 addition & 1 deletion control-plane/internal/httpapi/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// evict sessions issued before the reset so a stolen cookie dies too
// Evict sessions issued before the reset so a stolen cookie dies too
u.PasswordChangedAt = time.Now().Unix()
}
if body.Tenant != nil {
Expand Down
4 changes: 4 additions & 0 deletions control-plane/internal/httpapi/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, maxJSONBody, &body) {
return
}
if len(body.Email) > maxEmailLen {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
throttleKey := s.clientIP(r) + "|" + user.NormalizeEmail(body.Email)
if !s.throttle.allow(throttleKey, time.Now()) {
writeError(w, http.StatusTooManyRequests, "too many login attempts; retry later")
Expand Down
8 changes: 7 additions & 1 deletion control-plane/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
const (
sessionCookie = "cp_session"
maxJSONBody = 1 << 20
maxEmailLen = 254
maxConfigBody = 4 << 20
statusField = "status"
)
Expand Down Expand Up @@ -114,6 +115,11 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
return
}
u, err := s.users.ByID(r.Context(), session.UserID)
if err != nil && !errors.Is(err, user.ErrNotFound) {
log.WithFunc("httpapi.requireAuth").Errorf(r.Context(), err, "load session user rid=%s", gateway.RequestIDFrom(r.Context()))
writeError(w, http.StatusServiceUnavailable, "authentication is temporarily unavailable")
return
}
if err != nil || u.Disabled || (u.PasswordChangedAt > 0 && session.IssuedAt <= u.PasswordChangedAt) {
_ = s.sessions.Delete(r.Context(), session.ID)
writeError(w, http.StatusUnauthorized, "authentication required")
Expand Down Expand Up @@ -286,7 +292,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, limit int64, value any)
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// the status line is already committed; an encode error is a gone client
// The status line is already committed; an encode error is a gone client
_ = json.NewEncoder(w).Encode(value)
}

Expand Down
57 changes: 57 additions & 0 deletions control-plane/internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package httpapi

import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -229,6 +233,47 @@ func TestLoginThrottleSweepStaysAmortisedAndReclaimsExpired(t *testing.T) {
}
}

func TestLoginRejectsAnOverlongEmailBeforeTheThrottle(t *testing.T) {
s := newTestServer(t, usermemory.New(), false)
body, _ := json.Marshal(map[string]string{"email": strings.Repeat("a", 1<<16) + "@example.com", "password": "x"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if n := len(s.throttle.windows); n != 0 {
t.Fatalf("throttle windows = %d, want none kept for an overlong email", n)
}
}

func TestSessionSurvivesATransientUserStoreError(t *testing.T) {
store := &flakyStore{Store: usermemory.New()}
hash, err := auth.HashPassword("password123!")
if err != nil {
t.Fatalf("hash password: %v", err)
}
now := time.Now().Unix()
if err := store.Create(t.Context(), user.User{
ID: "admin", Email: "admin@example.com", DisplayName: "admin", PasswordHash: hash,
Role: user.RoleSystemAdmin, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("seed user: %v", err)
}
handler := newTestServer(t, store, false).Handler()
admin := loginAs(t, handler, "admin@example.com")

store.fail.Store(true)
if rec := request(t, handler, admin, http.MethodGet, "/api/v1/session", nil, false); rec.Code != http.StatusServiceUnavailable {
t.Fatalf("session status during a store outage = %d, want 503", rec.Code)
}
store.fail.Store(false)
if rec := request(t, handler, admin, http.MethodGet, "/api/v1/session", nil, false); rec.Code != http.StatusOK {
t.Fatalf("session status after the store recovered = %d, want 200", rec.Code)
}
}

type loginState struct {
cookie *http.Cookie
csrf string
Expand Down Expand Up @@ -312,3 +357,15 @@ func request(t *testing.T, handler http.Handler, state loginState, method, path
handler.ServeHTTP(rec, req)
return rec
}

type flakyStore struct {
user.Store
fail atomic.Bool
}

func (f *flakyStore) ByID(ctx context.Context, id string) (user.User, error) {
if f.fail.Load() {
return user.User{}, errors.New("connection refused")
}
return f.Store.ByID(ctx, id)
}
5 changes: 3 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,9 @@ against `max_live_streams_per_key`. JSON-RPC batches are refused (400); the
key's QPS and the tenant's pooled QPS apply. A server declared with `oauth` is
called with an access token the gateway fetches from the server's token
endpoint; a `401` from the server fetches a fresh token and retries the call
once. Upstream failures answer a generic `502`; the server's endpoint and the
identity provider's error text stay in the gateway log.
once. Upstream failures answer a generic `500` (`internal_server_exception`);
the server's endpoint and the identity provider's error text stay in the
gateway log.

## Batch & files

Expand Down
2 changes: 1 addition & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ Price the chat models by their reasoning too: xAI's chat wire reports
`total_tokens`, and the gateway reads that arithmetic and bills them at the
output rate. Every reply carries `usage.cost_in_usd_ticks`, so with list prices
configured the ledger's `cost_micros` matches the vendor's own charge — the
check `scripts/live-matrix/live_matrix.py --group xai` runs.
check `scripts/live-matrix/live_matrix.py xai` runs.

The same models are served by Bedrock and OpenRouter, both of which normalize
that usage shape to the OpenAI one:
Expand Down
6 changes: 3 additions & 3 deletions docs/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ secret and refresh-token seed are read from the environment at fetch time.
A tenant whose `security.moderate` is on has every served `tools/call`,
`resources/read` and `prompts/get` result reviewed by the moderator behind
`moderation:` — the same review the chat and realtime surfaces apply to
inbound text, here over every prose field of the result (string values under
`result`, joined by newlines; `blob`, `mimeType`, `name`, `type` and `uri`
fields are identifiers or binary and pass untouched). The reply is buffered
inbound text, here over every string value under `result`, joined by
newlines, except the base64 `blob` and `data` of an image or audio block or a
blob resource, which pass untouched. The reply is buffered
for the review, up to the server's `max_reply_bytes`. A mask rewrites the text
in place (`[MASKED]`); a denial, a degrade verdict, a mask that matches
nothing, or a reply the gateway cannot parse replaces the whole reply with one
Expand Down
6 changes: 4 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,10 @@ Stated so an operator can choose them deliberately:
(default) denies the request or tool result, `true` admits it.
- Upstream failure: account failover within the model, then the model's
`fallback_models` chain, only while nothing has been sent to the client.
Upstream and identity-provider errors reach the customer as a generic
`502`; endpoints and error text stay in the gateway log.
A terminal model failure then answers `424` with
`model_error_exception` (see [API](api.md)); an MCP server or
identity-provider failure answers a generic `500`, and its endpoint and
error text stay in the gateway log.
- Abuse of long-lived connections: realtime sessions and MCP listen streams
are capped per key (`max_live_streams_per_key`); realtime turns and every
MCP request spend the key's QPS permits; request bodies are capped at
Expand Down
Loading