package cabana import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "log/slog" "strings" "sync" "time" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) // APITokenPrefix is the fixed plaintext prefix of every personal admin API token. const APITokenPrefix = "g15_" // BackendAPIToken is one personal admin API token row. Only the sha256 hex of // the plaintext and a four-character hint are stored; the plaintext is never // persisted. type BackendAPIToken struct { ID uint `gorm:"column:id;primaryKey" json:"id"` BackendUserID uint `gorm:"column:backend_user_id" json:"backend_user_id"` Name string `gorm:"column:name" json:"name"` TokenHash string `gorm:"column:token_hash" json:"-"` TokenHint string `gorm:"column:token_hint" json:"hint"` Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes" json:"scopes"` ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"` LastUsedAt *time.Time `gorm:"column:last_used_at" json:"last_used_at"` RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"` RevokedBy *uint `gorm:"column:revoked_by" json:"revoked_by"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` } // TableName is the framework-owned personal API token table. func (BackendAPIToken) TableName() string { return "backend_api_tokens" } func mintAPIToken() (plain, hash, hint string, err error) { var b [20]byte if _, err = rand.Read(b[:]); err != nil { return "", "", "", err } plain = APITokenPrefix + hex.EncodeToString(b[:]) hash = hashAPIToken(plain) hint = plain[len(plain)-4:] return plain, hash, hint, nil } func hashAPIToken(plain string) string { sum := sha256.Sum256([]byte(plain)) return hex.EncodeToString(sum[:]) } func wellFormedAPIToken(v string) bool { if len(v) != len(APITokenPrefix)+40 || !strings.HasPrefix(v, APITokenPrefix) { return false } for _, c := range v[len(APITokenPrefix):] { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true } // cleanedTokenRequest is the validated create payload. type cleanedTokenRequest struct { Name string ExpiresAt *time.Time Scopes []string } // validateTokenRequest applies D-08/D-17 name, expiry and scope rules with no // database access. details maps field names to message keys under // backend::lang (for example api_tokens.name_required). cli enables YYYY-MM-DD // expiry dates (00:00 UTC, must be after now). func validateTokenRequest(reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (cleanedTokenRequest, map[string]any) { details := map[string]any{} name = strings.TrimSpace(name) if name == "" { details["name"] = []string{"api_tokens.name_required"} } else if len([]rune(name)) > 100 { details["name"] = []string{"api_tokens.name_too_long"} } expiresAt, expKey := parseTokenExpiry(expires, now, cli) if expKey != "" { details["expires"] = []string{expKey} } cleanedScopes, scopeDetails := validateTokenScopes(reg, owner, scopes) for k, v := range scopeDetails { details[k] = v } if len(details) > 0 { return cleanedTokenRequest{}, details } return cleanedTokenRequest{Name: name, ExpiresAt: expiresAt, Scopes: cleanedScopes}, nil } func parseTokenExpiry(expires string, now time.Time, cli bool) (*time.Time, string) { expires = strings.TrimSpace(expires) if expires == "" { expires = "90d" } switch expires { case "30d": t := now.UTC().Add(30 * 24 * time.Hour) return &t, "" case "90d": t := now.UTC().Add(90 * 24 * time.Hour) return &t, "" case "365d": t := now.UTC().Add(365 * 24 * time.Hour) return &t, "" case "never": return nil, "" } if cli { if t, err := time.Parse("2006-01-02", expires); err == nil { t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) if !t.After(now.UTC()) { return nil, "api_tokens.expires_invalid" } return &t, "" } } return nil, "api_tokens.expires_invalid" } func validateTokenScopes(reg *Registry, owner *bouncer.Principal, scopes []string) ([]string, map[string]any) { details := map[string]any{} if len(scopes) == 0 { details["scopes"] = []string{"api_tokens.scopes_required"} return nil, details } for _, s := range scopes { if s == scopeAll { return []string{scopeAll}, nil } } seen := map[string]bool{} out := make([]string, 0, len(scopes)) for _, name := range scopes { name = strings.TrimSpace(name) if name == "" || seen[name] { continue } seen[name] = true if reg == nil || reg.tokenScopes == nil || reg.tokenScopes.byName[name] == nil || !scopeOverlaps(reg, owner, name) { details["scopes"] = []string{"api_tokens.scope_unavailable|" + name} return nil, details } out = append(out, name) } if len(out) == 0 { details["scopes"] = []string{"api_tokens.scopes_required"} return nil, details } return out, nil } // issueAPIToken validates and inserts a token for the owner. On validation // failure details is non-nil and plain/row are zero. The plaintext is returned // exactly once on success. func issueAPIToken(db *gorm.DB, reg *Registry, owner *bouncer.Principal, name, expires string, scopes []string, now time.Time, cli bool) (plain string, row *BackendAPIToken, details map[string]any, err error) { if owner == nil || owner.ID == 0 { return "", nil, nil, fmt.Errorf("cabana: issue API token: missing owner") } cleaned, details := validateTokenRequest(reg, owner, name, expires, scopes, now, cli) if details != nil { return "", nil, details, nil } plain, row, err = insertAPIToken(db, owner.ID, cleaned.Name, cleaned.Scopes, cleaned.ExpiresAt) return plain, row, nil, err } // insertAPIToken mints and persists a row without validation (tests and the // validated issueAPIToken path). func insertAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) { if db == nil || ownerID == 0 { return "", nil, fmt.Errorf("cabana: issue API token: missing database or owner") } if name == "" { name = "token" } if len(scopes) == 0 { return "", nil, fmt.Errorf("cabana: issue API token: scopes required") } plain, hash, hint, err := mintAPIToken() if err != nil { return "", nil, err } row = &BackendAPIToken{ BackendUserID: ownerID, Name: name, TokenHash: hash, TokenHint: hint, Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, ExpiresAt: expiresAt, } if err := db.Create(row).Error; err != nil { return "", nil, err } return plain, row, nil } // revokeAPIToken soft-revokes the owner's active token. notFound is true when // no matching active row was updated. func revokeAPIToken(db *gorm.DB, ownerID, id uint, revokedBy *uint) (row *BackendAPIToken, notFound bool, err error) { if db == nil || ownerID == 0 || id == 0 { return nil, true, nil } now := time.Now().UTC() res := db.Model(&BackendAPIToken{}). Where("id = ? AND backend_user_id = ? AND revoked_at IS NULL", id, ownerID). Updates(map[string]any{"revoked_at": now, "revoked_by": revokedBy}) if res.Error != nil { return nil, false, res.Error } if res.RowsAffected == 0 { return nil, true, nil } var out BackendAPIToken if err := db.First(&out, id).Error; err != nil { return nil, false, err } return &out, false, nil } // tokenStore looks up usable personal API tokens by hash and stamps last_used_at. type tokenStore interface { FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error) StampLastUsed(ctx context.Context, id uint, now time.Time) error } type gormTokenStore struct { db *gorm.DB } func (s gormTokenStore) FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error) { if s.db == nil { return nil, fmt.Errorf("cabana: token store has no database") } var row BackendAPIToken err := s.db.WithContext(ctx). Where("token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)", hash, now). Limit(1). Find(&row).Error if err != nil { return nil, err } if row.ID == 0 { return nil, nil } return &row, nil } func (s gormTokenStore) StampLastUsed(ctx context.Context, id uint, now time.Time) error { if s.db == nil || id == 0 { return nil } return s.db.WithContext(ctx).Exec( `UPDATE backend_api_tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`, now, id, now.Add(-time.Minute), ).Error } // stampThrottle gates last_used_at writes to at most once per minute per token // ID per guard instance; one instance serves a cabana activation (the backend // guard and the ResolveBackend guard share it). The SQL UPDATE is additionally // conditional. type stampThrottle struct { last sync.Map // uint -> time.Time } // due reports whether id may be stamped at now and records the stamp if so. // A nil throttle never debounces. func (t *stampThrottle) due(id uint, now time.Time) bool { if t == nil { return true } if v, ok := t.last.Load(id); ok { if now.Sub(v.(time.Time)) < time.Minute { return false } } t.last.Store(id, now) return true } func stampLastUsed(ctx context.Context, stamps *stampThrottle, store tokenStore, id uint, now time.Time) { if store == nil || id == 0 { return } if !stamps.due(id, now) { return } if err := store.StampLastUsed(ctx, id, now); err != nil { slog.Default().WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err) } }