Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions licensing/composite_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package licensing

import (
"context"
)

// CompositeValidator tries an offline validator first, falling back to an HTTP
// validator. This enables air-gapped or low-latency license checks while keeping
// the HTTP validator as a fallback for online validation.
type CompositeValidator struct {
offline *OfflineValidator
http *HTTPValidator
}

// NewCompositeValidator creates a CompositeValidator from an offline and an HTTP
// validator. Either may be nil (though at least one should be non-nil).
func NewCompositeValidator(offline *OfflineValidator, http *HTTPValidator) *CompositeValidator {
return &CompositeValidator{offline: offline, http: http}
}

// Validate implements licensing.Validator. It tries the offline validator first;
// if the result is valid it is returned immediately. Otherwise it falls back to
// the HTTP validator.
func (c *CompositeValidator) Validate(ctx context.Context, key string) (*ValidationResult, error) {
if c.offline != nil {
result, err := c.offline.Validate(ctx, key)
if err == nil && result.Valid {
return result, nil
}
}
if c.http != nil {
return c.http.Validate(ctx, key)
}
return &ValidationResult{Valid: false, Error: "no validator configured"}, nil
}

// CheckFeature implements licensing.Validator. It uses the offline validator when
// available, otherwise falls back to the HTTP validator.
func (c *CompositeValidator) CheckFeature(feature string) bool {
if c.offline != nil {
return c.offline.CheckFeature(feature)
}
if c.http != nil {
return c.http.CheckFeature(feature)
}
return false
}

// GetLicenseInfo implements licensing.Validator. It returns the offline license
// info when available (non-nil), otherwise falls back to the HTTP validator.
func (c *CompositeValidator) GetLicenseInfo() *LicenseInfo {
if c.offline != nil {
if info := c.offline.GetLicenseInfo(); info != nil {
return info
}
}
if c.http != nil {
return c.http.GetLicenseInfo()
}
return nil
}

// ValidatePlugin implements plugin.LicenseValidator. It delegates to the offline
// validator, which performs the authoritative check without network calls.
func (c *CompositeValidator) ValidatePlugin(pluginName string) error {
if c.offline != nil {
return c.offline.ValidatePlugin(pluginName)
}
return nil
}
Comment on lines +63 to +70
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompositeValidator.ValidatePlugin returns nil when the offline validator is nil, which effectively approves every premium plugin in that configuration. Since plugin loading treats a nil error as licensed, this should fail closed (return an error) and/or explicitly delegate to the HTTP validator (e.g., via cached LicenseInfo) rather than silently allowing.

Copilot uses AI. Check for mistakes.

// CanLoadPlugin returns true when the offline validator permits the tier, or when
// there is no offline validator and the HTTP validator permits it.
func (c *CompositeValidator) CanLoadPlugin(tier string) bool {
if c.offline != nil {
return c.offline.CanLoadPlugin(tier)
}
if c.http != nil {
return c.http.CanLoadPlugin(tier)
}
return false
}
110 changes: 110 additions & 0 deletions licensing/offline_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package licensing

import (
"context"
"fmt"
"time"

"github.com/GoCodeAlone/workflow/pkg/license"
)

// OfflineValidator validates a license token using a local Ed25519 public key,
// with no network calls required after construction.
type OfflineValidator struct {
tokenStr string
token *license.LicenseToken
}

// NewOfflineValidator parses publicKeyPEM and tokenStr, verifies the token
// signature, and returns an OfflineValidator ready for use.
func NewOfflineValidator(publicKeyPEM []byte, tokenStr string) (*OfflineValidator, error) {
pub, err := license.UnmarshalPublicKeyPEM(publicKeyPEM)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
tok, err := license.Parse(tokenStr)
if err != nil {
return nil, fmt.Errorf("parse token: %w", err)
}
if err := tok.Verify(pub); err != nil {
return nil, fmt.Errorf("verify token signature: %w", err)
}
return &OfflineValidator{tokenStr: tokenStr, token: tok}, nil
}

// Validate implements licensing.Validator. It returns a valid result when key
// matches the stored token string, and an invalid result otherwise.
func (v *OfflineValidator) Validate(_ context.Context, key string) (*ValidationResult, error) {
if key != v.tokenStr {
return &ValidationResult{
Valid: false,
Error: "license key does not match token",
CachedUntil: time.Now().Add(DefaultCacheTTL),
}, nil
}
return &ValidationResult{
Valid: true,
License: *v.licenseInfo(),
CachedUntil: time.Now().Add(DefaultCacheTTL),
}, nil
}
Comment on lines +35 to +50
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OfflineValidator.Validate currently returns Valid=true whenever the provided key matches the stored token string, even if the token is expired. That makes an expired offline license look valid to callers that rely on ValidationResult.Valid (matching HTTPValidator behavior). Consider checking token expiration here and returning Valid=false with an appropriate Error when expired.

Copilot uses AI. Check for mistakes.

// CheckFeature implements licensing.Validator.
func (v *OfflineValidator) CheckFeature(feature string) bool {
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OfflineValidator.CheckFeature returns token.HasFeature without considering expiration. For consistency with HTTPValidator.CheckFeature (which returns false when no valid license is cached), this should likely return false when the token is expired (e.g., by using GetLicenseInfo()/IsExpired guard) so expired licenses don't continue to grant features.

Suggested change
func (v *OfflineValidator) CheckFeature(feature string) bool {
func (v *OfflineValidator) CheckFeature(feature string) bool {
if v.GetLicenseInfo() == nil {
return false
}

Copilot uses AI. Check for mistakes.
return v.token.HasFeature(feature)
}

// GetLicenseInfo implements licensing.Validator. Returns nil if the token is expired.
func (v *OfflineValidator) GetLicenseInfo() *LicenseInfo {
if v.token.IsExpired() {
return nil
}
info := v.licenseInfo()
return info
}

// ValidatePlugin implements plugin.LicenseValidator. It returns an error if the
// token is expired, the tier is not professional or enterprise, or the plugin name
// is not listed in the token's feature set.
func (v *OfflineValidator) ValidatePlugin(pluginName string) error {
if v.token.IsExpired() {
return fmt.Errorf("license token is expired")
}
if v.token.Tier != "professional" && v.token.Tier != "enterprise" {
return fmt.Errorf("license tier %q does not permit premium plugins", v.token.Tier)
}
if !v.token.HasFeature(pluginName) {
return fmt.Errorf("plugin %q is not licensed", pluginName)
}
return nil
}

// CanLoadPlugin returns true when the given plugin tier is permitted by the license.
// Core and community plugins are always allowed. Premium plugins require a
// professional or enterprise tier that is not expired.
func (v *OfflineValidator) CanLoadPlugin(tier string) bool {
switch tier {
case "core", "community":
return true
case "premium":
if v.token.IsExpired() {
return false
}
return v.token.Tier == "professional" || v.token.Tier == "enterprise"
default:
return false
}
}

// licenseInfo converts the stored token fields into a LicenseInfo struct.
func (v *OfflineValidator) licenseInfo() *LicenseInfo {
return &LicenseInfo{
Key: v.token.LicenseID,
Tier: v.token.Tier,
Organization: v.token.Organization,
ExpiresAt: time.Unix(v.token.ExpiresAt, 0),
MaxWorkflows: v.token.MaxWorkflows,
MaxPlugins: v.token.MaxPlugins,
Features: v.token.Features,
}
}
Loading
Loading