2
0

feat(license): enforce tier limits for secrets and tokens

Add license limit enforcement when creating secrets and tokens. Pass license limits to service layer and return appropriate errors when tier limits are exceeded. Handle limit errors in both API and web routes with proper error messages prompting users to upgrade.
This commit is contained in:
2026-01-21 15:55:29 -05:00
parent 220e04c073
commit 6790c1ea7c
7 changed files with 92 additions and 31 deletions

View File

@@ -39,7 +39,7 @@ const (
// Limits contains the feature limits for a license tier
type Limits struct {
Users int `json:"users"`
SecretsPerRepo int `json:"secrets_per_repo"` // -1 = unlimited
SecretsPerRepo int `json:"secrets_per_repo"` // -1 = unlimited
AuditRetentionDays int `json:"audit_retention_days"`
SSOEnabled bool `json:"sso_enabled"`
VersioningEnabled bool `json:"versioning_enabled"`

View File

@@ -13,7 +13,7 @@ import (
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/plugins"
"xorm.io/xorm"
)
@@ -146,10 +146,10 @@ func (p *VaultPlugin) ConfigurationError() string {
// Ensure VaultPlugin implements all required interfaces
var (
_ plugins.Plugin = (*VaultPlugin)(nil)
_ plugins.DatabasePlugin = (*VaultPlugin)(nil)
_ plugins.Plugin = (*VaultPlugin)(nil)
_ plugins.DatabasePlugin = (*VaultPlugin)(nil)
_ plugins.RepoRoutesPlugin = (*VaultPlugin)(nil)
_ plugins.LicensedPlugin = (*VaultPlugin)(nil)
_ plugins.LicensedPlugin = (*VaultPlugin)(nil)
)
// Register registers the vault plugin with GitCaddy

View File

@@ -74,7 +74,7 @@ func (p *VaultPlugin) CreateSecret(ctx context.Context, repoID int64, opts vault
Type: opts.Type,
Value: opts.Value,
CreatorID: opts.CreatorID,
})
}, nil)
if err != nil {
if err == services.ErrSecretExists {
return nil, vault_service.ErrSecretExists
@@ -209,7 +209,7 @@ func (p *VaultPlugin) CreateToken(ctx context.Context, repoID int64, opts vault_
Scope: opts.Scope,
TTL: opts.TTL,
CreatorID: opts.CreatorID,
})
}, nil)
if err != nil {
return nil, "", err
}

View File

@@ -10,10 +10,6 @@ import (
"strconv"
"strings"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
"git.marketally.com/gitcaddy/gitcaddy-vault/models"
"git.marketally.com/gitcaddy/gitcaddy-vault/services"
"code.gitcaddy.com/server/v3/models/perm"
"code.gitcaddy.com/server/v3/models/unit"
user_model "code.gitcaddy.com/server/v3/models/user"
@@ -22,6 +18,10 @@ import (
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/timeutil"
"code.gitcaddy.com/server/v3/services/context"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
"git.marketally.com/gitcaddy/gitcaddy-vault/models"
"git.marketally.com/gitcaddy/gitcaddy-vault/services"
"github.com/go-chi/chi/v5"
)
@@ -412,19 +412,28 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
if req.Name == "" {
req.Name = name
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
secret, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Value: req.Value,
CreatorID: ctx.Doer.ID,
})
}, limits)
if err != nil {
if err == services.ErrSecretExists {
ctx.JSON(http.StatusConflict, map[string]any{
"error": "already_exists",
"message": "Secret already exists",
})
} else if err == services.ErrSecretLimitReached {
ctx.JSON(http.StatusPaymentRequired, map[string]any{
"error": "limit_reached",
"message": "Secret limit reached for this tier. Upgrade your license for more secrets.",
})
} else {
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
@@ -793,13 +802,24 @@ func apiCreateToken(lic *license.Manager) http.HandlerFunc {
}
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: req.Description,
Scope: req.Scope,
TTL: ttl,
CreatorID: ctx.Doer.ID,
})
}, limits)
if err != nil {
if err == services.ErrTokenLimitReached {
ctx.JSON(http.StatusPaymentRequired, map[string]any{
"error": "limit_reached",
"message": "Token limit reached for this tier. Upgrade your license for more tokens.",
})
return
}
ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error",
"message": err.Error(),
@@ -959,7 +979,7 @@ func webListSecrets(lic *license.Manager) http.HandlerFunc {
// Check vault configuration
ctx.Data["VaultConfigured"] = crypto.HasMasterKey()
if !crypto.HasMasterKey() {
ctx.Data["VaultConfigError"] = "no master key configured"
ctx.Data["VaultConfigError"] = "no master key configured"
}
ctx.HTML(http.StatusOK, tplVaultList)
}
@@ -1145,13 +1165,17 @@ func webCreateSecret(lic *license.Manager) http.HandlerFunc {
return
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
_, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: name,
Description: description,
Type: secretType,
Value: value,
CreatorID: ctx.Doer.ID,
})
}, limits)
if err != nil {
if err == services.ErrSecretExists {
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
@@ -1162,6 +1186,14 @@ func webCreateSecret(lic *license.Manager) http.HandlerFunc {
ctx.Flash.Error(ctx.Tr("vault.error_secret_exists"))
ctx.HTML(http.StatusOK, tplVaultNew)
return
} else if err == services.ErrSecretLimitReached {
ctx.Data["Title"] = ctx.Tr("vault.new_secret")
ctx.Data["PageIsVaultSecrets"] = true
ctx.Data["name"] = name
ctx.Data["description"] = description
ctx.Flash.Error(ctx.Tr("vault.error_secret_limit"))
ctx.HTML(http.StatusOK, tplVaultNew)
return
}
ctx.ServerError("CreateSecret", err)
return
@@ -1588,13 +1620,22 @@ func webCreateToken(lic *license.Manager) http.HandlerFunc {
}
}
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: description,
Scope: scope,
TTL: ttl,
CreatorID: ctx.Doer.ID,
})
}, limits)
if err != nil {
if err == services.ErrTokenLimitReached {
ctx.Flash.Error(ctx.Tr("vault.error_token_limit"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return
}
ctx.Flash.Error(ctx.Tr("vault.error_create_token_failed"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return

View File

@@ -65,4 +65,4 @@ func ListAuditEntries(ctx context.Context, repoID int64, page, pageSize int) ([]
func CreateAuditEntry(ctx context.Context, entry *models.VaultAuditEntry) error {
_, err := db.GetEngine(ctx).Insert(entry)
return err
}
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
"git.marketally.com/gitcaddy/gitcaddy-vault/models"
"code.gitcaddy.com/server/v3/models/db"
@@ -17,11 +18,13 @@ import (
)
var (
ErrSecretNotFound = errors.New("secret not found")
ErrVersionNotFound = errors.New("version not found")
ErrSecretExists = errors.New("secret already exists")
ErrEncryptionFailed = errors.New("encryption failed")
ErrDecryptionFailed = errors.New("decryption failed")
ErrSecretNotFound = errors.New("secret not found")
ErrVersionNotFound = errors.New("version not found")
ErrSecretExists = errors.New("secret already exists")
ErrEncryptionFailed = errors.New("encryption failed")
ErrDecryptionFailed = errors.New("decryption failed")
ErrSecretLimitReached = errors.New("secret limit reached for this tier")
ErrVersionLimitReached = errors.New("version limit reached for this tier")
)
// ListSecrets returns all secrets for a repository
@@ -111,7 +114,7 @@ type CreateSecretOptions struct {
}
// CreateSecret creates a new secret
func CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions) (*models.VaultSecret, error) {
func CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions, limits *license.Limits) (*models.VaultSecret, error) {
// Check if secret already exists
existing := &models.VaultSecret{
RepoID: repoID,
@@ -124,6 +127,16 @@ func CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions) (
if has && existing.DeletedUnix == 0 {
return nil, ErrSecretExists
}
// Check secrets limit
if limits != nil && limits.SecretsPerRepo > 0 {
count, err := db.GetEngine(ctx).Where("repo_id = ? AND deleted_unix = 0", repoID).Count(&models.VaultSecret{})
if err != nil {
return nil, err
}
if int(count) >= limits.SecretsPerRepo {
return nil, ErrSecretLimitReached
}
}
// Get or create repo key
repoKey, err := GetOrCreateRepoKey(ctx, repoID)

View File

@@ -11,6 +11,7 @@ import (
"errors"
"time"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
"git.marketally.com/gitcaddy/gitcaddy-vault/models"
"code.gitcaddy.com/server/v3/models/db"
@@ -18,12 +19,13 @@ import (
)
var (
ErrTokenNotFound = errors.New("token not found")
ErrTokenRevoked = errors.New("token revoked")
ErrTokenExpired = errors.New("token expired")
ErrInvalidScope = errors.New("invalid token scope")
ErrAccessDenied = errors.New("access denied")
ErrInvalidToken = errors.New("invalid token")
ErrTokenNotFound = errors.New("token not found")
ErrTokenRevoked = errors.New("token revoked")
ErrTokenExpired = errors.New("token expired")
ErrInvalidScope = errors.New("invalid token scope")
ErrAccessDenied = errors.New("access denied")
ErrInvalidToken = errors.New("invalid token")
ErrTokenLimitReached = errors.New("token limit reached for this tier")
)
// ListTokens returns all tokens for a repository
@@ -44,7 +46,7 @@ type CreateTokenOptions struct {
}
// CreateToken creates a new CI/CD token
func CreateToken(ctx context.Context, repoID int64, opts CreateTokenOptions) (*models.VaultToken, string, error) {
func CreateToken(ctx context.Context, repoID int64, opts CreateTokenOptions, limits *license.Limits) (*models.VaultToken, string, error) {
// Parse TTL - "0" or empty means never expires
var expiresAt timeutil.TimeStamp
if opts.TTL == "" || opts.TTL == "0" || opts.TTL == "0h" {
@@ -52,11 +54,16 @@ func CreateToken(ctx context.Context, repoID int64, opts CreateTokenOptions) (*m
} else {
ttl, err := time.ParseDuration(opts.TTL)
if err != nil {
return nil, "", err
return nil, "", err
}
expiresAt = timeutil.TimeStamp(time.Now().Add(ttl).Unix())
}
// Check if CI/CD tokens are enabled for this tier
if limits != nil && !limits.CICDTokensEnabled {
return nil, "", ErrTokenLimitReached
}
// Generate random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {