mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-05 23:36:37 +00:00
fix(cloud): recover hosted tenant handoff context
Recover hosted tenant context during cloud handoff exchange when older tenant containers are missing PULSE_TENANT_ID, and govern the hosted handoff contract in the owning API/lifecycle/storage subsystem docs.
This commit is contained in:
parent
8ecea8dbdf
commit
8b91ab7c04
6 changed files with 145 additions and 4 deletions
|
|
@ -260,6 +260,12 @@ before any API-only token fallback or optional-auth anonymous fallback so
|
|||
operators can mint relay-mobile credentials and continue onboarding from the
|
||||
hosted runtime itself even after that tenant has already minted managed API
|
||||
tokens.
|
||||
That same lifecycle-adjacent hosted setup path must also survive legacy tenant
|
||||
runtime env drift. When Pulse Account hands an operator into a hosted workspace,
|
||||
`internal/api/cloud_handoff_handlers.go` must still recover the canonical
|
||||
tenant context from hosted runtime state if `PULSE_TENANT_ID` is missing, so
|
||||
lifecycle entry into onboarding, setup, and mobile-pairing surfaces does not
|
||||
die before the first authenticated page load.
|
||||
That same lifecycle-adjacent hosted setup path also depends on AI bootstrap
|
||||
staying canonical before the first settings write. Hosted operators may land
|
||||
in Chat, Patrol-backed setup hints, or AI-dependent remediation surfaces
|
||||
|
|
|
|||
|
|
@ -1465,6 +1465,13 @@ must also project the effective default-org hosted lease when the tenant-local
|
|||
billing file has not been materialized yet, so admin billing-state payloads
|
||||
stay coherent with the tenant's active entitlement payload instead of briefly
|
||||
regressing to local trial/default state.
|
||||
That same hosted handoff boundary also owns tenant-context recovery inside the
|
||||
tenant runtime itself. When older hosted containers are missing explicit
|
||||
`PULSE_TENANT_ID`, `internal/api/cloud_handoff_handlers.go` must recover the
|
||||
JWT audience/tenant context from hosted-only runtime inputs like
|
||||
`PULSE_PUBLIC_URL` or the hosted tenant request host before it mints the
|
||||
browser session, rather than failing a valid control-plane handoff with a
|
||||
generic `500 internal error`.
|
||||
Canonical missing-resource lookups in governed frontend API clients must now
|
||||
also route `404 => null` response handling through shared response helpers in
|
||||
`frontend-modern/src/api/responseUtils.ts` rather than open-coding local
|
||||
|
|
|
|||
|
|
@ -1052,6 +1052,11 @@ surfaces may run without local auth configured, but a valid tenant
|
|||
the anonymous optional-auth fallback so hosted recovery, onboarding, and
|
||||
support flows do not silently degrade into unauthenticated state or bearer-
|
||||
token-only mode after cloud handoff.
|
||||
That same hosted recovery boundary must also preserve tenant-context recovery at
|
||||
the handoff exchange itself. When older hosted tenant containers are missing
|
||||
`PULSE_TENANT_ID`, the shared handoff handler must still recover the tenant
|
||||
from hosted runtime context before it mints session cookies, so recovery/support
|
||||
entry from Pulse Account does not fail closed with a generic internal error.
|
||||
That same shared `internal/api/` boundary also owns hosted AI bootstrap
|
||||
continuity. Storage- and recovery-adjacent hosted flows may surface Patrol-
|
||||
backed investigation or AI-assisted recovery guidance before an operator has
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@ func tenantIDFromRequest(r *http.Request) string {
|
|||
}
|
||||
return ""
|
||||
}
|
||||
if hostedModeEnabledFromEnv() {
|
||||
if v := tenantIDFromPublicURL(strings.TrimSpace(os.Getenv("PULSE_PUBLIC_URL"))); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if hostedRuntimeTenantID := tenantIDFromHostedProxyRequest(r); hostedRuntimeTenantID != "" {
|
||||
return hostedRuntimeTenantID
|
||||
}
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -220,6 +228,46 @@ func tenantIDFromRequest(r *http.Request) string {
|
|||
return tenantID
|
||||
}
|
||||
|
||||
func tenantIDFromHostedProxyRequest(r *http.Request) string {
|
||||
if r == nil || !hostedModeEnabledFromEnv() {
|
||||
return ""
|
||||
}
|
||||
if v := tenantIDFromPublicURL(strings.TrimSpace(r.Host)); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tenantIDFromPublicURL(publicURL string) string {
|
||||
if publicURL == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(publicURL, "://") {
|
||||
publicURL = "https://" + publicURL
|
||||
}
|
||||
parsed, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
// Hosted tenant URLs follow "<tenant-id>.<base-domain>" and must have a
|
||||
// distinct tenant label ahead of the shared cloud domain.
|
||||
if strings.Count(host, ".") < 3 {
|
||||
return ""
|
||||
}
|
||||
tenantID := host
|
||||
if i := strings.IndexByte(host, '.'); i > 0 {
|
||||
tenantID = host[:i]
|
||||
}
|
||||
if !isValidOrganizationID(tenantID) {
|
||||
return ""
|
||||
}
|
||||
return tenantID
|
||||
}
|
||||
|
||||
func normalizeHandoffEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,9 +58,11 @@ func TestIsSQLiteUniqueViolation(t *testing.T) {
|
|||
|
||||
func TestTenantIDFromRequest(t *testing.T) {
|
||||
t.Run("uses env var when present", func(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "")
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
t.Setenv("PULSE_TENANT_ID", "env-tenant")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "host-tenant.example.com"
|
||||
req.RemoteAddr = "198.51.100.10:4567"
|
||||
|
|
@ -71,9 +73,11 @@ func TestTenantIDFromRequest(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("extracts subdomain from loopback host", func(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "")
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
t.Setenv("PULSE_TENANT_ID", "")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "tenant.example.com:8443"
|
||||
req.RemoteAddr = "127.0.0.1:8080"
|
||||
|
|
@ -84,9 +88,11 @@ func TestTenantIDFromRequest(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("returns full loopback host when no dot exists", func(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "")
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
t.Setenv("PULSE_TENANT_ID", "")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost"
|
||||
req.RemoteAddr = "127.0.0.1:8080"
|
||||
|
|
@ -97,9 +103,11 @@ func TestTenantIDFromRequest(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("extracts tenant from trusted proxy forwarded host", func(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "")
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "203.0.113.0/24")
|
||||
resetTrustedProxyConfig()
|
||||
t.Setenv("PULSE_TENANT_ID", "")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "203.0.113.10:9443"
|
||||
req.Host = "ignored.example.com"
|
||||
|
|
@ -110,10 +118,42 @@ func TestTenantIDFromRequest(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("ignores untrusted remote host header", func(t *testing.T) {
|
||||
t.Run("falls back to hosted public url when tenant env is missing", func(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "true")
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
t.Setenv("PULSE_TENANT_ID", "")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "https://tenant-from-public.cloud.pulserelay.pro")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "198.51.100.20:1234"
|
||||
req.Host = "untrusted.example.com"
|
||||
|
||||
if got := tenantIDFromRequest(req); got != "tenant-from-public" {
|
||||
t.Fatalf("tenantIDFromRequest() = %q, want %q", got, "tenant-from-public")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses hosted request host when tenant env and public url are missing", func(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "true")
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
t.Setenv("PULSE_TENANT_ID", "")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "198.51.100.20:1234"
|
||||
req.Host = "tenant-from-host.cloud.pulserelay.pro"
|
||||
|
||||
if got := tenantIDFromRequest(req); got != "tenant-from-host" {
|
||||
t.Fatalf("tenantIDFromRequest() = %q, want %q", got, "tenant-from-host")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores untrusted remote host header", func(t *testing.T) {
|
||||
t.Setenv("PULSE_HOSTED_MODE", "")
|
||||
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
||||
resetTrustedProxyConfig()
|
||||
t.Setenv("PULSE_TENANT_ID", "")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "198.51.100.20:1234"
|
||||
req.Host = "tenant.example.com"
|
||||
|
|
@ -124,6 +164,23 @@ func TestTenantIDFromRequest(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestTenantIDFromPublicURL(t *testing.T) {
|
||||
t.Run("extracts tenant from hosted public url", func(t *testing.T) {
|
||||
if got := tenantIDFromPublicURL("https://tenant-a.cloud.pulserelay.pro"); got != "tenant-a" {
|
||||
t.Fatalf("tenantIDFromPublicURL() = %q, want %q", got, "tenant-a")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid public url", func(t *testing.T) {
|
||||
if got := tenantIDFromPublicURL("://not-a-url"); got != "" {
|
||||
t.Fatalf("tenantIDFromPublicURL() = %q, want empty", got)
|
||||
}
|
||||
if got := tenantIDFromPublicURL("https://cloud.pulserelay.pro"); got != "" {
|
||||
t.Fatalf("tenantIDFromPublicURL() = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestJTIReplayStoreCheckAndStore(t *testing.T) {
|
||||
store := &jtiReplayStore{configDir: t.TempDir()}
|
||||
expires := time.Now().Add(time.Hour)
|
||||
|
|
@ -218,6 +275,12 @@ func TestJTIReplayStoreSecuresPermissionModes(t *testing.T) {
|
|||
func TestHandleHandoffExchange(t *testing.T) {
|
||||
key := []byte("test-handoff-key")
|
||||
configDir := t.TempDir()
|
||||
resetSessionStoreForTests()
|
||||
t.Cleanup(resetSessionStoreForTests)
|
||||
resetCSRFStoreForTests()
|
||||
t.Cleanup(resetCSRFStoreForTests)
|
||||
InitSessionStore(configDir)
|
||||
InitCSRFStore(configDir)
|
||||
secretsDir := filepath.Join(configDir, "secrets")
|
||||
if err := os.MkdirAll(secretsDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
|
|
@ -370,6 +433,12 @@ func TestHandleHandoffExchange(t *testing.T) {
|
|||
func TestHandleHandoffExchangeBrowserFlowSetsSessionCookies(t *testing.T) {
|
||||
key := []byte("test-handoff-key")
|
||||
configDir := t.TempDir()
|
||||
resetSessionStoreForTests()
|
||||
t.Cleanup(resetSessionStoreForTests)
|
||||
resetCSRFStoreForTests()
|
||||
t.Cleanup(resetCSRFStoreForTests)
|
||||
InitSessionStore(configDir)
|
||||
InitCSRFStore(configDir)
|
||||
secretsDir := filepath.Join(configDir, "secrets")
|
||||
if err := os.MkdirAll(secretsDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
|
|
|
|||
|
|
@ -2484,6 +2484,12 @@ func TestContract_HostedBillingStateFallbackJSONSnapshot(t *testing.T) {
|
|||
func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) {
|
||||
key := []byte("test-handoff-key")
|
||||
configDir := t.TempDir()
|
||||
resetSessionStoreForTests()
|
||||
t.Cleanup(resetSessionStoreForTests)
|
||||
resetCSRFStoreForTests()
|
||||
t.Cleanup(resetCSRFStoreForTests)
|
||||
InitSessionStore(configDir)
|
||||
InitCSRFStore(configDir)
|
||||
secretsDir := filepath.Join(configDir, "secrets")
|
||||
if err := os.MkdirAll(secretsDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
|
|
@ -2494,7 +2500,9 @@ func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) {
|
|||
|
||||
handler := HandleHandoffExchange(configDir)
|
||||
tenantID := "tenant-contract"
|
||||
t.Setenv("PULSE_HOSTED_MODE", "true")
|
||||
t.Setenv("PULSE_TENANT_ID", "")
|
||||
t.Setenv("PULSE_PUBLIC_URL", "")
|
||||
token := signHandoffToken(t, key, cloudHandoffClaims{
|
||||
AccountID: "acct-contract",
|
||||
Email: "Operator.Owner+Mixed@PulseRelay.Pro",
|
||||
|
|
@ -2511,9 +2519,7 @@ func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/api/cloud/handoff/exchange?token="+token+"&format=json", nil)
|
||||
req.Host = tenantID + ".cloud.pulserelay.pro"
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Forwarded-Host", req.Host)
|
||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
req.RemoteAddr = "198.51.100.20:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler(rec, req)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue