From 85e0de271b5efc426dd6910ec2a69e1eac6278d4 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 30 Apr 2026 12:53:29 +0100 Subject: [PATCH] Gate relay mobile tokens behind Relay license --- .../v6/internal/subsystems/agent-lifecycle.md | 6 ++++ .../v6/internal/subsystems/api-contracts.md | 7 +++++ .../v6/internal/subsystems/relay-runtime.md | 5 ++++ .../internal/subsystems/security-privacy.md | 5 ++++ .../internal/subsystems/storage-recovery.md | 5 ++++ internal/api/contract_test.go | 1 + .../api/license_limits_test_helpers_test.go | 25 ++++++++++++++++ internal/api/relay_sso_license_gating_test.go | 29 +++++++++++++++++++ internal/api/router_routes_auth_security.go | 6 +++- 9 files changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 443a3365f..914fd8440 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -213,6 +213,12 @@ profile and assignment columns, but embedded table framing must route through `policyPosture` aggregation as read-only data-governance context, but they must not reinterpret sensitivity, routing, or redaction counts as install capacity, registration eligibility, or agent assignment state. + Relay mobile credential issuance is not an agent bootstrap or lifecycle + repair path just because it lives under shared `internal/api/` routing. + `POST /api/security/tokens/relay-mobile` must remain API/security owned, + must require the paid `relay` entitlement before token minting, and must not + be reused by installer, auto-registration, assignment, or repair flows as an + alternate setup credential. When lifecycle surfaces also hydrate from `/api/state`, that first-session snapshot must carry the same canonical resource types and display names as `/api/resources` instead of briefly showing legacy host aliases before the diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 60dbf076a..7e860dc32 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -218,6 +218,13 @@ product API routes free of maintainer commercial analytics. session revocation must still delete the full session token set rather than leaving any retained replacement token valid. 48. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. + The dedicated Pulse Mobile relay token route is part of that same API + contract even though its runtime capability is Relay-owned: + `POST /api/security/tokens/relay-mobile` must pass normal admin and + `settings:write` authorization, then require the paid `relay` feature + before minting a `relay:mobile:access` credential. Community installs may + receive the standard license-required response, but direct API calls must + not bypass Relay entitlement by creating mobile runtime tokens. 49. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. 50. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. 51. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/relay-runtime.md b/docs/release-control/v6/internal/subsystems/relay-runtime.md index 11ca7e85a..1d09c62bf 100644 --- a/docs/release-control/v6/internal/subsystems/relay-runtime.md +++ b/docs/release-control/v6/internal/subsystems/relay-runtime.md @@ -116,3 +116,8 @@ surface too. The dedicated `relay:mobile:access` credential may only reach the explicit runtime route inventory in `internal/api/relay_mobile_capability.go`, and expanding that inventory is governed L7 work rather than a router-local compatibility tweak. +The route that mints that dedicated credential is also part of the paid Relay +boundary. `POST /api/security/tokens/relay-mobile` lives in the shared +auth/security router, but it must require the paid `relay` entitlement before +creating a `relay:mobile:access` token so Community installs cannot bypass +Relay/mobile gating through direct API calls. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 0b74253ac..06e7fffd7 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -92,6 +92,11 @@ controls as normal product settings. page-local `container runtime` labels. 13. `internal/api/security.go` shared with `api-contracts`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. 14. `internal/api/security_tokens.go` shared with `api-contracts`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. + Pulse Mobile relay token creation is a security token-management surface, + but it is not a free API-token convenience. After admin and + `settings:write` authorization, `POST /api/security/tokens/relay-mobile` + must fail closed with the standard license-required response unless the + active entitlement includes the paid `relay` feature. 15. `internal/api/system_settings.go` shared with `api-contracts`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. 16. `internal/cloudcp/auth/magiclink.go` shared with `cloud-paid`: control-plane magic-link HMAC handling is both a Pulse Cloud account-access boundary and a security/privacy token-secrecy boundary. 17. `internal/cloudcp/auth/magiclink_store.go` shared with `cloud-paid`: control-plane magic-link persistence is both a Pulse Cloud account-access boundary and a security/privacy storage-hardening boundary. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c1abc8296..d0611403c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -159,6 +159,11 @@ state. second generic auth body, and `/api/config/export` or `/api/config/import` bypass entries must still leave public-network and credential decisions to their route-local handlers. + That same adjacent token boundary does not make Relay mobile credentials + available to storage/recovery flows. `POST /api/security/tokens/relay-mobile` + must stay API/security and Relay-entitlement owned, require the paid `relay` + feature before minting, and must not be reused as a recovery session, + export/import bypass, or storage-local credential transport. That same adjacent API boundary also owns monitored-system admission preview transport for provider-backed setup context. `/api/truenas/connections/preview`, `/api/truenas/connections/{id}/preview`, `/api/vmware/connections/preview`, diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 29572174a..695794057 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -4144,6 +4144,7 @@ func TestContract_HostedOrgManagerSessionCanMintRelayMobileToken(t *testing.T) { } router := newMultiTenantRouter(t, cfg) + setLicenseTierForHandlersForTests(t, router.licenseHandlers, "org-a", pkglicensing.TierRelay) sessionToken := "relay-owner-session-" + strings.ReplaceAll(time.Now().UTC().Format(time.RFC3339Nano), ":", "-") GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "legacy-owner") diff --git a/internal/api/license_limits_test_helpers_test.go b/internal/api/license_limits_test_helpers_test.go index 35ac92299..8af415fe5 100644 --- a/internal/api/license_limits_test_helpers_test.go +++ b/internal/api/license_limits_test_helpers_test.go @@ -45,3 +45,28 @@ func setMaxMonitoredSystemsLicenseForTests(t *testing.T, maxMonitoredSystems int testLicenseProviderMu.Unlock() }) } + +func setLicenseTierForHandlersForTests(t *testing.T, handlers *LicenseHandlers, orgID string, tier pkglicensing.Tier) { + t.Helper() + if handlers == nil { + t.Fatal("license handlers are required") + } + ctx := context.Background() + if orgID != "" { + ctx = context.WithValue(ctx, OrgIDContextKey, orgID) + } + svc := handlers.Service(ctx) + if svc == nil { + t.Fatal("license service is required") + } + svc.SetCurrentForTesting(&pkglicensing.License{ + Claims: pkglicensing.Claims{ + LicenseID: "api-route-license-tier-test", + Email: "route-license@example.test", + Tier: tier, + IssuedAt: time.Now().Add(-time.Hour).Unix(), + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + }, + ValidatedAt: time.Now(), + }) +} diff --git a/internal/api/relay_sso_license_gating_test.go b/internal/api/relay_sso_license_gating_test.go index 297c503d8..af31010d6 100644 --- a/internal/api/relay_sso_license_gating_test.go +++ b/internal/api/relay_sso_license_gating_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) // TestRelayEndpointsRequireLicenseFeature verifies that relay settings endpoints @@ -89,6 +90,34 @@ func TestRelayOnboardingEndpointsRequireLicenseFeature(t *testing.T) { } } +func TestRelayMobileTokenEndpointRequiresLicenseFeature(t *testing.T) { + rawToken := "relay-mobile-token-license-test.12345678" + record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite, config.ScopeRelayMobileAccess}, nil) + cfg := newTestConfigWithTokens(t, record) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + handler := router.Handler() + + req := httptest.NewRequest(http.MethodPost, "/api/security/tokens/relay-mobile", strings.NewReader(`{}`)) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Fatalf("expected 402 for missing relay license, got %d: %s", rec.Code, rec.Body.String()) + } + + setLicenseTierForHandlersForTests(t, router.licenseHandlers, "", pkglicensing.TierRelay) + + req = httptest.NewRequest(http.MethodPost, "/api/security/tokens/relay-mobile", strings.NewReader(`{}`)) + req.Header.Set("X-API-Token", rawToken) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected relay-licensed token minting to succeed, got %d: %s", rec.Code, rec.Body.String()) + } +} + // TestRelayLicenseGatingResponseFormat verifies that the 402 response from // relay endpoints includes a JSON body with upgrade information. This ensures // the frontend can show an appropriate upgrade prompt. diff --git a/internal/api/router_routes_auth_security.go b/internal/api/router_routes_auth_security.go index 92c49c61f..ecad990a3 100644 --- a/internal/api/router_routes_auth_security.go +++ b/internal/api/router_routes_auth_security.go @@ -127,7 +127,11 @@ func (r *Router) registerAuthSecurityInstallRoutes() { if !ensureSettingsWriteScope(r.config, w, req) { return } - r.handleCreateRelayMobileAccessToken(w, req) + if req.Method != http.MethodPost { + r.handleCreateRelayMobileAccessToken(w, req) + return + } + RequireLicenseFeature(r.licenseHandlers, featureRelayKey, r.handleCreateRelayMobileAccessToken)(w, req) })) r.mux.HandleFunc("/api/security/tokens", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, func(w http.ResponseWriter, req *http.Request) { switch req.Method {