Files
hanzoai_agent/control-plane/pkg/auth/middleware.go
zeekay c633ebf33c fix(agent): close Red P1 — RequireIdentity rejects empty X-Org-Id
internal/server/middleware/identity.go + pkg/auth/middleware.go:
RequireIdentity(true) now rejects empty X-Org-Id regardless of
X-User-Id presence. The previous shape only 401'd when BOTH headers
were empty, letting "X-User-Id only" requests fall through with
empty org context. Empty org collapses to the solo bucket and reaches
handlers with unscoped queries until 021_org_id_not_null lands.

Org is the trust pivot for SQL scoping; user is informational. The
gin and stdlib paths now reject identically.

Tests:
- pkg/auth: TestRequireIdentity_RequireTrueRejectsUserOnly (renamed
  from RequireTrueAcceptsUserOnly), TestRequireIdentity_RequireTrueAcceptsOrgOnly.
- middleware: TestRequireIdentity_RejectsEmptyOrgWithUser,
  TestRequireIdentity_AcceptsOrgWithoutUser.

go test ./pkg/auth/... ./internal/server/middleware/... PASS.

Closes Red 2026-04-27 P1.
2026-04-28 13:48:21 -07:00

86 lines
2.7 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
// Package auth wires gateway-supplied identity headers (X-Org-Id,
// X-User-Id, X-User-Email) into request context. agentd does not
// validate JWTs itself — hanzoai/gateway already did that. The
// middleware is the trust boundary: in production, only the gateway
// can reach agentd, and AGENTD_REQUIRE_IDENTITY=true rejects any
// request without identity headers.
package auth
import (
"context"
"net/http"
)
const (
HeaderOrgID = "X-Org-Id"
HeaderUserID = "X-User-Id"
HeaderUserEmail = "X-User-Email"
)
type ctxKey int
const (
ctxKeyOrgID ctxKey = iota
ctxKeyUserID
ctxKeyUserEmail
)
// RequireIdentity reads identity headers and attaches them to ctx.
// When require=true and no headers are present, responds 401. When
// require=false, missing headers yield empty ctx values — the
// embedded/dev path.
func RequireIdentity(require bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
org := r.Header.Get(HeaderOrgID)
user := r.Header.Get(HeaderUserID)
email := r.Header.Get(HeaderUserEmail)
// Empty org collapses to the solo bucket and would
// reach handlers with unscoped context until the
// 021_org_id_not_null migration lands. Reject any
// request without an org header in cloud mode,
// regardless of X-User-Id presence — org is the
// trust pivot, user is informational.
if require && org == "" {
http.Error(w, `{"error":"identity required","code":401}`, http.StatusUnauthorized)
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, ctxKeyOrgID, org)
ctx = context.WithValue(ctx, ctxKeyUserID, user)
ctx = context.WithValue(ctx, ctxKeyUserEmail, email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// OrgID returns the org id attached by RequireIdentity, or "".
func OrgID(ctx context.Context) string { return strFromCtx(ctx, ctxKeyOrgID) }
// UserID returns the user id attached by RequireIdentity, or "".
func UserID(ctx context.Context) string { return strFromCtx(ctx, ctxKeyUserID) }
// UserEmail returns the user email attached by RequireIdentity, or "".
func UserEmail(ctx context.Context) string { return strFromCtx(ctx, ctxKeyUserEmail) }
// WithOrgContext returns ctx with org pinned. Used by the gin
// adapter (and any non-stdlib transport) so the SQL layer can read
// OrgID(ctx) without going through the gin Request.
func WithOrgContext(ctx context.Context, orgID string) context.Context {
if orgID == "" {
return ctx
}
return context.WithValue(ctx, ctxKeyOrgID, orgID)
}
func strFromCtx(ctx context.Context, k ctxKey) string {
if v, ok := ctx.Value(k).(string); ok {
return v
}
return ""
}