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:
parent
b3b05391ac
commit
618fa9d7fb
@ -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
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user