Updates all imports and go.mod to use the new /v3 suffixed module path for proper Go semantic versioning compliance. Also updates CI workflows to use version tags (v3.x.x) instead of pseudo-versions now that the server module has the proper /v3 suffix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
283 lines
7.7 KiB
Go
283 lines
7.7 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// Proprietary and confidential.
|
|
|
|
package models
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"code.gitcaddy.com/server/v3/models/db"
|
|
"code.gitcaddy.com/server/v3/modules/timeutil"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
// TokenScope represents a token's permission scope
|
|
// Grammar:
|
|
//
|
|
// scope = permission ":" target ("," target)*
|
|
// | "admin"
|
|
// permission = "read" | "write"
|
|
// target = "*" // all secrets
|
|
// | prefix "*" // prefix match (prod.*)
|
|
// | secret-name // exact match
|
|
//
|
|
// Examples:
|
|
//
|
|
// read:* - read all secrets
|
|
// write:* - write all secrets
|
|
// read:prod.* - read secrets starting with "prod."
|
|
// read:prod.*,staging.* - read prod and staging prefixes
|
|
// write:db.credentials - write only db.credentials
|
|
// admin - manage tokens, rotate repo key, view audit
|
|
type TokenScope string
|
|
|
|
// VaultToken represents a scoped access token for CI/CD
|
|
type VaultToken struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
|
TokenHash string `xorm:"VARCHAR(64) UNIQUE NOT NULL"` // SHA-256(token)
|
|
Scope TokenScope `xorm:"VARCHAR(255) NOT NULL"`
|
|
Description string `xorm:"VARCHAR(255)"`
|
|
ExpiresUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
|
CreatedBy int64 `xorm:"NOT NULL"`
|
|
UsedCount int64 `xorm:"NOT NULL DEFAULT 0"`
|
|
LastUsedUnix timeutil.TimeStamp
|
|
RevokedUnix timeutil.TimeStamp // 0 = active, >0 = revoked
|
|
}
|
|
|
|
// TableName returns the table name for xorm
|
|
func (VaultToken) TableName() string {
|
|
return "vault_token"
|
|
}
|
|
|
|
// IsActive returns true if the token is not revoked and not expired
|
|
func (t *VaultToken) IsActive() bool {
|
|
if t.RevokedUnix > 0 {
|
|
return false
|
|
}
|
|
if t.ExpiresUnix > 0 && t.ExpiresUnix < timeutil.TimeStampNow() {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// IsExpired returns true if the token is expired
|
|
func (t *VaultToken) IsExpired() bool {
|
|
return t.ExpiresUnix > 0 && t.ExpiresUnix < timeutil.TimeStampNow()
|
|
}
|
|
|
|
// IsRevoked returns true if the token is revoked
|
|
func (t *VaultToken) IsRevoked() bool {
|
|
return t.RevokedUnix > 0
|
|
}
|
|
|
|
// Allows checks if the scope allows the given action on the secret
|
|
func (scope TokenScope) Allows(action string, secretName string) bool {
|
|
scopeStr := string(scope)
|
|
|
|
if scopeStr == "admin" {
|
|
return true
|
|
}
|
|
|
|
parts := strings.SplitN(scopeStr, ":", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
|
|
permission, targets := parts[0], parts[1]
|
|
if permission != action {
|
|
return false
|
|
}
|
|
|
|
for _, target := range strings.Split(targets, ",") {
|
|
target = strings.TrimSpace(target)
|
|
if target == "*" {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(target, "*") {
|
|
prefix := strings.TrimSuffix(target, "*")
|
|
if strings.HasPrefix(secretName, prefix) {
|
|
return true
|
|
}
|
|
} else if target == secretName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CanRead checks if the scope allows reading the secret
|
|
func (scope TokenScope) CanRead(secretName string) bool {
|
|
return scope.Allows("read", secretName) || scope.Allows("write", secretName)
|
|
}
|
|
|
|
// CanWrite checks if the scope allows writing the secret
|
|
func (scope TokenScope) CanWrite(secretName string) bool {
|
|
return scope.Allows("write", secretName)
|
|
}
|
|
|
|
// IsAdmin checks if the scope has admin privileges
|
|
func (scope TokenScope) IsAdmin() bool {
|
|
return string(scope) == "admin"
|
|
}
|
|
|
|
// GenerateToken generates a new random token and its hash
|
|
func GenerateToken() (plaintext string, hash string) {
|
|
token := make([]byte, 32) // 256 bits entropy
|
|
_, _ = rand.Read(token) // crypto/rand.Read always fills the buffer
|
|
plaintext = base64.URLEncoding.EncodeToString(token)
|
|
hashBytes := sha256.Sum256([]byte(plaintext))
|
|
hash = fmt.Sprintf("%x", hashBytes)
|
|
return plaintext, hash
|
|
}
|
|
|
|
// HashToken returns the SHA-256 hash of a token
|
|
func HashToken(plaintext string) string {
|
|
hashBytes := sha256.Sum256([]byte(plaintext))
|
|
return fmt.Sprintf("%x", hashBytes)
|
|
}
|
|
|
|
// FindVaultTokenOptions contains options for finding tokens
|
|
type FindVaultTokenOptions struct {
|
|
db.ListOptions
|
|
RepoID int64
|
|
TokenHash string
|
|
IncludeRevoked bool
|
|
IncludeExpired bool
|
|
}
|
|
|
|
// ToConds converts options to xorm conditions
|
|
func (opts FindVaultTokenOptions) ToConds() builder.Cond {
|
|
cond := builder.NewCond()
|
|
|
|
if opts.RepoID > 0 {
|
|
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
|
}
|
|
|
|
if opts.TokenHash != "" {
|
|
cond = cond.And(builder.Eq{"token_hash": opts.TokenHash})
|
|
}
|
|
|
|
if !opts.IncludeRevoked {
|
|
cond = cond.And(builder.Eq{"revoked_unix": 0})
|
|
}
|
|
|
|
if !opts.IncludeExpired {
|
|
cond = cond.And(builder.Or(
|
|
builder.Eq{"expires_unix": 0},
|
|
builder.Gte{"expires_unix": timeutil.TimeStampNow()},
|
|
))
|
|
}
|
|
|
|
return cond
|
|
}
|
|
|
|
// CreateVaultToken creates a new vault token
|
|
func CreateVaultToken(ctx context.Context, token *VaultToken) error {
|
|
_, err := db.GetEngine(ctx).Insert(token)
|
|
return err
|
|
}
|
|
|
|
// GetVaultTokenByHash returns a token by its hash
|
|
func GetVaultTokenByHash(ctx context.Context, hash string) (*VaultToken, error) {
|
|
token := &VaultToken{}
|
|
has, err := db.GetEngine(ctx).Where("token_hash = ?", hash).Get(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !has {
|
|
return nil, nil
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// GetVaultTokenByID returns a token by ID
|
|
func GetVaultTokenByID(ctx context.Context, id int64) (*VaultToken, error) {
|
|
token := &VaultToken{}
|
|
has, err := db.GetEngine(ctx).ID(id).Get(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !has {
|
|
return nil, nil
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// GetVaultTokensByRepo returns all tokens for a repository
|
|
func GetVaultTokensByRepo(ctx context.Context, repoID int64, includeRevoked bool) ([]*VaultToken, error) {
|
|
tokens := make([]*VaultToken, 0)
|
|
sess := db.GetEngine(ctx).Where("repo_id = ?", repoID)
|
|
if !includeRevoked {
|
|
sess = sess.And("revoked_unix = 0")
|
|
}
|
|
err := sess.OrderBy("created_unix DESC").Find(&tokens)
|
|
return tokens, err
|
|
}
|
|
|
|
// RevokeVaultToken revokes a token
|
|
func RevokeVaultToken(ctx context.Context, token *VaultToken) error {
|
|
token.RevokedUnix = timeutil.TimeStampNow()
|
|
_, err := db.GetEngine(ctx).ID(token.ID).Cols("revoked_unix").Update(token)
|
|
return err
|
|
}
|
|
|
|
// RecordTokenUse records that a token was used
|
|
func RecordTokenUse(ctx context.Context, token *VaultToken) error {
|
|
token.UsedCount++
|
|
token.LastUsedUnix = timeutil.TimeStampNow()
|
|
_, err := db.GetEngine(ctx).ID(token.ID).Cols("used_count", "last_used_unix").Update(token)
|
|
return err
|
|
}
|
|
|
|
// ValidateAndUseToken validates a plaintext token and records its usage
|
|
func ValidateAndUseToken(ctx context.Context, plaintext string, repoID int64) (*VaultToken, error) {
|
|
hash := HashToken(plaintext)
|
|
token, err := GetVaultTokenByHash(ctx, hash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if token == nil {
|
|
return nil, fmt.Errorf("token not found")
|
|
}
|
|
|
|
// Verify repo
|
|
if token.RepoID != repoID {
|
|
return nil, fmt.Errorf("token not valid for this repository")
|
|
}
|
|
|
|
// Check if active
|
|
if !token.IsActive() {
|
|
if token.IsRevoked() {
|
|
return nil, fmt.Errorf("token revoked")
|
|
}
|
|
if token.IsExpired() {
|
|
return nil, fmt.Errorf("token expired")
|
|
}
|
|
}
|
|
|
|
// Record usage
|
|
if err := RecordTokenUse(ctx, token); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// DeleteExpiredTokens deletes tokens that have been expired for more than retentionDays
|
|
func DeleteExpiredTokens(ctx context.Context, retentionDays int) (int64, error) {
|
|
cutoff := timeutil.TimeStampNow() - timeutil.TimeStamp(retentionDays*24*60*60)
|
|
result, err := db.GetEngine(ctx).Where("expires_unix > 0 AND expires_unix < ?", cutoff).Delete(&VaultToken{})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result, nil
|
|
}
|