Adds dedicated error page and warnings for vault encryption key problems including missing configuration, fallback key usage, and decryption failures. Displays context-specific messages to help users understand and fix key configuration issues. Includes detection of crypto errors in vault operations and graceful error handling throughout the UI.
559 lines
15 KiB
Go
559 lines
15 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package vault
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"code.gitcaddy.com/server/v3/modules/plugins"
|
|
)
|
|
|
|
var (
|
|
ErrVaultNotAvailable = errors.New("vault plugin not available")
|
|
ErrVaultNotLicensed = errors.New("vault plugin not licensed")
|
|
ErrVaultNotConfigured = errors.New("vault master key not configured")
|
|
ErrSecretNotFound = errors.New("secret not found")
|
|
ErrSecretExists = errors.New("secret already exists")
|
|
ErrSecretLimitReached = errors.New("secret limit reached for current license tier")
|
|
ErrFeatureNotInTier = errors.New("feature not available in current license tier")
|
|
ErrTokenNotFound = errors.New("token not found")
|
|
ErrTokenExpired = errors.New("token expired")
|
|
ErrTokenLimitReached = errors.New("token limit reached for current license tier")
|
|
ErrTokenTTLExceeded = errors.New("token TTL exceeds maximum for current license tier")
|
|
ErrInvalidToken = errors.New("invalid token")
|
|
ErrInvalidScope = errors.New("invalid token scope")
|
|
ErrAccessDenied = errors.New("access denied")
|
|
)
|
|
|
|
// Plugin defines the interface that vault plugins must implement
|
|
type Plugin interface {
|
|
plugins.Plugin
|
|
plugins.LicensedPlugin
|
|
|
|
// Secret operations
|
|
ListSecrets(ctx context.Context, repoID int64, includeDeleted bool) ([]Secret, error)
|
|
GetSecret(ctx context.Context, repoID int64, name string) (*Secret, error)
|
|
GetSecretValue(ctx context.Context, repoID int64, name string, version int) (string, error)
|
|
CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions) (*Secret, error)
|
|
UpdateSecret(ctx context.Context, repoID int64, name string, opts UpdateSecretOptions) (*Secret, error)
|
|
DeleteSecret(ctx context.Context, repoID int64, name string, userID int64) error
|
|
RestoreSecret(ctx context.Context, repoID int64, name string) error
|
|
RollbackSecret(ctx context.Context, repoID int64, name string, version int, userID int64) error
|
|
|
|
// Version operations
|
|
ListVersions(ctx context.Context, repoID int64, name string) ([]SecretVersion, error)
|
|
|
|
// Token operations
|
|
ListTokens(ctx context.Context, repoID int64) ([]Token, error)
|
|
CreateToken(ctx context.Context, repoID int64, opts CreateTokenOptions) (*Token, string, error) // returns token and raw value
|
|
RevokeToken(ctx context.Context, repoID, tokenID int64) error
|
|
ValidateToken(ctx context.Context, rawToken, action, secretName string) (*Token, error)
|
|
|
|
// Audit operations
|
|
ListAuditEntries(ctx context.Context, repoID int64, page, pageSize int) ([]AuditEntry, int64, error)
|
|
}
|
|
|
|
// ConfigurablePlugin is an optional interface that vault plugins can implement
|
|
// to report their configuration status
|
|
type ConfigurablePlugin interface {
|
|
// IsConfigured returns true if the plugin is properly configured (e.g., has master key)
|
|
IsConfigured() bool
|
|
// ConfigurationError returns the configuration error message, if any
|
|
ConfigurationError() string
|
|
// IsUsingFallbackKey returns true if the vault is using Gitea's SECRET_KEY as
|
|
// the encryption key instead of an explicit vault-specific key.
|
|
IsUsingFallbackKey() bool
|
|
// KeySource returns a human-readable description of where the master key was loaded from.
|
|
KeySource() string
|
|
}
|
|
|
|
// Secret represents a vault secret
|
|
type Secret struct {
|
|
ID int64
|
|
RepoID int64
|
|
Name string
|
|
Description string
|
|
Type string
|
|
CurrentVersion int
|
|
CreatedUnix int64
|
|
UpdatedUnix int64
|
|
DeletedUnix int64
|
|
}
|
|
|
|
// IsDeleted returns true if the secret is soft deleted
|
|
func (s Secret) IsDeleted() bool {
|
|
return s.DeletedUnix > 0
|
|
}
|
|
|
|
// SecretVersion represents a version of a secret
|
|
type SecretVersion struct {
|
|
ID int64
|
|
SecretID int64
|
|
Version int
|
|
CreatorID int64
|
|
CreatorName string
|
|
Comment string
|
|
CreatedUnix int64
|
|
}
|
|
|
|
// Token represents a CI/CD token
|
|
type Token struct {
|
|
ID int64
|
|
RepoID int64
|
|
Description string
|
|
Scope string
|
|
CreatedUnix int64
|
|
ExpiresUnix int64
|
|
LastUsedUnix int64
|
|
UsedCount int64
|
|
IsRevoked bool
|
|
IsExpired bool
|
|
}
|
|
|
|
// AuditEntry represents an audit log entry
|
|
type AuditEntry struct {
|
|
ID int64
|
|
RepoID int64
|
|
SecretName string
|
|
Action string
|
|
UserID int64
|
|
UserName string
|
|
IPAddress string
|
|
Success bool
|
|
FailReason string
|
|
Timestamp int64
|
|
}
|
|
|
|
// CreateSecretOptions contains options for creating a secret
|
|
type CreateSecretOptions struct {
|
|
Name string
|
|
Description string
|
|
Type string
|
|
Value string
|
|
CreatorID int64
|
|
}
|
|
|
|
// UpdateSecretOptions contains options for updating a secret
|
|
type UpdateSecretOptions struct {
|
|
Type string
|
|
Value string
|
|
Comment string
|
|
UpdaterID int64
|
|
}
|
|
|
|
// CreateTokenOptions contains options for creating a token
|
|
type CreateTokenOptions struct {
|
|
Description string
|
|
Scope string
|
|
TTL string // e.g., "1h", "24h", "168h"
|
|
CreatorID int64
|
|
}
|
|
|
|
// GetPlugin returns the registered vault plugin or nil
|
|
func GetPlugin() Plugin {
|
|
p := plugins.Get("vault")
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
vp, ok := p.(Plugin)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return vp
|
|
}
|
|
|
|
// IsAvailable returns true if the vault plugin is registered
|
|
func IsAvailable() bool {
|
|
return GetPlugin() != nil
|
|
}
|
|
|
|
// IsLicensed returns true if the vault plugin is licensed
|
|
// Note: This always returns true if the plugin is available because
|
|
// we default to Solo tier (free) when no license is present
|
|
func IsLicensed() bool {
|
|
return plugins.IsLicensed("vault")
|
|
}
|
|
|
|
// IsConfigured returns true if the vault plugin is properly configured
|
|
// (e.g., has a master key set). Returns true if plugin doesn't implement
|
|
// ConfigurablePlugin interface (assumes configured).
|
|
func IsConfigured() bool {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return false
|
|
}
|
|
if cp, ok := vp.(ConfigurablePlugin); ok {
|
|
return cp.IsConfigured()
|
|
}
|
|
return true // Assume configured if plugin doesn't implement interface
|
|
}
|
|
|
|
// GetConfigurationError returns the configuration error message if the
|
|
// vault plugin is not properly configured. Returns empty string if configured
|
|
// or if the plugin doesn't implement ConfigurablePlugin interface.
|
|
func GetConfigurationError() string {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return "vault plugin not available"
|
|
}
|
|
if cp, ok := vp.(ConfigurablePlugin); ok {
|
|
return cp.ConfigurationError()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsUsingFallbackKey returns true if the vault is using Gitea's SECRET_KEY
|
|
// as the encryption key instead of an explicit vault-specific key.
|
|
// Returns false if the plugin doesn't implement the ConfigurablePlugin interface.
|
|
func IsUsingFallbackKey() bool {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return false
|
|
}
|
|
if cp, ok := vp.(ConfigurablePlugin); ok {
|
|
return cp.IsUsingFallbackKey()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// KeySource returns a human-readable description of where the master key was loaded from.
|
|
// Returns empty string if the plugin doesn't implement the ConfigurablePlugin interface.
|
|
func KeySource() string {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return ""
|
|
}
|
|
if cp, ok := vp.(ConfigurablePlugin); ok {
|
|
return cp.KeySource()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetLicenseInfo returns the license info for the vault plugin
|
|
// Returns default Solo license if no license file is present
|
|
func GetLicenseInfo() *plugins.LicenseInfo {
|
|
return plugins.GetLicenseInfo("vault")
|
|
}
|
|
|
|
// GetLimits returns the license limits for the vault plugin
|
|
func GetLimits() *plugins.LicenseLimits {
|
|
return plugins.GetLicenseLimits("vault")
|
|
}
|
|
|
|
// GetTier returns the current license tier (defaults to "solo")
|
|
func GetTier() string {
|
|
info := GetLicenseInfo()
|
|
if info == nil {
|
|
return plugins.TierSolo
|
|
}
|
|
return info.Tier
|
|
}
|
|
|
|
// CanUseVersioning returns true if the current tier supports versioning
|
|
func CanUseVersioning() bool {
|
|
limits := GetLimits()
|
|
return limits != nil && limits.VersioningEnabled
|
|
}
|
|
|
|
// CanUseCICDTokens returns true if the current tier supports CI/CD tokens
|
|
func CanUseCICDTokens() bool {
|
|
limits := GetLimits()
|
|
return limits != nil && limits.CICDTokensEnabled
|
|
}
|
|
|
|
// CanUseSSO returns true if the current tier supports SSO
|
|
func CanUseSSO() bool {
|
|
limits := GetLimits()
|
|
return limits != nil && limits.SSOEnabled
|
|
}
|
|
|
|
// GetMaxSecretsPerRepo returns the max secrets allowed per repo (-1 = unlimited)
|
|
func GetMaxSecretsPerRepo() int {
|
|
limits := GetLimits()
|
|
if limits == nil {
|
|
return 5 // Solo default
|
|
}
|
|
return limits.SecretsPerRepo
|
|
}
|
|
|
|
// GetAuditRetentionDays returns the audit log retention days
|
|
func GetAuditRetentionDays() int {
|
|
limits := GetLimits()
|
|
if limits == nil {
|
|
return 7 // Solo default
|
|
}
|
|
return limits.AuditRetentionDays
|
|
}
|
|
|
|
// GetMaxVersions returns the max versions to keep per secret (-1 = unlimited)
|
|
func GetMaxVersions() int {
|
|
limits := GetLimits()
|
|
if limits == nil {
|
|
return 2 // Solo default
|
|
}
|
|
return limits.MaxVersions
|
|
}
|
|
|
|
// GetMaxTokens returns the max tokens allowed per repo (-1 = unlimited)
|
|
func GetMaxTokens() int {
|
|
limits := GetLimits()
|
|
if limits == nil {
|
|
return 1 // Solo default
|
|
}
|
|
return limits.MaxTokens
|
|
}
|
|
|
|
// GetMaxTokenTTLHours returns the max token TTL in hours (-1 = unlimited)
|
|
func GetMaxTokenTTLHours() int {
|
|
limits := GetLimits()
|
|
if limits == nil {
|
|
return 24 // Solo default
|
|
}
|
|
return limits.MaxTokenTTLHours
|
|
}
|
|
|
|
// AreTokensReadOnly returns true if tokens are read-only (Solo tier restriction)
|
|
func AreTokensReadOnly() bool {
|
|
limits := GetLimits()
|
|
if limits == nil {
|
|
return true // Solo default
|
|
}
|
|
return limits.TokensReadOnly
|
|
}
|
|
|
|
// HasUnlimitedVersions returns true if the tier has unlimited version history
|
|
func HasUnlimitedVersions() bool {
|
|
limits := GetLimits()
|
|
return limits != nil && limits.MaxVersions == -1
|
|
}
|
|
|
|
// HasUnlimitedTokens returns true if the tier has unlimited tokens
|
|
func HasUnlimitedTokens() bool {
|
|
limits := GetLimits()
|
|
return limits != nil && limits.MaxTokens == -1
|
|
}
|
|
|
|
// CheckTokenLimit checks if adding a new token would exceed the tier limit
|
|
func CheckTokenLimit(ctx context.Context, repoID int64) error {
|
|
maxTokens := GetMaxTokens()
|
|
if maxTokens == -1 {
|
|
return nil // Unlimited
|
|
}
|
|
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return ErrVaultNotAvailable
|
|
}
|
|
|
|
tokens, err := vp.ListTokens(ctx, repoID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Count active (non-revoked, non-expired) tokens
|
|
activeCount := 0
|
|
for _, t := range tokens {
|
|
if !t.IsRevoked && !t.IsExpired {
|
|
activeCount++
|
|
}
|
|
}
|
|
|
|
if activeCount >= maxTokens {
|
|
return ErrTokenLimitReached
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CheckSecretLimit checks if adding a new secret would exceed the tier limit
|
|
func CheckSecretLimit(ctx context.Context, repoID int64) error {
|
|
maxSecrets := GetMaxSecretsPerRepo()
|
|
if maxSecrets == -1 {
|
|
return nil // Unlimited
|
|
}
|
|
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return ErrVaultNotAvailable
|
|
}
|
|
|
|
secrets, err := vp.ListSecrets(ctx, repoID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(secrets) >= maxSecrets {
|
|
return ErrSecretLimitReached
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListSecrets lists all secrets for a repository
|
|
func ListSecrets(ctx context.Context, repoID int64, includeDeleted bool) ([]Secret, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, ErrVaultNotLicensed
|
|
}
|
|
return vp.ListSecrets(ctx, repoID, includeDeleted)
|
|
}
|
|
|
|
// GetSecret gets a secret by name
|
|
func GetSecret(ctx context.Context, repoID int64, name string) (*Secret, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, ErrVaultNotLicensed
|
|
}
|
|
return vp.GetSecret(ctx, repoID, name)
|
|
}
|
|
|
|
// GetSecretValue gets the decrypted value of a secret
|
|
func GetSecretValue(ctx context.Context, repoID int64, name string, version int) (string, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return "", ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return "", ErrVaultNotLicensed
|
|
}
|
|
return vp.GetSecretValue(ctx, repoID, name, version)
|
|
}
|
|
|
|
// CreateSecret creates a new secret
|
|
func CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions) (*Secret, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, ErrVaultNotLicensed
|
|
}
|
|
return vp.CreateSecret(ctx, repoID, opts)
|
|
}
|
|
|
|
// UpdateSecret updates an existing secret
|
|
func UpdateSecret(ctx context.Context, repoID int64, name string, opts UpdateSecretOptions) (*Secret, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, ErrVaultNotLicensed
|
|
}
|
|
return vp.UpdateSecret(ctx, repoID, name, opts)
|
|
}
|
|
|
|
// DeleteSecret soft-deletes a secret
|
|
func DeleteSecret(ctx context.Context, repoID int64, name string, userID int64) error {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return ErrVaultNotLicensed
|
|
}
|
|
return vp.DeleteSecret(ctx, repoID, name, userID)
|
|
}
|
|
|
|
// RestoreSecret restores a soft-deleted secret
|
|
func RestoreSecret(ctx context.Context, repoID int64, name string) error {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return ErrVaultNotLicensed
|
|
}
|
|
return vp.RestoreSecret(ctx, repoID, name)
|
|
}
|
|
|
|
// RollbackSecret rolls back a secret to a previous version
|
|
func RollbackSecret(ctx context.Context, repoID int64, name string, version int, userID int64) error {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return ErrVaultNotLicensed
|
|
}
|
|
return vp.RollbackSecret(ctx, repoID, name, version, userID)
|
|
}
|
|
|
|
// ListVersions lists all versions of a secret
|
|
func ListVersions(ctx context.Context, repoID int64, name string) ([]SecretVersion, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, ErrVaultNotLicensed
|
|
}
|
|
return vp.ListVersions(ctx, repoID, name)
|
|
}
|
|
|
|
// ListTokens lists all tokens for a repository
|
|
func ListTokens(ctx context.Context, repoID int64) ([]Token, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, ErrVaultNotLicensed
|
|
}
|
|
return vp.ListTokens(ctx, repoID)
|
|
}
|
|
|
|
// CreateToken creates a new CI/CD token
|
|
func CreateToken(ctx context.Context, repoID int64, opts CreateTokenOptions) (*Token, string, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, "", ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, "", ErrVaultNotLicensed
|
|
}
|
|
return vp.CreateToken(ctx, repoID, opts)
|
|
}
|
|
|
|
// RevokeToken revokes a token
|
|
func RevokeToken(ctx context.Context, repoID, tokenID int64) error {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return ErrVaultNotLicensed
|
|
}
|
|
return vp.RevokeToken(ctx, repoID, tokenID)
|
|
}
|
|
|
|
// ValidateToken validates a token for a specific action
|
|
func ValidateToken(ctx context.Context, rawToken, action, secretName string) (*Token, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, ErrVaultNotLicensed
|
|
}
|
|
return vp.ValidateToken(ctx, rawToken, action, secretName)
|
|
}
|
|
|
|
// ListAuditEntries lists audit entries for a repository
|
|
func ListAuditEntries(ctx context.Context, repoID int64, page, pageSize int) ([]AuditEntry, int64, error) {
|
|
vp := GetPlugin()
|
|
if vp == nil {
|
|
return nil, 0, ErrVaultNotAvailable
|
|
}
|
|
if !IsLicensed() {
|
|
return nil, 0, ErrVaultNotLicensed
|
|
}
|
|
return vp.ListAuditEntries(ctx, repoID, page, pageSize)
|
|
}
|