Gate relay mobile tokens behind Relay license

This commit is contained in:
rcourtman
2026-04-30 12:53:29 +01:00
parent f67f877f95
commit 85e0de271b
9 changed files with 88 additions and 1 deletions
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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`,
+1
View File
@@ -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")
@@ -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(),
})
}
@@ -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.
+5 -1
View File
@@ -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 {