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:
rcourtman 2026-07-08 16:56:07 +01:00
parent eec5f7ec8c
commit e23505206d
6 changed files with 104 additions and 0 deletions

View file

@ -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

View file

@ -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),
}

View file

@ -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{

View file

@ -173,6 +173,7 @@ var grantContractJSONTags = []string{
"plan",
"feat",
"max_guests",
"max_users",
"grace_until",
"email",
"jti",

View file

@ -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

View file

@ -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{},