Make hosted signup responses privacy-safe

This commit is contained in:
rcourtman 2026-04-22 07:12:56 +01:00
parent e68bdc40e2
commit ce9b89abee
11 changed files with 113 additions and 67 deletions

View file

@ -134,7 +134,10 @@ an add-only capacity posture.
the signed entitlement lease already carried in canonical billing state.
Lifecycle flows must not reintroduce anonymous bootstrap identity,
tenant-local commercial-owner surrogates, or fake activation records when
they traverse those shared handlers.
they traverse those shared handlers. They also must not infer tenant
creation, email issuance, or public-route availability from
`/api/public/signup` response codes or payload fields just because that
commercial route lives under the shared `internal/api/` tree.
That same shared quickstart boundary is vendor-neutral at the lifecycle
edge too: lifecycle-adjacent consumers may observe the stable
`quickstart:pulse-hosted` alias in AI settings payloads, but they must not

View file

@ -138,6 +138,11 @@ Own canonical runtime payload shapes between backend and frontend.
42. `internal/api/org_lifecycle_handlers.go` shared with `organization-settings`: organization lifecycle handlers are both an organization settings control surface and a canonical API payload contract boundary.
43. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
44. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
That same shared boundary also owns public hosted-signup response privacy:
syntactically valid `/api/public/signup` requests must return one generic
`202 Accepted` Pulse Account message whether provisioning/email side effects
ran or were suppressed by the owner-email limiter, while invalid bodies and
true server failures remain explicit.
45. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface.
46. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
47. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
@ -2777,6 +2782,11 @@ boundary: `internal/api/public_signup_handlers.go` owns request/response and
magic-link payload semantics, while `internal/hosted/provisioner.go` owns the
shared org bootstrap and rollback mechanics that the hosted signup handler
invokes.
That shared public-signup response contract is now intentionally uniform for
syntactically valid requests: the route returns `202 Accepted` with one generic
Pulse Account message whether provisioning/email side effects ran or were
suppressed by the owner-email rate limiter, while invalid request bodies and
true server failures remain explicit.
The API token settings surface now also follows the same explicit ownership
rule. Changes to `frontend-modern/src/components/Settings/APITokenManager.tsx`,
`frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, and

View file

@ -116,6 +116,10 @@ agreement, and cloud-specific enforcement rules.
zero-delta and removal-only TrueNAS or VMware previews as non-consuming or
capacity-freeing changes rather than warning users that a disabled connection
still grows monitored-system usage.
That same shared signup boundary also owns the public privacy floor:
syntactically valid `/api/public/signup` requests resolve to one uniform
`202 Accepted` Pulse Account response whether provisioning/email side
effects ran or were suppressed by owner-email throttling.
The real `pulse-pro` license-server legacy checkout issuance, recurring
renewals, manual issue, and legacy exchange flows are part of that same
@ -1480,6 +1484,11 @@ That duplicate-owner-email rule is fail-closed and server-owned. Repeated
hosted signup attempts for an email that already owns a tenant must resolve to
the existing org identity instead of minting a second tenant and relying on
later auth or billing surfaces to untangle the collision.
That same public signup boundary must also stay privacy-safe at the browser
edge: syntactically valid signup requests return one uniform `202 Accepted`
Pulse Account message whether the backend provisioned/sent email or suppressed
side effects due to the owner-email rate limit, so the route does not reveal
prior email usage through `201` versus `429` drift.
Hosted billing-state normalization now follows the same rule: a missing
`plan_version` must remain missing instead of being synthesized from
`subscription_state`, while explicit trial defaults remain explicit.

View file

@ -146,6 +146,10 @@ querying, and the operator-facing storage health presentation layer.
any adjacent recovery-rollup query, so hidden workload-route selectors do
not hydrate storage/recovery transport on the protected hot path.
6. Preserve API-owned node identity continuity in shared `internal/api/` helpers so storage and recovery transport attachments do not fork by hostname-versus-IP drift across the same runtime.
That same adjacent `internal/api/` boundary also keeps public hosted signup
commercial-only: storage and recovery surfaces must not infer tenant
existence, email issuance, or readiness from `/api/public/signup` response
codes or payload fields when shared backend API helpers change nearby.
7. Preserve fail-closed API assignment and lookup behavior in shared `internal/api/` helpers so storage and recovery surfaces do not inherit orphaned profile or resource references from unrelated transport mutations.
8. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows.
9. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads.

View file

@ -17,11 +17,9 @@ describe('HostedSignupAPI', () => {
vi.mocked(apiClient.fetch).mockResolvedValueOnce(
new Response(
JSON.stringify({
org_id: 'org-1',
user_id: 'owner@example.com',
message: 'Check your email.',
message: "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.",
}),
{ status: 201 },
{ status: 202 },
),
);
@ -47,11 +45,9 @@ describe('HostedSignupAPI', () => {
});
expect(result).toEqual({
ok: true,
status: 201,
status: 202,
data: {
org_id: 'org-1',
user_id: 'owner@example.com',
message: 'Check your email.',
message: "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.",
},
});
});

View file

@ -93,7 +93,7 @@ export default function HostedSignup() {
return;
}
setStatus('success');
setMessage(result.data.message || 'Check your email for a Pulse Account sign-in link.');
setMessage(result.data.message || "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.");
return;
}

View file

@ -31,9 +31,9 @@ describe('HostedSignup', () => {
requestMagicLinkMock.mockReset();
signupMock.mockResolvedValue({
ok: true,
status: 201,
status: 202,
data: {
message: 'Check your email for a Pulse Account sign-in link.',
message: "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly.",
},
});
requestMagicLinkMock.mockResolvedValue({

View file

@ -2319,9 +2319,7 @@ func TestContract_ApprovalListResponseJSONSnapshot(t *testing.T) {
func TestContract_HostedSignupResponseJSONSnapshot(t *testing.T) {
payload := hostedSignupResponse{
OrgID: "org-123",
UserID: "owner@example.com",
Message: "Check your email for a magic link to finish signing in.",
Message: hostedSignupAcceptedMessage,
}
got, err := json.Marshal(payload)
@ -2330,9 +2328,7 @@ func TestContract_HostedSignupResponseJSONSnapshot(t *testing.T) {
}
const want = `{
"org_id":"org-123",
"user_id":"owner@example.com",
"message":"Check your email for a magic link to finish signing in."
"message":"If that email can finish signup, you'll receive a Pulse Account sign-in link shortly."
}`
assertJSONSnapshot(t, got, want)

View file

@ -38,27 +38,26 @@ func issueHostedLifecycleTrialInitiationToken(t *testing.T, h *LicenseHandlers,
func TestHostedLifecycle(t *testing.T) {
t.Run("Signup_SeedsTrialBillingState_ForBillingAdmin", func(t *testing.T) {
router, _, _, _, baseDir := newHostedSignupTestRouter(t, true)
router, persistence, _, _, baseDir := newHostedSignupTestRouter(t, true)
rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusCreated, rec.Body.String())
if rec.Code != http.StatusAccepted {
t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String())
}
var signupResp struct {
OrgID string `json:"org_id"`
}
var signupResp hostedSignupResponse
if err := json.NewDecoder(rec.Body).Decode(&signupResp); err != nil {
t.Fatalf("decode signup response: %v", err)
}
if signupResp.OrgID == "" {
t.Fatal("expected signup response org_id to be populated")
if signupResp.OrgID != "" || signupResp.UserID != "" {
t.Fatalf("expected signup response to omit identifiers, got %+v", signupResp)
}
orgID := requireHostedSignupProvisionedOrgID(t, persistence, "owner@example.com")
billingStore := config.NewFileBillingStore(baseDir)
stored, err := billingStore.GetBillingState(signupResp.OrgID)
stored, err := billingStore.GetBillingState(orgID)
if err != nil {
t.Fatalf("GetBillingState(%s) failed: %v", signupResp.OrgID, err)
t.Fatalf("GetBillingState(%s) failed: %v", orgID, err)
}
if stored == nil {
t.Fatal("expected seeded billing state after hosted signup")
@ -91,8 +90,8 @@ func TestHostedLifecycle(t *testing.T) {
}
billingHandlers := NewBillingStateHandlers(billingStore, true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/orgs/"+signupResp.OrgID+"/billing-state", nil)
req.SetPathValue("id", signupResp.OrgID)
req := httptest.NewRequest(http.MethodGet, "/api/admin/orgs/"+orgID+"/billing-state", nil)
req.SetPathValue("id", orgID)
rec2 := httptest.NewRecorder()
billingHandlers.HandleGetBillingState(rec2, req)
if rec2.Code != http.StatusOK {
@ -120,34 +119,33 @@ func TestHostedLifecycle(t *testing.T) {
router, mtp, _, _, baseDir := newHostedSignupTestRouter(t, true)
rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusCreated, rec.Body.String())
if rec.Code != http.StatusAccepted {
t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String())
}
var signupResp struct {
OrgID string `json:"org_id"`
}
var signupResp hostedSignupResponse
if err := json.NewDecoder(rec.Body).Decode(&signupResp); err != nil {
t.Fatalf("decode signup response: %v", err)
}
if signupResp.OrgID == "" {
t.Fatal("expected signup response org_id to be populated")
if signupResp.OrgID != "" || signupResp.UserID != "" {
t.Fatalf("expected signup response to omit identifiers, got %+v", signupResp)
}
orgID := requireHostedSignupProvisionedOrgID(t, mtp, "owner@example.com")
// Write Pro billing state via the billing store.
billingStore := config.NewFileBillingStore(baseDir)
if err := billingStore.SaveBillingState(signupResp.OrgID, &entitlements.BillingState{
if err := billingStore.SaveBillingState(orgID, &entitlements.BillingState{
Capabilities: append([]string(nil), license.TierFeatures[license.TierPro]...),
Limits: map[string]int64{},
MetersEnabled: []string{},
PlanVersion: string(license.TierPro),
SubscriptionState: entitlements.SubStateActive,
}); err != nil {
t.Fatalf("SaveBillingState(%s) failed: %v", signupResp.OrgID, err)
t.Fatalf("SaveBillingState(%s) failed: %v", orgID, err)
}
handlers := NewLicenseHandlers(mtp, true)
ctx := context.WithValue(context.Background(), OrgIDContextKey, signupResp.OrgID)
ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID)
// GET entitlements and verify capabilities match billing state.
entReq := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)

View file

@ -19,29 +19,26 @@ func TestHostedSignupSuccess(t *testing.T) {
router, persistence, rbacProvider, emailer, _ := newHostedSignupTestRouter(t, true)
rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d: %s", rec.Code, rec.Body.String())
}
var response struct {
OrgID string `json:"org_id"`
UserID string `json:"user_id"`
Message string `json:"message"`
}
var response hostedSignupResponse
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.OrgID == "" {
t.Fatal("expected org_id to be set")
if response.OrgID != "" {
t.Fatalf("expected org_id to be omitted, got %q", response.OrgID)
}
if response.UserID != "owner@example.com" {
t.Fatalf("expected user_id owner@example.com, got %q", response.UserID)
if response.UserID != "" {
t.Fatalf("expected user_id to be omitted, got %q", response.UserID)
}
if response.Message != "Check your email for a magic link to finish signing in." {
if response.Message != hostedSignupAcceptedMessage {
t.Fatalf("unexpected message: %q", response.Message)
}
org, err := persistence.LoadOrganization(response.OrgID)
orgID := requireHostedSignupProvisionedOrgID(t, persistence, "owner@example.com")
org, err := persistence.LoadOrganization(orgID)
if err != nil {
t.Fatalf("load org: %v", err)
}
@ -52,7 +49,7 @@ func TestHostedSignupSuccess(t *testing.T) {
t.Fatalf("expected owner owner@example.com, got %q", org.OwnerUserID)
}
manager, err := rbacProvider.GetManager(response.OrgID)
manager, err := rbacProvider.GetManager(orgID)
if err != nil {
t.Fatalf("get rbac manager: %v", err)
}
@ -155,8 +152,8 @@ func TestHostedSignupRateLimit(t *testing.T) {
i,
)
rec := doHostedSignupRequest(router, body)
if i <= 5 && rec.Code != http.StatusCreated {
t.Fatalf("request %d expected 201, got %d: %s", i, rec.Code, rec.Body.String())
if i <= 5 && rec.Code != http.StatusAccepted {
t.Fatalf("request %d expected 202, got %d: %s", i, rec.Code, rec.Body.String())
}
if i == 6 && rec.Code != http.StatusTooManyRequests {
t.Fatalf("request %d expected 429, got %d: %s", i, rec.Code, rec.Body.String())
@ -182,8 +179,8 @@ func TestHostedSignupRateLimit_NoProvisioningSideEffects(t *testing.T) {
)
first := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"Org One"}`)
if first.Code != http.StatusCreated {
t.Fatalf("first request expected 201, got %d: %s", first.Code, first.Body.String())
if first.Code != http.StatusAccepted {
t.Fatalf("first request expected 202, got %d: %s", first.Code, first.Body.String())
}
orgsBefore, err := persistence.ListOrganizations()
@ -193,8 +190,20 @@ func TestHostedSignupRateLimit_NoProvisioningSideEffects(t *testing.T) {
beforeCount := len(orgsBefore)
second := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"Org Two"}`)
if second.Code != http.StatusTooManyRequests {
t.Fatalf("second request expected 429, got %d: %s", second.Code, second.Body.String())
if second.Code != http.StatusAccepted {
t.Fatalf("second request expected 202, got %d: %s", second.Code, second.Body.String())
}
var firstResp hostedSignupResponse
if err := json.NewDecoder(first.Body).Decode(&firstResp); err != nil {
t.Fatalf("decode first response: %v", err)
}
var secondResp hostedSignupResponse
if err := json.NewDecoder(second.Body).Decode(&secondResp); err != nil {
t.Fatalf("decode second response: %v", err)
}
if firstResp != secondResp {
t.Fatalf("expected uniform accepted responses, got first=%+v second=%+v", firstResp, secondResp)
}
orgsAfter, err := persistence.ListOrganizations()
@ -339,6 +348,25 @@ func doHostedSignupRequest(router *Router, body string) *httptest.ResponseRecord
return rec
}
func requireHostedSignupProvisionedOrgID(t *testing.T, persistence *config.MultiTenantPersistence, ownerEmail string) string {
t.Helper()
orgs, err := persistence.ListOrganizations()
if err != nil {
t.Fatalf("list orgs: %v", err)
}
for _, org := range orgs {
if org == nil || org.ID == "default" {
continue
}
if strings.EqualFold(org.OwnerUserID, ownerEmail) {
return org.ID
}
}
t.Fatalf("expected provisioned org for owner %q", ownerEmail)
return ""
}
func containsRoleID(items []string, target string) bool {
for _, item := range items {
if item == target {

View file

@ -15,6 +15,8 @@ import (
const hostedSignupRequestBodyLimit = 64 * 1024
const hostedSignupAcceptedMessage = "If that email can finish signup, you'll receive a Pulse Account sign-in link shortly."
type HostedRBACProvider interface {
GetManager(orgID string) (auth.ExtendedManager, error)
RemoveTenant(orgID string) error
@ -35,8 +37,8 @@ type hostedSignupRequest struct {
}
type hostedSignupResponse struct {
OrgID string `json:"org_id"`
UserID string `json:"user_id"`
OrgID string `json:"org_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Message string `json:"message"`
}
@ -103,7 +105,9 @@ func (h *HostedSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r *http
// Enforce per-email rate limit before any provisioning side effects.
if !h.magicLinks.AllowRequest(req.Email) {
writeErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Too many magic link requests. Please wait and try again.", nil)
writeJSON(w, http.StatusAccepted, hostedSignupResponse{
Message: hostedSignupAcceptedMessage,
})
return
}
@ -177,10 +181,8 @@ func (h *HostedSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r *http
hosted.GetHostedMetrics().RecordSignup()
hosted.GetHostedMetrics().RecordProvisionStatus(hosted.ProvisionMetricStatusSuccess)
writeJSON(w, http.StatusCreated, hostedSignupResponse{
OrgID: orgID,
UserID: userID,
Message: "Check your email for a magic link to finish signing in.",
writeJSON(w, http.StatusAccepted, hostedSignupResponse{
Message: hostedSignupAcceptedMessage,
})
}