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 // Limits contains the feature limits for a license tier
type Limits struct { type Limits struct {
Users int `json:"users"` 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"` AuditRetentionDays int `json:"audit_retention_days"`
SSOEnabled bool `json:"sso_enabled"` SSOEnabled bool `json:"sso_enabled"`
VersioningEnabled bool `json:"versioning_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/log"
"code.gitcaddy.com/server/v3/modules/plugins" "code.gitcaddy.com/server/v3/modules/plugins"
"xorm.io/xorm" "xorm.io/xorm"
) )
@@ -146,10 +146,10 @@ func (p *VaultPlugin) ConfigurationError() string {
// Ensure VaultPlugin implements all required interfaces // Ensure VaultPlugin implements all required interfaces
var ( var (
_ plugins.Plugin = (*VaultPlugin)(nil) _ plugins.Plugin = (*VaultPlugin)(nil)
_ plugins.DatabasePlugin = (*VaultPlugin)(nil) _ plugins.DatabasePlugin = (*VaultPlugin)(nil)
_ plugins.RepoRoutesPlugin = (*VaultPlugin)(nil) _ plugins.RepoRoutesPlugin = (*VaultPlugin)(nil)
_ plugins.LicensedPlugin = (*VaultPlugin)(nil) _ plugins.LicensedPlugin = (*VaultPlugin)(nil)
) )
// Register registers the vault plugin with GitCaddy // 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, Type: opts.Type,
Value: opts.Value, Value: opts.Value,
CreatorID: opts.CreatorID, CreatorID: opts.CreatorID,
}) }, nil)
if err != nil { if err != nil {
if err == services.ErrSecretExists { if err == services.ErrSecretExists {
return nil, vault_service.ErrSecretExists return nil, vault_service.ErrSecretExists
@@ -209,7 +209,7 @@ func (p *VaultPlugin) CreateToken(ctx context.Context, repoID int64, opts vault_
Scope: opts.Scope, Scope: opts.Scope,
TTL: opts.TTL, TTL: opts.TTL,
CreatorID: opts.CreatorID, CreatorID: opts.CreatorID,
}) }, nil)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }

View File

@@ -10,10 +10,6 @@ import (
"strconv" "strconv"
"strings" "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/perm"
"code.gitcaddy.com/server/v3/models/unit" "code.gitcaddy.com/server/v3/models/unit"
user_model "code.gitcaddy.com/server/v3/models/user" 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/templates"
"code.gitcaddy.com/server/v3/modules/timeutil" "code.gitcaddy.com/server/v3/modules/timeutil"
"code.gitcaddy.com/server/v3/services/context" "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" "github.com/go-chi/chi/v5"
) )
@@ -412,19 +412,28 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
if req.Name == "" { if req.Name == "" {
req.Name = 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{ secret, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Type: req.Type, Type: req.Type,
Value: req.Value, Value: req.Value,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
}) }, limits)
if err != nil { if err != nil {
if err == services.ErrSecretExists { if err == services.ErrSecretExists {
ctx.JSON(http.StatusConflict, map[string]any{ ctx.JSON(http.StatusConflict, map[string]any{
"error": "already_exists", "error": "already_exists",
"message": "Secret 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 { } else {
ctx.JSON(http.StatusInternalServerError, map[string]any{ ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error", "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{ token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: req.Description, Description: req.Description,
Scope: req.Scope, Scope: req.Scope,
TTL: ttl, TTL: ttl,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
}) }, limits)
if err != nil { 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{ ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": "internal_error", "error": "internal_error",
"message": err.Error(), "message": err.Error(),
@@ -959,7 +979,7 @@ func webListSecrets(lic *license.Manager) http.HandlerFunc {
// Check vault configuration // Check vault configuration
ctx.Data["VaultConfigured"] = crypto.HasMasterKey() ctx.Data["VaultConfigured"] = crypto.HasMasterKey()
if !crypto.HasMasterKey() { if !crypto.HasMasterKey() {
ctx.Data["VaultConfigError"] = "no master key configured" ctx.Data["VaultConfigError"] = "no master key configured"
} }
ctx.HTML(http.StatusOK, tplVaultList) ctx.HTML(http.StatusOK, tplVaultList)
} }
@@ -1145,13 +1165,17 @@ func webCreateSecret(lic *license.Manager) http.HandlerFunc {
return return
} }
var limits *license.Limits
if info := lic.Info(); info != nil {
limits = &info.Limits
}
_, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{ _, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: name, Name: name,
Description: description, Description: description,
Type: secretType, Type: secretType,
Value: value, Value: value,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
}) }, limits)
if err != nil { if err != nil {
if err == services.ErrSecretExists { if err == services.ErrSecretExists {
ctx.Data["Title"] = ctx.Tr("vault.new_secret") 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.Flash.Error(ctx.Tr("vault.error_secret_exists"))
ctx.HTML(http.StatusOK, tplVaultNew) ctx.HTML(http.StatusOK, tplVaultNew)
return 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) ctx.ServerError("CreateSecret", err)
return 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{ token, rawToken, err := services.CreateToken(ctx, ctx.Repo.Repository.ID, services.CreateTokenOptions{
Description: description, Description: description,
Scope: scope, Scope: scope,
TTL: ttl, TTL: ttl,
CreatorID: ctx.Doer.ID, CreatorID: ctx.Doer.ID,
}) }, limits)
if err != nil { 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.Flash.Error(ctx.Tr("vault.error_create_token_failed"))
ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens") ctx.Redirect(ctx.Repo.RepoLink + "/vault/tokens")
return 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 { func CreateAuditEntry(ctx context.Context, entry *models.VaultAuditEntry) error {
_, err := db.GetEngine(ctx).Insert(entry) _, err := db.GetEngine(ctx).Insert(entry)
return err return err
} }

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto" "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/models"
"code.gitcaddy.com/server/v3/models/db" "code.gitcaddy.com/server/v3/models/db"
@@ -17,11 +18,13 @@ import (
) )
var ( var (
ErrSecretNotFound = errors.New("secret not found") ErrSecretNotFound = errors.New("secret not found")
ErrVersionNotFound = errors.New("version not found") ErrVersionNotFound = errors.New("version not found")
ErrSecretExists = errors.New("secret already exists") ErrSecretExists = errors.New("secret already exists")
ErrEncryptionFailed = errors.New("encryption failed") ErrEncryptionFailed = errors.New("encryption failed")
ErrDecryptionFailed = errors.New("decryption 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 // ListSecrets returns all secrets for a repository
@@ -111,7 +114,7 @@ type CreateSecretOptions struct {
} }
// CreateSecret creates a new secret // 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 // Check if secret already exists
existing := &models.VaultSecret{ existing := &models.VaultSecret{
RepoID: repoID, RepoID: repoID,
@@ -124,6 +127,16 @@ func CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions) (
if has && existing.DeletedUnix == 0 { if has && existing.DeletedUnix == 0 {
return nil, ErrSecretExists 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 // Get or create repo key
repoKey, err := GetOrCreateRepoKey(ctx, repoID) repoKey, err := GetOrCreateRepoKey(ctx, repoID)

View File

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