Skip to content
Open
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
17 changes: 12 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,11 +762,18 @@ therefore has exactly three routing shapes, mirroring the entire.io BFF:
when the skips left no cell to query), never substituted with a ready
mirror.

Token rule: identity tokens are **per-jurisdiction, not per-cell**. Multi-cell
callers must build one `auth.CellClientFactory`
(`NewEntireAPICellClientFactory`) per operation — it resolves the login
subject once and mints at most one token per jurisdiction. `fanOutCells` does
this automatically; do not call `NewEntireAPICellClient` in a loop.
Token rule: cell clients carry the **login JWT itself**, not a minted
identity token — the per-jurisdiction exchange they used to do was removed in
`3df7ea461` ("Use login JWTs directly for cell auth"), so `CellTarget`'s
jurisdiction now selects only the *host*, never an audience. Multi-cell
callers must still build one `auth.CellClientFactory`
(`NewEntireAPICellClientFactory`) per operation — it resolves and refreshes
the login subject once instead of per cell. `fanOutCells` does this
automatically; do not call `NewEntireAPICellClient` in a loop. The minting
path survives only as `auth.JurisdictionToken`, behind
`entire auth token --jurisdiction` (a scripting helper for the cells that DO
require `aud=<jurisdiction host>`); do not infer from it what the CLI's own
data-plane requests carry.

### Session Strategy (`cmd/entire/cli/strategy/`)

Expand Down
8 changes: 5 additions & 3 deletions cmd/entire/cli/cell_fanout.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,9 +382,11 @@ type cellClientBuilder interface {
ClientFor(ctx context.Context, target *auth.CellTarget) (*api.Client, error)
}

// newCellClientBuilder builds the per-operation cell client factory: the
// subject is resolved once and identity tokens are minted once per
// jurisdiction, however many cells the fan-out touches. Swapped in tests.
// newCellClientBuilder builds the per-operation cell client factory: the login
// subject is resolved (and refreshed) once, however many cells the fan-out
// touches, and every cell client carries that login JWT directly — the
// per-jurisdiction identity-token exchange this used to do was removed in
// 3df7ea461. Swapped in tests.
var newCellClientBuilder = func(ctx context.Context, insecureHTTP bool) (cellClientBuilder, error) {
return auth.NewEntireAPICellClientFactory(ctx, insecureHTTP)
}
Expand Down
29 changes: 25 additions & 4 deletions cmd/entire/cli/search/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ const apiTimeout = 30 * time.Second

// v4ServicePath is the per-repo v4 query-serve route exposed by the entire-api
// cell gateway. It takes repo=<ULID>. The BFF (entire.io /api/v1/search)
// forwards to this same path; the CLI dials the cell directly with a
// jurisdictional identity token, skipping the BFF hop.
// forwards to this same path; the CLI dials the cell directly with the
// caller's login JWT, skipping the BFF hop.
const v4ServicePath = "/api/v1/semantic-search/search/v1/search"

// ErrCellUnavailable reports that a cell's gateway does not expose the
Expand Down Expand Up @@ -64,6 +64,18 @@ type MalformedResponseError struct {

func (e *MalformedResponseError) Error() string { return e.Message }

// ErrCellUnauthorized reports that a cell answered the semantic-search route
// but refused the caller's bearer (HTTP 401, or 403 for a credential the
// service verified and then declined). The login is not the suspect it looks
// like: cell clients carry the login JWT itself (3df7ea461), so the very same
// bearer is the one code search accepts at the same host — a rejection here is
// this service declining a credential, not a session the user can refresh.
// Distinct from ErrCellUnavailable because the two demand opposite things of
// the user: wait for the query-serve rollout versus report a rejection that
// will not age out. Never report one as the other (entireio/cli#2121,
// entirehq/entire-search#196).
var ErrCellUnauthorized = errors.New("semantic search rejected the caller's credentials in this cell")

// WildcardQuery is the query string used when only filters are provided (no search terms).
const WildcardQuery = "*"

Expand Down Expand Up @@ -734,8 +746,8 @@ func AppendUnique(existing []string, values ...string) []string {
}

// CellV4 performs a v4 query-serve search against a single entire-api
// cell, via the pre-authenticated client (bearer = jurisdictional identity
// token; host = the cell). repoIDs are repo ULIDs to scope to (the v4 route is
// cell, via the pre-authenticated client (bearer = the caller's login JWT,
// which is what cell clients carry since 3df7ea461; host = the cell). repoIDs are repo ULIDs to scope to (the v4 route is
// per-repo and keys on ULIDs, not owner/name slugs); an empty repoIDs means
// "every repo the caller can access in this cell" — query-serve fans out across
// those namespaces itself. The cross-cell fan-out and merge live in the cli
Expand Down Expand Up @@ -787,6 +799,15 @@ func CellV4(ctx context.Context, client *api.Client, cfg Config, repoIDs []strin
}
return nil, ErrCellUnavailable
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
// Add the sentinel WITHOUT dropping the HTTPStatusError
// parseSearchResponse would have returned: its status is what outcome
// telemetry classifies as auth (ENT-1938), and its wording is what
// callers and tests pin. Two %w verbs keep both matchable — errors.Is
// for the sentinel, errors.As for the status.
_, statusErr := parseSearchResponse(resp.StatusCode, body)
return nil, fmt.Errorf("%w: %w", ErrCellUnauthorized, statusErr)
}
return parseSearchResponse(resp.StatusCode, body)
}

Expand Down
73 changes: 66 additions & 7 deletions cmd/entire/cli/search/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,30 +133,89 @@ func TestParseGitHubRemote_EntireMirror(t *testing.T) {

// -- Search() tests --

// TestCellV4_Unauthorized covers the credential-rejection classification: a
// cell that answers the route but refuses the token is ErrCellUnauthorized, so
// the fan-out can say so instead of leaking a bare "search service error
// (401): Unauthorized" (entireio/cli#2121). 403 joins it — the service
// verifies the token and then declines it — while the status and the service's
// own message stay in the text — and the typed status stays in the chain — for
// debug logs and outcome telemetry.
func TestCellV4_Unauthorized(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
status int
body string
want string
}{
{name: "401 with json error", status: http.StatusUnauthorized, body: `{"error":"Unauthorized"}`, want: "search service error (401): Unauthorized"},
{name: "403 with json error", status: http.StatusForbidden, body: `{"error":"Forbidden"}`, want: "search service error (403): Forbidden"},
{name: "401 with raw body", status: http.StatusUnauthorized, body: "authorization required", want: "search service returned 401: authorization required"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
w.Write([]byte(tc.body)) //nolint:errcheck // test helper response
}))
defer srv.Close()

_, err := CellV4(context.Background(), api.NewClientWithBaseURL("tok", srv.URL), Config{Query: "q"}, nil)
if !errors.Is(err, ErrCellUnauthorized) {
t.Fatalf("error = %v, want ErrCellUnauthorized", err)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %q, want it to contain %q", err.Error(), tc.want)
}
// A rejected credential must never read as a cell that lacks the
// route: the two need opposite responses from the user.
if errors.Is(err, ErrCellUnavailable) {
t.Error("ErrCellUnauthorized must not also match ErrCellUnavailable")
}
// The typed status must survive alongside the sentinel — outcome
// telemetry classifies auth from it (ENT-1938).
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) {
t.Fatal("want an *HTTPStatusError in the chain")
}
if statusErr.StatusCode != tc.status {
t.Errorf("StatusCode = %d, want %d", statusErr.StatusCode, tc.status)
}
})
}
}

// TestCellV4_ErrorJSON pins the generic non-2xx wording, which every status
// outside the classified ones (404 route/repo, 401/403 credentials) still
// gets.
func TestCellV4_ErrorJSON(t *testing.T) {
t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid token"}) //nolint:errcheck // test helper response
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "Search failed"}) //nolint:errcheck // test helper response
}))
defer srv.Close()

_, err := CellV4(context.Background(), api.NewClientWithBaseURL("tok", srv.URL), Config{Query: "q"}, nil)
if err == nil {
t.Fatal("expected error for 401")
t.Fatal("expected error for 500")
}
if got := err.Error(); got != "search service error (500): Search failed" {
t.Errorf("error = %q, want 'search service error (500): Search failed'", got)
}
if got := err.Error(); got != "search service error (401): Invalid token" {
t.Errorf("error = %q, want 'search service error (401): Invalid token'", got)
if errors.Is(err, ErrCellUnauthorized) {
t.Error("a 500 must not classify as a credential rejection")
}
// Outcome telemetry classifies by status code, so the error must be typed.
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) {
t.Fatalf("error is %T, want *HTTPStatusError", err)
}
if statusErr.StatusCode != http.StatusUnauthorized {
t.Errorf("StatusCode = %d, want %d", statusErr.StatusCode, http.StatusUnauthorized)
if statusErr.StatusCode != http.StatusInternalServerError {
t.Errorf("StatusCode = %d, want %d", statusErr.StatusCode, http.StatusInternalServerError)
}
}

Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/search_telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ func classifySearchError(err error) string {
return telemetry.SearchErrClassAuth
case errors.Is(err, auth.ErrNoCellForJurisdiction):
return telemetry.SearchErrClassCellSkip
case errors.Is(err, search.ErrCellUnauthorized):
// A cell that answered and refused the bearer. The 401/403
// HTTPStatusError is still in the chain (and would classify the same
// way), but keep the sentinel case: it fixes the class even for a
// rejection that arrives without one.
return telemetry.SearchErrClassAuth
case errors.Is(err, search.ErrCellUnavailable), errors.Is(err, errNoRegionAvailable):
return telemetry.SearchErrClassRegionUnavailable
case errors.Is(err, search.ErrRepoFilterUnmatched), errors.Is(err, errNoRepoAvailable):
Expand Down
32 changes: 31 additions & 1 deletion cmd/entire/cli/search_telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/url"
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/api"
Expand Down Expand Up @@ -78,7 +79,7 @@ func TestClassifySemanticCells_SkipCausesClassify(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
pages, _, lastErr := classifySemanticCells(context.Background(), tc.results)
pages, _, _, lastErr := classifySemanticCells(context.Background(), tc.results)
if len(pages) != 0 || lastErr == nil {
t.Fatalf("pages = %d, lastErr = %v; want no pages and an error", len(pages), lastErr)
}
Expand All @@ -92,6 +93,35 @@ func TestClassifySemanticCells_SkipCausesClassify(t *testing.T) {
}
}

// Pins the same contract for a rejected credential: the user-facing message
// swaps in (naming the regions, no bare "search service error (401)"), while
// the chain still classifies as auth rather than falling through to "other" —
// which is what a plain fmt.Errorf here would have done.
func TestClassifySemanticCells_UnauthorizedClassifiesAsAuth(t *testing.T) {
t.Parallel()
cell := func(name string, err error) cellCallResult[*search.Response] {
return cellCallResult[*search.Response]{group: cellGroup{cell: name}, err: err}
}
unauthorized := fmt.Errorf("%w: %w", search.ErrCellUnauthorized, &search.HTTPStatusError{StatusCode: 401, Message: "search service error (401): Unauthorized"})

pages, _, unauth, lastErr := classifySemanticCells(context.Background(), []cellCallResult[*search.Response]{
cell("aws-eu-central-1", unauthorized),
cell("aws-us-east-2", unauthorized),
})
if len(pages) != 0 || lastErr == nil {
t.Fatalf("pages = %d, lastErr = %v; want no pages and an error", len(pages), lastErr)
}
if len(unauth) != 2 {
t.Errorf("unauthorized cells = %v, want both named", unauth)
}
if got := lastErr.Error(); !strings.Contains(got, "aws-eu-central-1") || strings.Contains(got, "search service error") {
t.Errorf("user-facing message = %q, want the regions named and the raw service error gone", got)
}
if got := classifySearchError(lastErr); got != telemetry.SearchErrClassAuth {
t.Errorf("classifySearchError = %q, want %q", got, telemetry.SearchErrClassAuth)
}
}

// hintError must swap the user-facing message without truncating the typed
// chain telemetry classifies from.
func TestHintErrorPreservesMessageAndChain(t *testing.T) {
Expand Down
Loading
Loading