mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Carry the max_users grant claim into instance license limits
GrantClaims and Claims gain a MaxUsers seat limit mirroring MaxGuests: the grant JWT claim is copied at the activation bridge and surfaced through EffectiveLimits()["max_users"], which the already-shipped user-limit enforcement (MaxUsersLimitFromLicense) reads unchanged. Inert by default: no plan sets max_users yet, so absent claim = 0 = unlimited for every existing license and grant. The self-hosted commercial volume scrub strips only max_monitored_systems and max_guests; tests now pin that max_users survives it from both the named field and explicit limits, plus the end-to-end proof that a grant carrying max_users=3 enforces 3 and a grant without it stays unlimited. Contract shape recorded in cloud-paid Extension Point 26; the grant wire contract list gains max_users against the relay-server reference (pulse-pro 95a18bd).
This commit is contained in:
parent
eec5f7ec8c
commit
e23505206d
6 changed files with 104 additions and 0 deletions
|
|
@ -1050,6 +1050,19 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`.
|
|||
in-product plan surface may reference Business before that rollout, and
|
||||
monitored-system volume stays out of the Business plan model per the
|
||||
self-hosted commercial boundary above.
|
||||
The `max_users` seat limit travels the licensing chain as one named
|
||||
shape mirroring `MaxGuests`: `Plan.MaxUsers` on the license server,
|
||||
`max_users` on the stored v6 license and in relay grant claims (all
|
||||
three copies of the grant wire struct), `GrantClaims.MaxUsers` copied
|
||||
into `Claims.MaxUsers` in `pkg/licensing`, and surfaced through
|
||||
`EffectiveLimits()["max_users"]` into the shipped user-limit
|
||||
enforcement (`MaxUsersLimitFromLicense`). It mirrors `MaxGuests` in
|
||||
shape only, not in scrub behavior: `max_users` is a seat limit, not a
|
||||
monitored-volume cap, so `stripSelfHostedCommercialVolumeCaps` and the
|
||||
license-server entitlement normalization must never strip or zero it.
|
||||
Absent claim = 0 = unlimited at every hop, which keeps all existing
|
||||
licenses, grants, and plans inert until the governed rollout sets
|
||||
`max_users` on a plan.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ type GrantClaims struct {
|
|||
PlanKey string `json:"plan"`
|
||||
Features []string `json:"feat"`
|
||||
MaxGuests int `json:"max_guests"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
GraceUntil int64 `json:"grace_until"`
|
||||
|
|
@ -95,6 +96,7 @@ func grantClaimsToClaimsWithContinuity(gc *GrantClaims, _ ActivationContinuity)
|
|||
ExpiresAt: gc.ExpiresAt,
|
||||
Features: gc.Features,
|
||||
MaxGuests: gc.MaxGuests,
|
||||
MaxUsers: gc.MaxUsers,
|
||||
PlanVersion: gc.PlanKey,
|
||||
CoreMonitoringUncapped: grantClaimsUseUncappedCoreMonitoring(gc),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ func TestGrantClaimsToClaims(t *testing.T) {
|
|||
wantEmail string
|
||||
wantFeatures []string
|
||||
wantMaxGuests int
|
||||
wantMaxUsers int
|
||||
}{
|
||||
{
|
||||
name: "active state with email",
|
||||
|
|
@ -27,6 +28,7 @@ func TestGrantClaimsToClaims(t *testing.T) {
|
|||
Email: "user@example.com",
|
||||
Features: []string{"ai_patrol", "relay"},
|
||||
MaxGuests: 5,
|
||||
MaxUsers: 3,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
||||
},
|
||||
|
|
@ -36,6 +38,7 @@ func TestGrantClaimsToClaims(t *testing.T) {
|
|||
wantEmail: "user@example.com",
|
||||
wantFeatures: []string{"ai_patrol", "relay"},
|
||||
wantMaxGuests: 5,
|
||||
wantMaxUsers: 3,
|
||||
},
|
||||
{
|
||||
name: "past_due maps to grace",
|
||||
|
|
@ -118,10 +121,55 @@ func TestGrantClaimsToClaims(t *testing.T) {
|
|||
if c.MaxGuests != tt.wantMaxGuests {
|
||||
t.Errorf("MaxGuests = %d, want %d", c.MaxGuests, tt.wantMaxGuests)
|
||||
}
|
||||
if c.MaxUsers != tt.wantMaxUsers {
|
||||
t.Errorf("MaxUsers = %d, want %d", c.MaxUsers, tt.wantMaxUsers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantMaxUsersFlowsToUserLimitEnforcement is the end-to-end instance
|
||||
// proof for the max_users seat-limit chain: a grant JWT whose payload carries
|
||||
// max_users=3 (raw server-shaped JSON, not this repo's struct tags) yields
|
||||
// MaxUsersLimitFromLicense()==3, and a grant without the claim stays 0
|
||||
// (unlimited), keeping the change inert for every existing license.
|
||||
func TestGrantMaxUsersFlowsToUserLimitEnforcement(t *testing.T) {
|
||||
t.Run("grant with max_users enforces the seat limit", func(t *testing.T) {
|
||||
jwt := makeUnsignedTestJWT(t, `{
|
||||
"lid": "lic_business_seats",
|
||||
"st": "active",
|
||||
"tier": "business",
|
||||
"plan": "price_business_annual",
|
||||
"max_users": 3
|
||||
}`)
|
||||
gc, err := parseGrantJWTUnsafe(jwt)
|
||||
if err != nil {
|
||||
t.Fatalf("parseGrantJWTUnsafe: %v", err)
|
||||
}
|
||||
lic := grantClaimsToLicense(gc, jwt)
|
||||
if got := MaxUsersLimitFromLicense(lic); got != 3 {
|
||||
t.Fatalf("MaxUsersLimitFromLicense = %d, want 3", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("grant without max_users stays unlimited", func(t *testing.T) {
|
||||
jwt := makeUnsignedTestJWT(t, `{
|
||||
"lid": "lic_pro_unlimited",
|
||||
"st": "active",
|
||||
"tier": "pro",
|
||||
"plan": "price_pro_annual"
|
||||
}`)
|
||||
gc, err := parseGrantJWTUnsafe(jwt)
|
||||
if err != nil {
|
||||
t.Fatalf("parseGrantJWTUnsafe: %v", err)
|
||||
}
|
||||
lic := grantClaimsToLicense(gc, jwt)
|
||||
if got := MaxUsersLimitFromLicense(lic); got != 0 {
|
||||
t.Fatalf("MaxUsersLimitFromLicense = %d, want 0 (unlimited)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGrantClaimsToLicense(t *testing.T) {
|
||||
t.Run("basic license from grant", func(t *testing.T) {
|
||||
gc := &GrantClaims{
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ var grantContractJSONTags = []string{
|
|||
"plan",
|
||||
"feat",
|
||||
"max_guests",
|
||||
"max_users",
|
||||
"grace_until",
|
||||
"email",
|
||||
"jti",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ type Claims struct {
|
|||
// Max guests (0 = unlimited)
|
||||
MaxGuests int `json:"max_guests,omitempty"`
|
||||
|
||||
// Max users (0 = unlimited). A seat limit, not a legacy volume cap:
|
||||
// the self-hosted commercial volume scrub never strips it.
|
||||
MaxUsers int `json:"max_users,omitempty"`
|
||||
|
||||
// Entitlement primitives (B1) - when present, these override tier-based derivation.
|
||||
// When absent (nil/empty), entitlements are derived from Tier + existing fields.
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
|
|
@ -68,6 +72,9 @@ func (c Claims) EffectiveLimits() map[string]int64 {
|
|||
if c.MaxGuests > 0 {
|
||||
limits["max_guests"] = int64(c.MaxGuests)
|
||||
}
|
||||
if c.MaxUsers > 0 {
|
||||
limits[MaxUsersLicenseGateKey] = int64(c.MaxUsers)
|
||||
}
|
||||
stripSelfHostedCommercialVolumeCaps(limits, c.EntitlementPlanVersion(), c.Tier, c.CoreMonitoringUncapped)
|
||||
}
|
||||
return limits
|
||||
|
|
|
|||
|
|
@ -88,6 +88,39 @@ func TestClaims_EffectiveLimits(t *testing.T) {
|
|||
},
|
||||
expected: map[string]int64{},
|
||||
},
|
||||
{
|
||||
name: "nil_limits_derives_from_max_users_field",
|
||||
claims: Claims{
|
||||
Limits: nil,
|
||||
MaxUsers: 3,
|
||||
},
|
||||
expected: map[string]int64{"max_users": 3},
|
||||
},
|
||||
{
|
||||
name: "zero_max_users_ignored",
|
||||
claims: Claims{
|
||||
Limits: nil,
|
||||
MaxUsers: 0,
|
||||
},
|
||||
expected: map[string]int64{},
|
||||
},
|
||||
{
|
||||
name: "max_users_survives_self_hosted_volume_cap_scrub_from_field",
|
||||
claims: Claims{
|
||||
Tier: TierPro,
|
||||
MaxGuests: 100,
|
||||
MaxUsers: 3,
|
||||
},
|
||||
expected: map[string]int64{"max_users": 3},
|
||||
},
|
||||
{
|
||||
name: "max_users_survives_self_hosted_volume_cap_scrub_from_explicit_limits",
|
||||
claims: Claims{
|
||||
Tier: TierBusiness,
|
||||
Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100, "max_users": 3},
|
||||
},
|
||||
expected: map[string]int64{"max_users": 3},
|
||||
},
|
||||
{
|
||||
name: "no_limits_returns_empty",
|
||||
claims: Claims{},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue