mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 07:54:10 +00:00
Make hosted signup responses privacy-safe
This commit is contained in:
parent
e68bdc40e2
commit
ce9b89abee
11 changed files with 113 additions and 67 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue