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:
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user