Skip to content

Commit 08ea333

Browse files
committed
feat[backend](billing): license authority with edition/MSSP feature gating and datasource usage
1 parent 4bfd7e6 commit 08ea333

25 files changed

Lines changed: 582 additions & 25 deletions

File tree

.github/workflows/reusable-golang.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ on:
2222
type: string
2323
default: ""
2424
description: "Path to the Dockerfile (defaults to ./<image_name>/Dockerfile)"
25+
secrets:
26+
API_SECRET:
27+
required: false
28+
description: "Token to fetch private github.com/utmstack Go modules. Optional; when unset, only public modules are fetchable (backwards-compatible)."
2529
jobs:
2630
build:
2731
name: Build
@@ -36,6 +40,15 @@ jobs:
3640
go-version: ^1.20
3741
id: go
3842

43+
- name: Configure git for private modules
44+
run: |
45+
if [ -n "${{ secrets.API_SECRET }}" ]; then
46+
git config --global url."https://${{ secrets.API_SECRET }}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
47+
echo "GOPRIVATE=github.com/utmstack" >> $GITHUB_ENV
48+
echo "GONOPROXY=github.com/utmstack" >> $GITHUB_ENV
49+
echo "GONOSUMDB=github.com/utmstack" >> $GITHUB_ENV
50+
fi
51+
3952
- name: Running Tests
4053
working-directory: ./${{inputs.image_name}}
4154
run: go test -v ./...

.github/workflows/v11-deployment-pipeline.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,11 @@ jobs:
450450
tag: ${{ needs.setup_deployment.outputs.tag }}
451451
build_context: "."
452452
dockerfile: "./backend/Dockerfile"
453+
flags: >-
454+
-X 'github.com/utmstack/utmstack/backend/modules/billing.PublicKey=${{ secrets.CM_SIGN_PUBLIC_KEY }}'
455+
-X 'github.com/utmstack/utmstack/backend/modules/billing.EncryptSalt=${{ secrets.CM_ENCRYPT_SALT }}'
456+
secrets:
457+
API_SECRET: ${{ secrets.API_SECRET }}
453458
build_frontend:
454459
name: Build Frontend Microservice
455460
needs: [validations, setup_deployment]

backend/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ func main() {
4343
panic(err)
4444
}
4545

46-
modules.alerts.Start(appCtx)
46+
modules.billing.Start(appCtx)
4747
modules.eventProcessing.Start(appCtx)
4848
modules.compliance.Start(appCtx)
4949
modules.integrations.Start(appCtx)
50+
modules.datasources.Start(appCtx)
5051
if err := modules.soar.Start(appCtx); err != nil {
5152
_ = catcher.Error("soar flow bootstrap failed", err, nil)
5253
}

backend/modules.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/utmstack/utmstack/backend/modules/alerts"
1313
"github.com/utmstack/utmstack/backend/modules/appconfig"
1414
"github.com/utmstack/utmstack/backend/modules/audit"
15+
"github.com/utmstack/utmstack/backend/modules/billing"
1516
"github.com/utmstack/utmstack/backend/modules/compliance"
1617
"github.com/utmstack/utmstack/backend/modules/dashboards"
1718
"github.com/utmstack/utmstack/backend/modules/datasources"
@@ -52,6 +53,7 @@ type modules struct {
5253
iam *iam.Module
5354
audit *audit.Module
5455
appconfig *appconfig.Module
56+
billing *billing.Module
5557
mail *mail.Module
5658
compliance *compliance.Module
5759
dashboards *dashboards.Module
@@ -89,6 +91,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
8991
limiter := ratelimit.NewLoginLimiter(loginMaxFailures, loginBlockTTL, loginWindowTTL)
9092

9193
auditMod := audit.NewModule(db)
94+
billingMod := billing.NewModule(env.String("UPDATES_DIR", "/updates", false), 25)
9295
configMod := appconfig.NewModule(db, cipher)
9396
mailMod := mail.NewModule(configMod.Store())
9497
configMod.SetMailer(mailMod.Service())
@@ -121,7 +124,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
121124
_ = catcher.Error("opensearch SDK connect failed", err, nil)
122125
}
123126

124-
alertsMod := alerts.NewModule(db, env.Bool("ALERTS_SCHEDULER_ENABLED", false))
127+
alertsMod := alerts.NewModule(db)
125128

126129
agentClient, agentErr := agentmanager.NewClient()
127130
if agentErr != nil {
@@ -132,17 +135,20 @@ func initModules(db *gorm.DB, cfg *config) *modules {
132135
soarMod := soar.NewModule(db, agentClient, signer, cipher)
133136
eventProcessingMod := eventprocessing.NewModule(db, auditMod.Logger())
134137

135-
integrationsMod := integrations.NewModule(db, cipher,
136-
env.String("INTEGRATIONS_TENANT_DIR", "/workdir/pipeline", false),
137-
)
138-
139-
// datasources: registered log sources (agents, collectors, pullers, direct inputs)
140-
// and their groups. Registration + liveness arrive via the ping endpoint.
141138
dsRepo := ns_repository.NewDatasourceRepository(db)
142139
dsGroupRepo := ns_repository.NewAssetGroupRepository(db)
143140
dsUC := ns_usecase.NewDatasourceUsecase(dsRepo)
144141
dsGroupUC := ns_usecase.NewAssetGroupUsecase(dsGroupRepo)
145-
datasourcesMod := datasources.NewModule(dsUC, dsGroupUC)
142+
var dsReconciler *ns_usecase.StatsReconciler
143+
if cfg.esHost != "" {
144+
dsReconciler = ns_usecase.NewStatsReconciler(dsRepo, ns_repository.NewStatsReader())
145+
}
146+
datasourcesMod := datasources.NewModule(dsUC, dsGroupUC, dsReconciler, billingMod.License())
147+
148+
integrationsMod := integrations.NewModule(db, cipher,
149+
env.String("INTEGRATIONS_TENANT_DIR", "/workdir/pipeline", false),
150+
dsUC,
151+
)
146152

147153
opensearchMod := opensearchgw.NewModule(db, cfg.esHost != "")
148154
notificationsMod := notifications.NewModule(db, auditMod.Logger())
@@ -165,6 +171,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
165171
iam: iam.NewModule(authUsecase, userUsecase, roleUsecase, tfaUsecase, apiKeyUsecase, idpUsecase, samlUsecase, cfg.uploadDir),
166172
audit: auditMod,
167173
appconfig: configMod,
174+
billing: billingMod,
168175
mail: mailMod,
169176
compliance: complianceMod,
170177
dashboards: dashboardsMod,

backend/modules/appconfig/routes.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"github.com/utmstack/utmstack/backend/pkg/http/middleware"
66
)
77

8-
func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
8+
func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, mssp gin.HandlerFunc) {
99
g := api.Group("/config", userAuth)
1010
g.GET("", middleware.RequirePermission("config.read"), m.Handler().List)
1111
g.GET("/:key", middleware.RequirePermission("config.read"), m.Handler().Get)
@@ -14,6 +14,6 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) {
1414

1515
b := api.Group("/branding")
1616
b.GET("", userAuth, middleware.RequirePermission("config.read"), m.BrandingHandler().Get)
17-
b.PUT("", userAuth, middleware.RequirePermission("config.write"), m.BrandingHandler().Update)
17+
b.PUT("", userAuth, mssp, middleware.RequirePermission("config.write"), m.BrandingHandler().Update)
1818
b.GET("/public", m.BrandingHandler().Public)
1919
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package domain
2+
3+
import "time"
4+
5+
type Edition string
6+
7+
const (
8+
EditionCommunity Edition = "community"
9+
EditionEnterprise Edition = "enterprise"
10+
)
11+
12+
type License struct {
13+
Edition Edition `json:"edition"`
14+
MSSP bool `json:"mssp"`
15+
Datasources int64 `json:"datasources"` // cap; 0 = unlimited
16+
Type string `json:"type"` // online | offline ("" when community)
17+
ExpiresAt time.Time `json:"expiresAt,omitempty"`
18+
}
19+
20+
func Community() License { return License{Edition: EditionCommunity} }
21+
22+
func (l License) IsEnterprise() bool { return l.Edition == EditionEnterprise }
23+
func (l License) IsMSSP() bool { return l.MSSP }
24+
func (l License) Unlimited() bool { return l.Datasources == 0 }
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package dto
2+
3+
type VersionInfo struct {
4+
Version string `json:"version"`
5+
Edition string `json:"edition"`
6+
Changelog string `json:"changelog,omitempty"`
7+
Server string `json:"server,omitempty"`
8+
InstanceID string `json:"instanceId,omitempty"`
9+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package handler
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/gin-gonic/gin"
7+
"github.com/utmstack/utmstack/backend/modules/billing/domain"
8+
)
9+
10+
type licenseProvider interface {
11+
Current() domain.License
12+
}
13+
14+
type LicenseHandler struct {
15+
license licenseProvider
16+
}
17+
18+
func NewLicenseHandler(license licenseProvider) *LicenseHandler {
19+
return &LicenseHandler{license: license}
20+
}
21+
22+
// Get godoc
23+
//
24+
// @Summary Get the evaluated license / edition
25+
// @Description Authoritative edition (community|enterprise) and capabilities,
26+
// @Description derived from the signed LICENSE envelope.
27+
// @Tags Billing
28+
// @Security BearerAuth
29+
// @Produce json
30+
// @Success 200 {object} domain.License
31+
// @Router /billing/license [get]
32+
func (h *LicenseHandler) Get(c *gin.Context) {
33+
c.JSON(http.StatusOK, h.license.Current())
34+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package handler
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/gin-gonic/gin"
7+
"github.com/utmstack/utmstack/backend/modules/billing/domain"
8+
"github.com/utmstack/utmstack/backend/modules/billing/dto"
9+
)
10+
11+
type versionProvider interface {
12+
Info() (dto.VersionInfo, error)
13+
}
14+
15+
type editionProvider interface {
16+
Current() domain.License
17+
}
18+
19+
type VersionHandler struct {
20+
usecase versionProvider
21+
license editionProvider
22+
}
23+
24+
func NewVersionHandler(uc versionProvider, license editionProvider) *VersionHandler {
25+
return &VersionHandler{usecase: uc, license: license}
26+
}
27+
28+
// Get godoc
29+
//
30+
// @Summary Get deployment version and instance info
31+
// @Description Reads version.json and instance-config.yml from the updates folder.
32+
// @Tags Billing
33+
// @Security BearerAuth
34+
// @Produce json
35+
// @Success 200 {object} dto.VersionInfo
36+
// @Failure 500 {object} map[string]string
37+
// @Router /billing/version [get]
38+
func (h *VersionHandler) Get(c *gin.Context) {
39+
info, err := h.usecase.Info()
40+
if err != nil {
41+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
42+
return
43+
}
44+
// Edition is authoritative from the license, not version.json.
45+
info.Edition = string(h.license.Current().Edition)
46+
c.JSON(http.StatusOK, info)
47+
}

backend/modules/billing/module.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package billing
2+
3+
import (
4+
"context"
5+
6+
"github.com/utmstack/utmstack/backend/modules/billing/usecase"
7+
)
8+
9+
var (
10+
PublicKey string
11+
EncryptSalt string
12+
)
13+
14+
type Module struct {
15+
version *usecase.VersionUsecase
16+
license *usecase.LicenseService
17+
}
18+
19+
func NewModule(updatesDir string, communityDatasourceCap int64) *Module {
20+
return &Module{
21+
version: usecase.NewVersionUsecase(updatesDir),
22+
license: usecase.NewLicenseService(updatesDir, PublicKey, EncryptSalt, communityDatasourceCap),
23+
}
24+
}
25+
26+
func (m *Module) Start(ctx context.Context) {
27+
m.license.Start(ctx)
28+
}
29+
30+
func (m *Module) Version() *usecase.VersionUsecase { return m.version }
31+
32+
func (m *Module) License() *usecase.LicenseService { return m.license }

0 commit comments

Comments
 (0)