mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Clarify hosted entitlement signing compatibility
This commit is contained in:
parent
99e65d7e68
commit
1d189d3343
11 changed files with 139 additions and 25 deletions
|
|
@ -1632,6 +1632,12 @@ self-hosted callback must also stay absent from lifecycle retry and backoff
|
|||
behavior. Lifecycle-adjacent setup and install surfaces must also treat
|
||||
`trial_eligible` and `trial_eligibility_reason` as retired compatibility
|
||||
fields, not as prompt state or setup transport state.
|
||||
Legacy-named hosted entitlement verifier plumbing under shared `internal/api/`
|
||||
is boundary-only commercial compatibility, not lifecycle setup state:
|
||||
agent-lifecycle surfaces may consume the resolved entitlement outcome, but
|
||||
must not treat `TrialActivation*` names or the retained
|
||||
`PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` literal as permission to recreate trial
|
||||
acquisition, setup retry, or install-progress prompts.
|
||||
That same shared `internal/api/` dependency also assumes session-carried OIDC
|
||||
refresh tokens stay fail-closed at rest: `session_store.go` may only persist
|
||||
or recover those tokens through encrypted-at-rest session payloads, and any
|
||||
|
|
|
|||
|
|
@ -379,6 +379,12 @@ the canonical monitored-system blocked payload.
|
|||
target before refresh, persistence, and evaluator rewiring, so tenant-
|
||||
scoped hosted routes cannot refresh against an empty non-default org while
|
||||
the machine's real hosted lease still lives on `default`.
|
||||
The hosted verifier bridge may keep the legacy
|
||||
`PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` environment literal for deployed tenant
|
||||
compatibility, but API call sites that validate hosted entitlement leases
|
||||
must route through the `HostedEntitlement*` licensing aliases rather than
|
||||
treating the retired trial-activation callback as the active acquisition
|
||||
model.
|
||||
33. Keep public demo bootstrap posture on the shared security-status contract.
|
||||
`internal/api/router_routes_auth_security.go`,
|
||||
`internal/api/security_status_capabilities.go`, frontend security-status
|
||||
|
|
|
|||
|
|
@ -1000,6 +1000,13 @@ The retired `/auth/trial-activate` return path must also stay out of the
|
|||
ordinary self-hosted router and Pro settings UI. Hosted/cloud entitlement lease
|
||||
refresh may still validate signed leases for already-approved hosted state, but
|
||||
ordinary self-hosted Pulse must not create a local trial acquisition callback.
|
||||
The remaining `TrialActivation*` verifier names and
|
||||
`PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` environment literal are boundary-only
|
||||
compatibility for hosted entitlement lease signing and already-deployed tenant
|
||||
configuration. New entitlement runtime call sites must use the
|
||||
`HostedEntitlement*` aliases unless they are explicitly proving the retired
|
||||
callback boundary, and changing the environment literal requires a separately
|
||||
governed credential rollout.
|
||||
The matching control-plane acquisition routes are retired too:
|
||||
`/start-pro-trial`, `/trial-signup/*`, and `/api/trial-signup/*` must remain
|
||||
unregistered. `/api/entitlements/refresh` remains the only hosted entitlement
|
||||
|
|
|
|||
|
|
@ -751,6 +751,12 @@ Shared licensing routes under `internal/api/` may retain legacy
|
|||
recovery surfaces must continue to consume the presentation policy instead of
|
||||
using those route names as a cue to render paid history prompts in ordinary
|
||||
self-hosted sessions.
|
||||
Legacy-named hosted entitlement verifier wiring under shared `internal/api/`
|
||||
is the same kind of boundary-only compatibility: storage and recovery surfaces
|
||||
may consume the resolved hosted entitlement, but they must not infer trial
|
||||
acquisition, restore identity, or recovery-progress state from
|
||||
`TrialActivation*` names or the retained
|
||||
`PULSE_TRIAL_ACTIVATION_PUBLIC_KEY` literal.
|
||||
That same shared boundary now also owns the one runtime-safe exception:
|
||||
storage and recovery may inherit demo-safe `/api/license/runtime-capabilities`
|
||||
reads for capability and history-retention truth, but
|
||||
|
|
|
|||
|
|
@ -5240,6 +5240,27 @@ func TestContract_HostedTenantEntitlementRefreshFallsBackToDefaultBillingState(t
|
|||
}
|
||||
}
|
||||
|
||||
func TestContract_HostedEntitlementVerifierBridgeUsesCompatibilityEnvAlias(t *testing.T) {
|
||||
if pkglicensing.HostedEntitlementPublicKeyEnvVar != pkglicensing.TrialActivationPublicKeyEnvVar {
|
||||
t.Fatalf("hosted entitlement verifier env=%q, want legacy env alias %q", pkglicensing.HostedEntitlementPublicKeyEnvVar, pkglicensing.TrialActivationPublicKeyEnvVar)
|
||||
}
|
||||
|
||||
pub, _, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
t.Setenv("PULSE_HOSTED_MODE", "true")
|
||||
t.Setenv(pkglicensing.HostedEntitlementPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
got, err := trialActivationPublicKeyFromLicensing()
|
||||
if err != nil {
|
||||
t.Fatalf("trialActivationPublicKeyFromLicensing: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, pub) {
|
||||
t.Fatal("hosted entitlement verifier bridge did not resolve the compatibility env key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_EntitlementPayloadMonitoredSystemUsageJSONSnapshot(t *testing.T) {
|
||||
payload := buildEntitlementPayloadWithUsage(&licenseStatus{
|
||||
Valid: true,
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ func parseOptionalTimeParamFromLicensing(raw string, defaultValue time.Time) (ti
|
|||
}
|
||||
|
||||
func trialActivationPublicKeyFromLicensing() (ed25519.PublicKey, error) {
|
||||
return pkglicensing.TrialActivationPublicKey()
|
||||
return pkglicensing.HostedEntitlementPublicKey()
|
||||
}
|
||||
|
||||
func signPurchaseReturnTokenFromLicensing(signingKey []byte, claims purchaseReturnClaimsModel) (string, error) {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,17 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// TrialEntitlementLeaseIssuer is the JWT issuer for hosted entitlement cache leases.
|
||||
TrialEntitlementLeaseIssuer = "pulse-pro-entitlement-lease"
|
||||
// HostedEntitlementLeaseIssuer is the JWT issuer for hosted entitlement cache leases.
|
||||
HostedEntitlementLeaseIssuer = "pulse-pro-entitlement-lease"
|
||||
|
||||
// TrialEntitlementLeaseAudience is the JWT audience for hosted entitlement cache leases.
|
||||
TrialEntitlementLeaseAudience = "pulse-pro-entitlement-cache"
|
||||
// HostedEntitlementLeaseAudience is the JWT audience for hosted entitlement cache leases.
|
||||
HostedEntitlementLeaseAudience = "pulse-pro-entitlement-cache"
|
||||
|
||||
// TrialEntitlementLeaseIssuer is the legacy exported name for hosted entitlement leases.
|
||||
TrialEntitlementLeaseIssuer = HostedEntitlementLeaseIssuer
|
||||
|
||||
// TrialEntitlementLeaseAudience is the legacy exported name for hosted entitlement leases.
|
||||
TrialEntitlementLeaseAudience = HostedEntitlementLeaseAudience
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -88,10 +94,10 @@ func SignEntitlementLeaseToken(privateKey ed25519.PrivateKey, claims Entitlement
|
|||
claims.ExpiresAt = jwt.NewNumericDate(expiresAt)
|
||||
}
|
||||
if strings.TrimSpace(claims.Issuer) == "" {
|
||||
claims.Issuer = TrialEntitlementLeaseIssuer
|
||||
claims.Issuer = HostedEntitlementLeaseIssuer
|
||||
}
|
||||
if len(claims.Audience) == 0 {
|
||||
claims.Audience = jwt.ClaimStrings{TrialEntitlementLeaseAudience}
|
||||
claims.Audience = jwt.ClaimStrings{HostedEntitlementLeaseAudience}
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
|
|
@ -127,8 +133,8 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec
|
|||
claims := &EntitlementLeaseClaims{}
|
||||
parseOpts := []jwt.ParserOption{
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}),
|
||||
jwt.WithIssuer(TrialEntitlementLeaseIssuer),
|
||||
jwt.WithAudience(TrialEntitlementLeaseAudience),
|
||||
jwt.WithIssuer(HostedEntitlementLeaseIssuer),
|
||||
jwt.WithAudience(HostedEntitlementLeaseAudience),
|
||||
jwt.WithTimeFunc(func() time.Time { return now }),
|
||||
}
|
||||
if skipTimeValidation {
|
||||
|
|
@ -162,7 +168,7 @@ func parseEntitlementLeaseToken(token string, publicKey ed25519.PublicKey, expec
|
|||
}
|
||||
expected := normalizeHost(expectedInstanceHost)
|
||||
if expected != "" && !strings.EqualFold(claims.InstanceHost, expected) {
|
||||
return nil, ErrTrialActivationHostMismatch
|
||||
return nil, ErrHostedEntitlementHostMismatch
|
||||
}
|
||||
normalizeEntitlementLeaseClaims(claims)
|
||||
return claims, nil
|
||||
|
|
@ -175,7 +181,7 @@ func ResolveEntitlementLeaseBillingState(state BillingState, expectedInstanceHos
|
|||
if token == "" {
|
||||
return normalizeTrialExpiry(state, now)
|
||||
}
|
||||
publicKey, err := TrialActivationPublicKey()
|
||||
publicKey, err := HostedEntitlementPublicKey()
|
||||
if err != nil {
|
||||
return entitlementLeaseFallbackState(state, now)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,8 +120,8 @@ func TestVerifyEntitlementLeaseTokenHostMismatch(t *testing.T) {
|
|||
}
|
||||
|
||||
_, err = VerifyEntitlementLeaseToken(token, pub, "pulse-b.example.com", time.Now())
|
||||
if !errors.Is(err, ErrTrialActivationHostMismatch) {
|
||||
t.Fatalf("VerifyEntitlementLeaseToken() error=%v, want %v", err, ErrTrialActivationHostMismatch)
|
||||
if !errors.Is(err, ErrHostedEntitlementHostMismatch) {
|
||||
t.Fatalf("VerifyEntitlementLeaseToken() error=%v, want %v", err, ErrHostedEntitlementHostMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !release
|
||||
|
||||
package licensing
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -17,10 +17,18 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// TrialActivationPublicKeyEnvVar overrides the public key used to validate
|
||||
// hosted trial activation tokens.
|
||||
// TrialActivationPublicKeyEnvVar is the legacy environment variable name
|
||||
// for the hosted entitlement signing public key. The literal value stays
|
||||
// stable for deployed-tenant compatibility; do not use it to reintroduce
|
||||
// self-hosted trial acquisition.
|
||||
TrialActivationPublicKeyEnvVar = "PULSE_TRIAL_ACTIVATION_PUBLIC_KEY"
|
||||
|
||||
// HostedEntitlementPublicKeyEnvVar is the canonical name for the hosted
|
||||
// entitlement signing public-key source. It intentionally aliases the
|
||||
// legacy environment variable until a separately governed credential
|
||||
// migration exists.
|
||||
HostedEntitlementPublicKeyEnvVar = TrialActivationPublicKeyEnvVar
|
||||
|
||||
// TrialActivationIssuer is the JWT issuer for hosted trial activation tokens.
|
||||
TrialActivationIssuer = "pulse-pro-trial-signup"
|
||||
|
||||
|
|
@ -38,10 +46,16 @@ var (
|
|||
ErrTrialActivationReturnURLMissing = errors.New("trial activation return_url is required")
|
||||
ErrTrialActivationReturnURLInvalid = errors.New("trial activation return_url is invalid")
|
||||
ErrTrialActivationHostMismatch = errors.New("trial activation token host mismatch")
|
||||
|
||||
ErrHostedEntitlementPublicKeyMissing = ErrTrialActivationPublicKeyMissing
|
||||
ErrHostedEntitlementPublicKeyInvalid = ErrTrialActivationPublicKeyInvalid
|
||||
ErrHostedEntitlementHostMismatch = ErrTrialActivationHostMismatch
|
||||
)
|
||||
|
||||
// TrialActivationClaims are signed by the hosted signup service and consumed by
|
||||
// self-hosted Pulse instances to start a Pro trial after registration/checkout.
|
||||
// TrialActivationClaims model the retired self-hosted trial-acquisition
|
||||
// callback. They remain for compatibility tests around the closed
|
||||
// /auth/trial-activate boundary; normal v6 hosted entitlement state uses
|
||||
// EntitlementLeaseClaims.
|
||||
type TrialActivationClaims struct {
|
||||
OrgID string `json:"org_id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
|
|
@ -75,16 +89,29 @@ func DecodeEd25519PrivateKey(encoded string) (ed25519.PrivateKey, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// TrialActivationPublicKey resolves the verification key for hosted trial
|
||||
// activation tokens. Environment override is only allowed in local/dev builds.
|
||||
// Release builds must use the build-time embedded verification key instead of
|
||||
// reopening that trust boundary through hosted-mode environment wiring.
|
||||
// HostedEntitlementPublicKey resolves the verification key for hosted
|
||||
// entitlement leases. It currently reads the legacy trial-activation
|
||||
// environment name for deployed-tenant compatibility. Environment override is
|
||||
// only allowed in local/dev builds. Release builds must use the build-time
|
||||
// embedded verification key instead of reopening that trust boundary through
|
||||
// hosted-mode environment wiring.
|
||||
func HostedEntitlementPublicKey() (ed25519.PublicKey, error) {
|
||||
return trialActivationPublicKey()
|
||||
}
|
||||
|
||||
// TrialActivationPublicKey resolves the same legacy verification source for
|
||||
// retired trial-activation compatibility. New hosted entitlement code should
|
||||
// call HostedEntitlementPublicKey instead.
|
||||
func TrialActivationPublicKey() (ed25519.PublicKey, error) {
|
||||
return trialActivationPublicKey()
|
||||
}
|
||||
|
||||
func trialActivationPublicKey() (ed25519.PublicKey, error) {
|
||||
if allowTrialActivationPublicKeyEnvOverride() {
|
||||
if env := strings.TrimSpace(os.Getenv(TrialActivationPublicKeyEnvVar)); env != "" {
|
||||
if env := strings.TrimSpace(os.Getenv(HostedEntitlementPublicKeyEnvVar)); env != "" {
|
||||
key, err := DecodePublicKey(env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTrialActivationPublicKeyInvalid, err)
|
||||
return nil, fmt.Errorf("%w: %v", ErrHostedEntitlementPublicKeyInvalid, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
|
@ -92,11 +119,11 @@ func TrialActivationPublicKey() (ed25519.PublicKey, error) {
|
|||
|
||||
embedded := strings.TrimSpace(EmbeddedPublicKey)
|
||||
if embedded == "" {
|
||||
return nil, ErrTrialActivationPublicKeyMissing
|
||||
return nil, ErrHostedEntitlementPublicKeyMissing
|
||||
}
|
||||
key, err := DecodePublicKey(embedded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTrialActivationPublicKeyInvalid, err)
|
||||
return nil, fmt.Errorf("%w: %v", ErrHostedEntitlementPublicKeyInvalid, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,3 +194,36 @@ func TestTrialActivationPublicKey_SelectsAllowedVerificationSource(t *testing.T)
|
|||
t.Fatalf("TrialActivationPublicKey mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostedEntitlementPublicKey_AliasesLegacyVerificationSource(t *testing.T) {
|
||||
if HostedEntitlementPublicKeyEnvVar != TrialActivationPublicKeyEnvVar {
|
||||
t.Fatalf("HostedEntitlementPublicKeyEnvVar=%q, want legacy env var %q", HostedEntitlementPublicKeyEnvVar, TrialActivationPublicKeyEnvVar)
|
||||
}
|
||||
|
||||
envPub, _, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
embeddedPub, _, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PULSE_HOSTED_MODE", "true")
|
||||
t.Setenv(HostedEntitlementPublicKeyEnvVar, base64.StdEncoding.EncodeToString(envPub))
|
||||
embeddedBefore := EmbeddedPublicKey
|
||||
t.Cleanup(func() { EmbeddedPublicKey = embeddedBefore })
|
||||
EmbeddedPublicKey = base64.StdEncoding.EncodeToString(embeddedPub)
|
||||
|
||||
got, err := HostedEntitlementPublicKey()
|
||||
if err != nil {
|
||||
t.Fatalf("HostedEntitlementPublicKey: %v", err)
|
||||
}
|
||||
want := embeddedPub
|
||||
if allowTrialActivationPublicKeyEnvOverride() {
|
||||
want = envPub
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("HostedEntitlementPublicKey mismatch")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue