fix(catalyst-api): mint HS256 bridge token for sovereign app publish proxy (Closes #1735)

The chroot proxy at /api/v1/sovereign/apps/{slug}/publish forwards
to the SME catalog at http://catalog.sme.svc.cluster.local:8082's
PATCH /catalog/admin/apps/{slug}/publish endpoint. The pre-fix code
sent NO Authorization header at all, so:

  1. core/services/catalog/main.go's JWTAuth middleware (line 77, applied
     to every /catalog/admin/* path) rejected the request with 401
     BEFORE the handler ran ("missing or invalid authorization header").

  2. Even with a header, requireAdmin (core/services/catalog/handlers
     /handlers.go:21) would reject any caller without role="superadmin".

Result: every Publish toggle click in the Sovereign Console surfaced
as "sme-catalog-rejected upstream returned 401" with no actionable
hint — the operator could not toggle marketplace visibility for any
app on a production Sovereign.

Fix: mint a fresh HS256 bridge token via the existing
h.mintSMEBridgeToken helper (the same one sme_billing_vouchers.go's
proxySMEVoucher uses for the BSS Vouchers surface) and forward it as
the upstream Authorization header. The helper signs the token with
sme-secrets/JWT_SECRET — the same secret the SME catalog Pod loads
from its JWT_SECRET env (per products/catalyst/chart/templates
/sme-services/catalog.yaml:40-44). Operators with `catalyst-owner`
realm-role (per shared/auth.SMERoleFor) get role="superadmin" in the
bridge token, satisfying requireAdmin upstream.

  - Adds a `bearer` parameter to smeCatalogClient.SetPublished.
  - HandleSovereignAppPublish mints the bridge token BEFORE the
    upstream round-trip so an unwired bridge (Sovereign without
    marketplace, stale chart predating the reflector annotation
    on sme-secrets) surfaces 503 sme-jwt-bridge-unwired rather
    than the pre-fix silent 401.
  - Per docs/INVIOLABLE-PRINCIPLES.md #10 the token is NEVER logged.

Verified: build + go test ./internal/handler/ pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-18 19:37:58 +02:00
parent b3b05391ac
commit 618fa9d7fb
2 changed files with 45 additions and 6 deletions

View File

@ -130,7 +130,19 @@ func (c *smeCatalogClient) PublishedBySlug(ctx context.Context) (map[string]bool
// SetPublished — proxy PATCH /catalog/admin/apps/{slug}/publish to the
// SME catalog. Used by HandleSovereignAppPublish. Returns the upstream
// HTTP status verbatim (so a 404 from SME catalog stays 404, etc.).
func (c *smeCatalogClient) SetPublished(ctx context.Context, slug string, published bool) (int, error) {
//
// `bearer` is the compact HS256 token (no "Bearer " prefix) the caller
// must have minted via authpkg.MintSMEAccessToken — without it the
// upstream catalog's JWTAuth middleware (core/services/catalog/main.go:79)
// rejects the request with 401 BEFORE the handler runs, and the
// handler's `requireAdmin` (core/services/catalog/handlers/handlers.go:21)
// rejects any caller without the `superadmin` role claim. The pre-bridge
// state shipped no Authorization header at all → silent 401 surfaced to
// the operator as "sme-catalog-rejected upstream returned 401" with no
// actionable hint. See Closes #1735 for the original repro.
//
// Per docs/INVIOLABLE-PRINCIPLES.md #10 the token is NEVER logged.
func (c *smeCatalogClient) SetPublished(ctx context.Context, slug string, published bool, bearer string) (int, error) {
if strings.TrimSpace(slug) == "" {
return 0, fmt.Errorf("sme-catalog: slug is required")
}
@ -141,6 +153,9 @@ func (c *smeCatalogClient) SetPublished(ctx context.Context, slug string, publis
return 0, err
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(bearer) != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := c.http.Do(req)
if err != nil {
return 0, err

View File

@ -711,10 +711,24 @@ func resolveAppEnvironment(envBySlug map[string]string, slug string) string {
// same data plane the deleted /catalog page used.
//
// 503 when the SME catalog is not deployed on this Sovereign
// (marketplace.enabled=false in the chart values). 404 when the slug
// (marketplace.enabled=false in the chart values), or when the
// SME HS256 bridge secret is not wired (sme-secrets reflection
// not yet reconciled). 401 when the operator's session has no
// claims attached (auth middleware bypassed). 404 when the slug
// isn't in the SME catalog. The upstream status is surfaced verbatim.
//
// Body shape: {"published": true|false}.
//
// Auth seam (Closes #1735): the upstream catalog runs JWTAuth on
// every /catalog/admin/* path and requireAdmin on this specific
// handler. Forwarding the operator's Keycloak RS256 session header
// to the catalog 401s upstream (catalog only accepts HS256 signed
// with sme-secrets/JWT_SECRET, just like the rest of the SME mesh).
// Mint a fresh HS256 bridge token via the same h.mintSMEBridgeToken
// helper sme_billing_vouchers.go uses for the BSS Vouchers surface
// and forward THAT as the upstream Authorization header. Operators
// with `catalyst-owner` realm-role (per SMERoleFor) get role=
// "superadmin" claimed in the bridge token, satisfying requireAdmin.
func (h *Handler) HandleSovereignAppPublish(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
if slug == "" {
@ -733,9 +747,19 @@ func (h *Handler) HandleSovereignAppPublish(w http.ResponseWriter, r *http.Reque
})
return
}
// Mint the HS256 bridge token BEFORE the upstream round-trip so
// an unwired bridge (Sovereign without marketplace, or stale
// chart predating the reflector annotation on sme-secrets)
// surfaces 503 with an actionable error rather than the silent
// 401 the pre-bridge state produced.
bearer, status, errResp := h.mintSMEBridgeToken(r)
if errResp != nil {
writeJSON(w, status, errResp)
return
}
ctx, cancel := context.WithTimeout(r.Context(), smeCatalogProbeBudget)
defer cancel()
status, err := smeCatalog().SetPublished(ctx, slug, body.Published)
upstreamStatus, err := smeCatalog().SetPublished(ctx, slug, body.Published, bearer)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "sme-catalog-unreachable",
@ -743,16 +767,16 @@ func (h *Handler) HandleSovereignAppPublish(w http.ResponseWriter, r *http.Reque
})
return
}
if status >= 200 && status < 300 {
if upstreamStatus >= 200 && upstreamStatus < 300 {
writeJSON(w, http.StatusOK, map[string]any{
"slug": slug,
"published": body.Published,
})
return
}
writeJSON(w, status, map[string]string{
writeJSON(w, upstreamStatus, map[string]string{
"error": "sme-catalog-rejected",
"detail": fmt.Sprintf("upstream returned %d", status),
"detail": fmt.Sprintf("upstream returned %d", upstreamStatus),
})
}