Skip to content

Commit 6e4d0df

Browse files
committed
feat[backend](billing): allow uploading and replacing the LICENSE
1 parent 4a832c4 commit 6e4d0df

5 files changed

Lines changed: 101 additions & 6 deletions

File tree

backend/modules/audit/domain/event_type.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ const (
161161
DATASOURCE_GROUP_UPDATE_SUCCESS ApplicationEventType = "DATASOURCE_GROUP_UPDATE_SUCCESS"
162162
DATASOURCE_GROUP_DELETE_ATTEMPT ApplicationEventType = "DATASOURCE_GROUP_DELETE_ATTEMPT"
163163
DATASOURCE_GROUP_DELETE_SUCCESS ApplicationEventType = "DATASOURCE_GROUP_DELETE_SUCCESS"
164+
165+
// Replacing the signed LICENSE envelope (admin-only).
166+
LICENSE_UPLOAD_ATTEMPT ApplicationEventType = "LICENSE_UPLOAD_ATTEMPT"
167+
LICENSE_UPLOAD_SUCCESS ApplicationEventType = "LICENSE_UPLOAD_SUCCESS"
164168
// SERVER_MODULE_CREATE_ATTEMPT ApplicationEventType = "SERVER_MODULE_CREATE_ATTEMPT"
165169
// SERVER_MODULE_CREATE_SUCCESS ApplicationEventType = "SERVER_MODULE_CREATE_SUCCESS"
166170
// SERVER_MODULE_UPDATE_ATTEMPT ApplicationEventType = "SERVER_MODULE_UPDATE_ATTEMPT"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package domain
2+
3+
import "errors"
4+
5+
// ErrInvalidLicense is returned when an uploaded LICENSE envelope fails to
6+
// decrypt/verify against this instance, cannot be parsed, or is already expired.
7+
var ErrInvalidLicense = errors.New("invalid license")

backend/modules/billing/handler/license.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
package handler
22

33
import (
4+
"errors"
5+
"io"
46
"net/http"
57

68
"github.com/gin-gonic/gin"
9+
"github.com/threatwinds/go-sdk/catcher"
10+
"github.com/utmstack/utmstack/backend/modules/audit"
11+
audit_connectors "github.com/utmstack/utmstack/backend/modules/audit/connectors"
12+
audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain"
713
"github.com/utmstack/utmstack/backend/modules/billing/domain"
814
)
915

1016
type licenseProvider interface {
1117
Current() domain.License
18+
Replace(envelope []byte) (domain.License, error)
1219
}
1320

1421
type LicenseHandler struct {
@@ -32,3 +39,52 @@ func NewLicenseHandler(license licenseProvider) *LicenseHandler {
3239
func (h *LicenseHandler) Get(c *gin.Context) {
3340
c.JSON(http.StatusOK, h.license.Current())
3441
}
42+
43+
// Upload godoc
44+
//
45+
// @Summary Upload/replace the signed LICENSE envelope (admin only)
46+
// @Description Validates the uploaded LICENSE against this instance and, only if
47+
// @Description valid (signature + not expired), atomically replaces the stored
48+
// @Description license and returns the newly evaluated edition.
49+
// @Tags Billing
50+
// @Security BearerAuth
51+
// @Accept multipart/form-data
52+
// @Produce json
53+
// @Param file formData file true "LICENSE envelope file"
54+
// @Success 200 {object} domain.License
55+
// @Failure 400 {object} map[string]string
56+
// @Failure 500 {object} map[string]string
57+
// @Router /billing/license [post]
58+
func (h *LicenseHandler) Upload(c *gin.Context) {
59+
file, err := c.FormFile("file")
60+
if err != nil {
61+
c.JSON(http.StatusBadRequest, gin.H{"error": "license file is required (multipart field 'file')"})
62+
return
63+
}
64+
f, err := file.Open()
65+
if err != nil {
66+
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot open uploaded file"})
67+
return
68+
}
69+
defer func() { _ = f.Close() }()
70+
71+
envelope, err := io.ReadAll(f)
72+
if err != nil {
73+
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read uploaded file"})
74+
return
75+
}
76+
77+
lic, err := h.license.Replace(envelope)
78+
audit.Record(c, audit_connectors.Event{Action: "license.upload", ResourceType: "license"},
79+
audit_domain.LICENSE_UPLOAD_ATTEMPT, audit_domain.LICENSE_UPLOAD_SUCCESS, err)
80+
if err != nil {
81+
if errors.Is(err, domain.ErrInvalidLicense) {
82+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
83+
return
84+
}
85+
_ = catcher.Error("billing: license upload failed", err, nil)
86+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to install license"})
87+
return
88+
}
89+
c.JSON(http.StatusOK, lic)
90+
}

backend/modules/billing/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package billing
33
import (
44
"github.com/gin-gonic/gin"
55
"github.com/utmstack/utmstack/backend/modules/billing/handler"
6+
"github.com/utmstack/utmstack/backend/pkg/http/middleware"
67
)
78

89
func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
@@ -12,4 +13,5 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
1213
g := api.Group("/billing", userAuth)
1314
g.GET("/version", vh.Get)
1415
g.GET("/license", lh.Get)
16+
g.POST("/license", middleware.RequireAdmin(), lh.Upload) // upload/replace the LICENSE
1517
}

backend/modules/billing/usecase/license.go

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package usecase
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
67
"os"
78
"path/filepath"
89
"strings"
@@ -97,26 +98,32 @@ func (s *LicenseService) evaluate() domain.License {
9798
if err != nil {
9899
return domain.Community() // no license installed → community
99100
}
101+
lic, err := s.validateAndParse(envelope)
102+
if err != nil {
103+
_ = catcher.Error("billing: license invalid", err, nil)
104+
return domain.Community()
105+
}
106+
return lic
107+
}
100108

109+
func (s *LicenseService) validateAndParse(envelope []byte) (domain.License, error) {
101110
instanceID := s.instanceID()
102111
if instanceID == "" || s.publicKey == "" || s.salt == "" {
103-
return domain.Community()
112+
return domain.License{}, fmt.Errorf("license verification not configured")
104113
}
105114

106115
decrypted, err := lm.DecryptAndVerifyFromBase64(strings.TrimSpace(string(envelope)), []string{instanceID, s.salt}, s.publicKey)
107116
if err != nil {
108-
_ = catcher.Error("billing: license decrypt/verify failed", err, nil)
109-
return domain.Community()
117+
return domain.License{}, fmt.Errorf("decrypt/verify failed: %w", err)
110118
}
111119

112120
var inner licenseInner
113121
if err := json.Unmarshal([]byte(decrypted), &inner); err != nil {
114-
_ = catcher.Error("billing: cannot parse license payload", err, nil)
115-
return domain.Community()
122+
return domain.License{}, fmt.Errorf("cannot parse license payload: %w", err)
116123
}
117124

118125
if time.Now().After(inner.ExpiresAt) {
119-
return domain.Community() // expired → community
126+
return domain.License{}, fmt.Errorf("license expired at %s", inner.ExpiresAt.Format(time.RFC3339))
120127
}
121128

122129
return domain.License{
@@ -125,7 +132,26 @@ func (s *LicenseService) evaluate() domain.License {
125132
Datasources: inner.Datasources,
126133
Type: inner.Type,
127134
ExpiresAt: inner.ExpiresAt,
135+
}, nil
136+
}
137+
138+
func (s *LicenseService) Replace(envelope []byte) (domain.License, error) {
139+
if _, err := s.validateAndParse(envelope); err != nil {
140+
return domain.License{}, fmt.Errorf("%w: %v", domain.ErrInvalidLicense, err)
141+
}
142+
if err := s.writeLicenseFile(envelope); err != nil {
143+
return domain.License{}, fmt.Errorf("billing: cannot write license file: %w", err)
144+
}
145+
return s.Refresh(), nil
146+
}
147+
148+
func (s *LicenseService) writeLicenseFile(envelope []byte) error {
149+
data := []byte(strings.TrimSpace(string(envelope)) + "\n")
150+
tmp := s.licenseFile + ".tmp"
151+
if err := os.WriteFile(tmp, data, 0o600); err != nil {
152+
return err
128153
}
154+
return os.Rename(tmp, s.licenseFile)
129155
}
130156

131157
func (s *LicenseService) instanceID() string {

0 commit comments

Comments
 (0)