Harden hosted signup and audit webhook trust paths

This commit is contained in:
rcourtman 2026-04-22 00:54:49 +01:00
parent 586473ee31
commit 513399b004
7 changed files with 110 additions and 34 deletions

View file

@ -165,6 +165,10 @@ Community limit enforcement.
through `pkg/licensing/trial_activation.go` and
`pkg/licensing/purchase_return.go`
12. Add or change hosted signup provisioning through `internal/hosted/provisioner.go`
That provisioning boundary owns owner-email idempotency for public hosted
signup. Repeated signup requests for the same owner email must resolve to
the existing tenant record instead of creating parallel orgs for one
account identity.
13. Add or change hosted billing-admin presentation through `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`, `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx`, and `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts`
14. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts`
15. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts`
@ -1448,7 +1452,12 @@ unowned hosted runtime helper.
That hosted signup boundary is now also canonical in shape: the public signup
handler owns request validation, trial billing initialization, and magic-link
issuance, while `internal/hosted/provisioner.go` owns the shared org
bootstrap/admin-role assignment and rollback path for hosted signup failures.
bootstrap/admin-role assignment, duplicate-owner-email idempotency, and
rollback path for hosted signup failures.
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.
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

@ -89,9 +89,7 @@ func AdminKeyMiddleware(adminKey string, next http.Handler) http.Handler {
// Provide a conservative default role so downstream account-mutation auth
// checks stay fail-closed on missing role context.
req := r.Clone(r.Context())
if strings.TrimSpace(req.Header.Get("X-User-Role")) == "" {
req.Header.Set("X-User-Role", string(registry.MemberRoleOwner))
}
req.Header.Set("X-User-Role", string(registry.MemberRoleOwner))
next.ServeHTTP(w, req)
})
}

View file

@ -148,7 +148,7 @@ func TestAdminKeyMiddleware(t *testing.T) {
}
})
t.Run("injects default owner role when missing", func(t *testing.T) {
t.Run("injects owner role when missing", func(t *testing.T) {
roleSeen := ""
inspect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleSeen = r.Header.Get("X-User-Role")
@ -169,7 +169,7 @@ func TestAdminKeyMiddleware(t *testing.T) {
}
})
t.Run("preserves explicit caller role header", func(t *testing.T) {
t.Run("overrides explicit caller role header", func(t *testing.T) {
roleSeen := ""
inspect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roleSeen = r.Header.Get("X-User-Role")
@ -186,8 +186,8 @@ func TestAdminKeyMiddleware(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if roleSeen != string(registry.MemberRoleAdmin) {
t.Fatalf("X-User-Role = %q, want %q", roleSeen, registry.MemberRoleAdmin)
if roleSeen != string(registry.MemberRoleOwner) {
t.Fatalf("X-User-Role = %q, want %q", roleSeen, registry.MemberRoleOwner)
}
})

View file

@ -131,21 +131,10 @@ func (p *Provisioner) ProvisionTenant(ctx context.Context, req ProvisionRequest)
return nil, err
}
orgs, err := p.persistence.ListOrganizations()
if err != nil {
return nil, &SystemError{Op: "list_organizations", Err: err}
}
for _, org := range orgs {
if org == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(org.OwnerUserID), req.Email) {
return &ProvisionResult{
OrgID: org.ID,
UserID: req.Email,
Status: ProvisionStatusExisting,
}, nil
}
if existing, err := p.findExistingOrganizationByOwnerEmail(req.Email); err != nil {
return nil, err
} else if existing != nil {
return existing, nil
}
orgID := p.newOrgID()
@ -165,9 +154,35 @@ func (p *Provisioner) ProvisionHostedSignup(ctx context.Context, req HostedSignu
return nil, err
}
if existing, err := p.findExistingOrganizationByOwnerEmail(req.Email); err != nil {
return nil, err
} else if existing != nil {
return existing, nil
}
return p.createOrganization(ctx, p.newOrgID(), req.Email, req.OrgName)
}
func (p *Provisioner) findExistingOrganizationByOwnerEmail(email string) (*ProvisionResult, error) {
orgs, err := p.persistence.ListOrganizations()
if err != nil {
return nil, &SystemError{Op: "list_organizations", Err: err}
}
for _, org := range orgs {
if org == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(org.OwnerUserID), email) {
return &ProvisionResult{
OrgID: org.ID,
UserID: strings.TrimSpace(org.OwnerUserID),
Status: ProvisionStatusExisting,
}, nil
}
}
return nil, nil
}
func (p *Provisioner) RollbackProvisioning(orgID string) {
if p == nil {
return

View file

@ -405,6 +405,58 @@ func TestProvisionHostedSignupSuccess(t *testing.T) {
}
}
func TestProvisionHostedSignupIdempotentDuplicateEmailCaseInsensitive(t *testing.T) {
baseDir := t.TempDir()
persistence := config.NewMultiTenantPersistence(baseDir)
authManager := &mockAuthManager{}
authProvider := &mockAuthProvider{manager: authManager}
provisioner := NewProvisioner(persistence, authProvider)
existingOrg := &models.Organization{
ID: "existing-org",
DisplayName: "Existing Org",
CreatedAt: time.Now().UTC(),
OwnerUserID: "owner@example.com",
Members: []models.OrganizationMember{
{
UserID: "owner@example.com",
Role: models.OrgRoleOwner,
AddedAt: time.Now().UTC(),
AddedBy: "owner@example.com",
},
},
}
if err := persistence.SaveOrganization(existingOrg); err != nil {
t.Fatalf("SaveOrganization returned error: %v", err)
}
result, err := provisioner.ProvisionHostedSignup(context.Background(), HostedSignupRequest{
Email: "Owner@Example.com",
OrgName: "Another Org",
})
if err != nil {
t.Fatalf("ProvisionHostedSignup returned error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Status != ProvisionStatusExisting {
t.Fatalf("expected status %q, got %q", ProvisionStatusExisting, result.Status)
}
if result.OrgID != "existing-org" {
t.Fatalf("expected org ID existing-org, got %q", result.OrgID)
}
if result.UserID != "owner@example.com" {
t.Fatalf("expected normalized user ID owner@example.com, got %q", result.UserID)
}
if authProvider.calls != 0 {
t.Fatalf("expected GetManager to not be called for idempotent hosted signup, got %d", authProvider.calls)
}
if authManager.calls != 0 {
t.Fatalf("expected UpdateUserRoles to not be called for idempotent hosted signup, got %d", authManager.calls)
}
}
func TestCleanupOrgDirectorySkipsUnsafePath(t *testing.T) {
baseDir := t.TempDir()
p := &Provisioner{}

View file

@ -19,6 +19,7 @@ type RestrictedOutboundHTTPOptions struct {
AllowPrivateIPs bool
AllowLoopback bool
TLSConfig *tls.Config
ResolveIPAddrs func(ctx context.Context, host string) ([]net.IPAddr, error)
}
var resolveOutboundFetchIPs = net.DefaultResolver.LookupIPAddr
@ -64,6 +65,13 @@ func validateOutboundIP(ip net.IP, opts RestrictedOutboundHTTPOptions) error {
return nil
}
func resolveOutboundIPAddrs(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) ([]net.IPAddr, error) {
if resolver := opts.ResolveIPAddrs; resolver != nil {
return resolver(ctx, host)
}
return resolveOutboundFetchIPs(ctx, host)
}
func resolvePermittedOutboundIP(ctx context.Context, host string, opts RestrictedOutboundHTTPOptions) (net.IP, error) {
host = strings.TrimSpace(host)
if host == "" {
@ -89,7 +97,7 @@ func resolvePermittedOutboundIP(ctx context.Context, host string, opts Restricte
resolveCtx, cancel := context.WithTimeout(baseCtx, 5*time.Second)
defer cancel()
addrs, err := resolveOutboundFetchIPs(resolveCtx, host)
addrs, err := resolveOutboundIPAddrs(resolveCtx, host, opts)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %s: %w", host, err)
}

View file

@ -48,16 +48,10 @@ type WebhookPayload struct {
func NewWebhookDelivery(urls []string) *WebhookDelivery {
return &WebhookDelivery{
urls: urls,
client: &http.Client{
Timeout: webhookTimeout,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
MaxConnsPerHost: 5,
MaxIdleConnsPerHost: 2,
},
},
client: securityutil.NewRestrictedOutboundHTTPClient(webhookTimeout, securityutil.RestrictedOutboundHTTPOptions{
AllowedSchemes: []string{"http", "https"},
ResolveIPAddrs: resolveWebhookIPs,
}),
queue: make(chan Event, webhookQueueSize),
stopChan: make(chan struct{}),
}