feat: add agent runtime hour license claims and entitlement feature (#27459)

Licenses can now carry three agent runtime hour claims:
`agent_runtime_hours_allocation`, `agent_runtime_hours_limit_soft`, and
`agent_runtime_hours_limit_hard` (unit: hours). They surface as the new
usage-period feature `agent_runtime_hours` in `GET
/api/v2/entitlements`, where `limit` carries the allocation and the new
optional `soft_limit` / `hard_limit` fields on `codersdk.Feature` carry
the thresholds.

Invalid combinations reject the entire license via `validateClaims`
(both at upload and when computing entitlements for stored licenses):
soft/hard without allocation, negative allocation, soft outside `0 <=
soft < allocation`, or `hard < allocation`.

Soft and hard limits are not comparison inputs in `Feature.Compare`;
they ride along with whichever license wins (newest `iat`, existing
behavior). None of the three claim names is a feature name, so old
servers ignore them via the existing unknown-claim tolerance, protecting
rollout of licenses minted with the new claims.

The claim name constants defined in `enterprise/coderd/license` are the
canonical contract for `github.com/coder/license` (X1).

Part of
[CODAGT-837](https://linear.app/codercom/issue/CODAGT-837/a1-agent-runtime-license-claims-and-entitlement-feature).
Blocks B4 (usage wiring + warnings), C1 (hard-limit admission gate), F1
(licenses page), A4 (managed-agent coexistence), X1 (licensor).

Out of scope, handled by follow-up issues: `Actual` usage wiring,
threshold warnings, admission gating, premium defaults, and FE surfacing
beyond regenerated types.

<details>
<summary>Implementation plan and decision log</summary>

## Decisions (confirmed by jaayden, 2026-07-23)

1. **Claim names / unit:**
   - `agent_runtime_hours_allocation` - allocation (unit: hours, int64)
   - `agent_runtime_hours_limit_soft` - soft limit
   - `agent_runtime_hours_limit_hard` - hard limit
- None of the three claim names is itself a `FeatureName`; all three map
to the single new usage-period feature `agent_runtime_hours`
(`FeatureAgentRuntimeHours`), mirroring how `managed_agent_limit_soft`
mapped onto `managed_agent_limit`. Old servers therefore ignore all
three claims via the `FeatureNamesMap` check.
2. **Reject-license.** Invalid claim combinations reject the whole
license via `validateClaims` (upload returns 400 via
`ParseClaimsIgnoreNbf`; already-stored licenses produce an `Invalid
license ... parsing claims` entitlements error and contribute nothing).

## Design notes

- `codersdk.Feature` had a `SoftLimit` field until 051ed34580 ("feat:
convert soft_limit to limit", #22048) collapsed managed-agent soft/hard
into a single `limit`. This reintroduces soft/hard as optional fields
without changing managed-agent behavior.
- Existing usage-period machinery populates `UsagePeriod` from
`nbf`/`exp` (`usagePeriodStart`/`usagePeriodEnd` in
`LicensesEntitlements`); reused unchanged, consistent with managed
agents.
- `Entitlements.AddFeature` replaces whole `Feature` structs (no
merging), so soft/hard automatically ride along with the winning
license. No `Feature.Compare` logic change; doc updates plus tests pin
that soft/hard are not comparison inputs.
- The feature name itself is not accepted as a claim; the allocation
must come from the dedicated claim so it is validated against soft/hard
(prevents a validation bypass where a direct feature-name claim could
win precedence with unvalidated thresholds).
- The generic "enabled but not entitled/expired" warning loop skips the
feature, mirroring `FeatureManagedAgentLimit`; usage-based warnings
arrive with B4.
- No premium default for this feature (unlike managed agents).

## Changes

1. `codersdk/deployment.go`: new `FeatureAgentRuntimeHours` (in
`FeatureNames`, `UsesLimit()`, `UsesUsagePeriod()`, keeping it out of
`FeatureSet` expansion); `Feature.SoftLimit`/`Feature.HardLimit`
(`soft_limit`/`hard_limit`, omitempty); doc updates for `UsagePeriod`
and `Compare`.
2. `enterprise/coderd/license/license.go`: canonical claim constants;
validation helper called from `validateClaims`; al-la-carte loop maps
the allocation claim to the feature and attaches soft/hard from the
companion claims; skips for the companion claims and the raw feature
name; generic warning loop skip.
3. `enterprise/coderd/coderdenttest`: `AgentRuntimeHours(allocation)`
builder.
4. Tests:
- `TestAgentRuntimeHoursLicenses`: entitled/grace round-trips (including
JSON field assertions), allocation-only, explicit zero,
`IssuedAtRanking` mirror, soft/hard ride-along with a newer
allocation-only license, direct feature-name claim ignored,
unknown-claims compatibility (old-server simulation).
- `TestAgentRuntimeHoursClaimValidation`: table of valid/invalid claim
combinations against `ParseClaims`, plus stored-license entitlements
error.
- `TestPostLicense`: API-level 400 rejection and a happy-path POST +
`GET /api/v2/entitlements` round-trip.
- `TestFeatureComparison`: soft/hard ignored in comparison; newest `iat`
wins over larger soft/hard.
5. `make gen`: regenerated `site/src/api/typesGenerated.ts`,
`coderd/apidoc/*`, `docs/reference/api/*`.

## Verification

- `go test ./enterprise/coderd/license/ ./codersdk/` and `go test
./enterprise/coderd/ -run 'TestPostLicense|TestEntitlements'` pass.
- `golangci-lint` clean on changed packages; `make lint/emdash` clean;
FE `tsc --noEmit` clean.
- Independent agent review of the diff found no blockers; its minor
findings (direct feature-name claim validation bypass, precedence test
gap, missing API happy-path test) were addressed.

</details>

> [!NOTE]
> Generated by Coder Agents on behalf of @jaaydenh (Linear CODAGT-837
agent session).
This commit is contained in:
Jaayden Halko
2026-07-29 07:58:30 +01:00
committed by GitHub
parent d072aa7bd0
commit 06ceb4253d
10 changed files with 842 additions and 10 deletions
+126
View File
@@ -508,6 +508,16 @@ func LicensesEntitlements(
continue
}
// Agent runtime hours are encoded as up to three claims and are
// decoded together after this loop, see
// decodeAgentRuntimeHours. The feature name itself is never a
// valid claim. The allocation must come from the dedicated claim
// so it is validated against the soft and hard limits.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
}
if featureValue < 0 {
// We currently don't use negative values for features.
continue
@@ -567,6 +577,16 @@ func LicensesEntitlements(
}
}
// The loop above skips Agent runtime hours because the
// three claims that encode them decode into a single feature.
if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
}); ok {
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
licenseHasAIGovernanceAddon := false
@@ -790,6 +810,11 @@ func LicensesEntitlements(
if featureName == codersdk.FeatureManagedAgentLimit {
continue
}
// Agent runtime hours is a usage period feature and does not
// generate generic entitlement warnings.
if featureName == codersdk.FeatureAgentRuntimeHours {
continue
}
feature := entitlements.Features[featureName]
if !feature.Enabled {
@@ -860,6 +885,26 @@ const (
VersionClaim = "version"
)
// Agent runtime hour license claims. These are the canonical claim names
// minted by github.com/coder/license. All three claims map to the single
// codersdk.FeatureAgentRuntimeHours feature and are validated together when
// the license is parsed, see validateClaims.
//
// The unit for all three claims is hours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
// allocation for the license term. It becomes the feature's Limit.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// must satisfy 0 <= soft < allocation, so it may only be set when the
// allocation is greater than 0. It becomes the feature's SoftLimit.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
// absent or >= allocation, and may only be set when the allocation is
// greater than 0. It becomes the feature's HardLimit.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
var (
ValidMethods = []string{"EdDSA"}
@@ -872,10 +917,88 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
// claims are decoded together, see decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
ClaimAgentRuntimeHoursLimitSoft,
ClaimAgentRuntimeHoursLimitHard:
return true
default:
return false
}
}
// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
// from the claims that encode it. It reports false when the license carries no
// allocation claim, in which case the license does not grant the feature.
//
// The claim combination is validated when the license is parsed, see
// Features.validateAgentRuntimeHours. The allocation is never negative here
// and the soft and hard limits are only present alongside a positive
// allocation.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
if !ok {
return codersdk.Feature{}, false
}
feature := codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
feature.SoftLimit = &soft
}
if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
feature.HardLimit = &hard
}
return feature, true
}
// validateAgentRuntimeHours validates the relationship between the agent
// runtime hour claims. Invalid combinations reject the entire license.
func (f Features) validateAgentRuntimeHours() error {
allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
if !hasAllocation {
if hasSoft || hasHard {
return ErrMissingAgentRuntimeHoursAllocation
}
return nil
}
if allocation < 0 {
return ErrInvalidAgentRuntimeHoursAllocation
}
// A zero allocation disables the feature.
// A zero hard limit is not permitted.
if allocation == 0 && (hasSoft || hasHard) {
return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
if hasSoft && (soft < 0 || soft >= allocation) {
return ErrInvalidAgentRuntimeHoursSoftLimit
}
if hasHard && hard < allocation {
return ErrInvalidAgentRuntimeHoursHardLimit
}
return nil
}
// Claims is the full set of claims in a license.
type Claims struct {
jwt.RegisteredClaims
@@ -966,6 +1089,9 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
if err := claims.Features.validateAgentRuntimeHours(); err != nil {
return nil, err
}
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
+559
View File
@@ -2,6 +2,7 @@ package license_test
import (
"context"
"encoding/json"
"fmt"
"slices"
"testing"
@@ -87,6 +88,10 @@ func TestEntitlements(t *testing.T) {
f[codersdk.FeatureManagedAgentLimit] = 100
continue
}
if name == codersdk.FeatureAgentRuntimeHours {
f[license.ClaimAgentRuntimeHoursAllocation] = 100
continue
}
f[name] = 1
}
return f
@@ -381,6 +386,7 @@ func TestEntitlements(t *testing.T) {
featureName == codersdk.FeatureHighAvailability ||
featureName == codersdk.FeatureMultipleExternalAuth ||
featureName == codersdk.FeatureManagedAgentLimit ||
featureName == codersdk.FeatureAgentRuntimeHours ||
featureName == codersdk.FeatureAIGovernanceUserLimit ||
featureName == codersdk.FeatureBoundary {
// These fields don't generate warnings when not entitled unless
@@ -394,6 +400,12 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
require.Contains(t, entitlements.Warnings, fmt.Sprintf("%s is enabled but your license is not entitled to this feature.", niceName))
}
// Agent runtime hours is enabled by `all` and not granted by this
// license, which is exactly the state the warning suppression covers.
require.NotContains(t, entitlements.Warnings, fmt.Sprintf(
"%s is enabled but your license is not entitled to this feature.",
codersdk.FeatureAgentRuntimeHours.Humanize(),
))
})
t.Run("TooManyUsers", func(t *testing.T) {
t.Parallel()
@@ -2146,6 +2158,553 @@ func TestManagedAgentLimitDefault(t *testing.T) {
})
}
// TestAgentRuntimeHoursLicenses ensures licenses carrying the agent runtime
// hour claims (allocation, soft limit, hard limit) surface as the single
// agent_runtime_hours feature.
func TestAgentRuntimeHoursLicenses(t *testing.T) {
t.Parallel()
t.Run("AllClaims", func(t *testing.T) {
t.Parallel()
licIat := time.Now().Add(-time.Minute)
licNbf := licIat.Add(-time.Minute)
licExp := licIat.Add(time.Hour)
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: licExp,
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: licIat,
NotBefore: licNbf,
ExpiresAt: licExp,
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 80, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.Nil(t, feature.Actual)
require.NotNil(t, feature.UsagePeriod)
require.WithinDuration(t, licIat, feature.UsagePeriod.IssuedAt, 2*time.Second)
require.WithinDuration(t, licNbf, feature.UsagePeriod.Start, 2*time.Second)
require.WithinDuration(t, licExp, feature.UsagePeriod.End, 2*time.Second)
// The feature round-trips into the entitlements JSON served by
// GET /api/v2/entitlements with all four fields.
data, err := json.Marshal(entitlements)
require.NoError(t, err)
var raw struct {
Features map[codersdk.FeatureName]map[string]any `json:"features"`
}
require.NoError(t, json.Unmarshal(data, &raw))
rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
require.EqualValues(t, 100, rawFeature["limit"])
require.EqualValues(t, 80, rawFeature["soft_limit"])
require.EqualValues(t, 120, rawFeature["hard_limit"])
require.Contains(t, rawFeature, "usage_period")
})
t.Run("GracePeriod", func(t *testing.T) {
t.Parallel()
now := time.Now()
opts := coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}
opts.GracePeriod(now)
lic := database.License{
ID: 1,
UploadedAt: now,
Exp: now.Add(time.Hour * 24),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, opts),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), now, []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 80, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
})
t.Run("AllocationOnly", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.Nil(t, feature.SoftLimit)
require.Nil(t, feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
})
// A license with an explicit zero allocation is entitled but disabled,
// mirroring the managed agent limit behavior.
t.Run("ExplicitZero", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.False(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 0, *feature.Limit)
require.NotNil(t, feature.UsagePeriod)
})
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
// Mirrors TestUsageLimitFeatures/IssuedAtRanking.
t.Run("IssuedAtRanking", func(t *testing.T) {
t.Parallel()
lic1 := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: time.Now().Add(-time.Minute * 2),
NotBefore: time.Now().Add(-time.Minute * 2),
ExpiresAt: time.Now().Add(time.Hour * 2),
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}),
}
lic2Iat := time.Now().Add(-time.Minute * 1)
lic2Nbf := lic2Iat.Add(-time.Minute)
lic2Exp := lic2Iat.Add(time.Hour)
lic2 := database.License{
ID: 2,
UploadedAt: time.Now(),
Exp: lic2Exp,
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: lic2Iat,
NotBefore: lic2Nbf,
ExpiresAt: lic2Exp,
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 50,
license.ClaimAgentRuntimeHoursLimitSoft: 40,
license.ClaimAgentRuntimeHoursLimitHard: 60,
},
}),
}
// Load the licenses in both orders to ensure the correct
// behavior is observed no matter the order.
for _, order := range [][]database.License{
{lic1, lic2},
{lic2, lic1},
} {
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 50, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 40, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 60, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
require.WithinDuration(t, lic2Iat, feature.UsagePeriod.IssuedAt, 2*time.Second)
require.WithinDuration(t, lic2Nbf, feature.UsagePeriod.Start, 2*time.Second)
require.WithinDuration(t, lic2Exp, feature.UsagePeriod.End, 2*time.Second)
}
})
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
t.Parallel()
lic1 := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: time.Now().Add(-time.Minute * 2),
NotBefore: time.Now().Add(-time.Minute * 2),
ExpiresAt: time.Now().Add(time.Hour * 2),
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}),
}
lic2Iat := time.Now().Add(-time.Minute * 1)
lic2 := database.License{
ID: 2,
UploadedAt: time.Now(),
Exp: lic2Iat.Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: lic2Iat,
NotBefore: lic2Iat.Add(-time.Minute),
ExpiresAt: lic2Iat.Add(time.Hour),
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 50,
},
}),
}
for _, order := range [][]database.License{
{lic1, lic2},
{lic2, lic1},
} {
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 50, *feature.Limit)
require.Nil(t, feature.SoftLimit)
require.Nil(t, feature.HardLimit)
}
})
// The feature name itself is not a valid claim; the allocation must come
// from the dedicated claim.
t.Run("DirectFeatureNameClaimIgnored", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAgentRuntimeHours: 100,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Nil(t, feature.Limit)
})
// The rollout guarantee for old deployments is that none of the three
// claim names is itself a feature name, so an old server ignores them as
// unknown claims. Pin the invariant so a future feature registration
// cannot break it silently.
t.Run("ClaimNamesAreNotFeatureNames", func(t *testing.T) {
t.Parallel()
for _, claim := range []string{
license.ClaimAgentRuntimeHoursAllocation,
license.ClaimAgentRuntimeHoursLimitSoft,
license.ClaimAgentRuntimeHoursLimitHard,
} {
require.NotContains(t, codersdk.FeatureNamesMap, codersdk.FeatureName(claim))
}
})
// Ensures licenses carrying claims for features this server version does
// not know about do not break entitlement computation. This is exactly
// what old deployments see when a license carries the agent runtime hour
// claims, since none of the three claim names is a feature name.
t.Run("UnknownClaimsCompatibility", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureName("future_feature_allocation"): 100,
codersdk.FeatureName("future_feature_limit_soft"): 80,
codersdk.FeatureName("future_feature_limit_hard"): 120,
codersdk.FeatureName("future_boolean_feature"): 1,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
require.True(t, entitlements.HasLicense)
// The unknown claims are silently ignored and the known claim is
// still entitled.
require.NotContains(t, entitlements.Features, codersdk.FeatureName("future_feature_allocation"))
require.NotContains(t, entitlements.Features, codersdk.FeatureName("future_boolean_feature"))
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
require.NotNil(t, userLimit.Limit)
require.EqualValues(t, 100, *userLimit.Limit)
})
}
// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
// agent runtime hour claims reject the entire license.
func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
features license.Features
expectedErr error
}{
{
name: "AllClaims",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
},
{
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
},
{
name: "HardEqualsAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitHard: 100,
},
},
{
name: "ZeroAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
},
},
{
name: "ZeroAllocationWithZeroHard",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
license.ClaimAgentRuntimeHoursLimitHard: 0,
},
expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
name: "ZeroAllocationWithPositiveHard",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
license.ClaimAgentRuntimeHoursLimitHard: 1000,
},
expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
name: "SoftWithoutAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
name: "HardWithoutAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
name: "NegativeAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: -1,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
},
{
name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: -1,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
},
{
name: "SoftEqualsAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
},
{
name: "SoftAboveAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
},
{
name: "SoftWithZeroAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
name: "HardBelowAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitHard: 99,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: tc.features,
})
_, err := license.ParseClaims(jwt, coderdenttest.Keys)
if tc.expectedErr == nil {
require.NoError(t, err)
return
}
require.ErrorIs(t, err, tc.expectedErr)
})
}
// An invalid license already stored in the database is rejected entirely
// and produces an entitlements error.
t.Run("EntitlementsError", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Len(t, entitlements.Errors, 1)
require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
require.False(t, entitlements.HasLicense)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
})
}
func TestAIGovernanceAddon(t *testing.T) {
t.Parallel()
+45
View File
@@ -105,6 +105,51 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
// A soft limit claim without an allocation claim rejects the whole
// license.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
errResp := &codersdk.Error{}
require.ErrorAs(t, err, &errResp)
require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
require.Contains(t, errResp.Message, "Invalid license")
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
})
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
require.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 80, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
})
t.Run("Unauthorized", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})