feat(server): enforce licence limits on servers, secret groups and channels

This commit is contained in:
2026-07-24 15:11:03 +01:00
parent 1d3fcebb28
commit 855537c535
4 changed files with 120 additions and 0 deletions
+3
View File
@@ -72,6 +72,9 @@ func validateChannelIDs(instanceID string, channelIDs []string) error {
}
func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
if err := CheckChannelLimit(instanceID); err != nil {
return nil, err
}
ctx, cancel := monCtx()
defer cancel()
ch.InstanceID = instanceID
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"context"
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// LimitError is returned when a licence cap would be exceeded. The API maps it
// to 403 with a machine-readable body.
type LimitError struct {
Limit string
Current int
Max int
}
func (e *LimitError) Error() string {
return fmt.Sprintf("licence limit reached: %s (%d of %d)", e.Limit, e.Current, e.Max)
}
func limitCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// CheckServerLimit refuses a new server when the instance is at its cap.
//
// Counts live rows only. An instance already over its cap keeps every server it
// has — nothing is truncated — it simply cannot add another.
func CheckServerLimit(instanceID string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return err
}
if !license.WithinLimit(int(n), st.Limits.MaxServers) {
return &LimitError{Limit: "max_servers", Current: int(n), Max: st.Limits.MaxServers}
}
return nil
}
// CheckSecretGroupLimit refuses a NEW group at the cap. Writing to a group that
// already exists is always allowed, so a capped customer can still rotate the
// secrets they have.
func CheckSecretGroupLimit(instanceID, group string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
existing, err := db.Col("secrets").CountDocuments(ctx,
bson.M{"instance_id": instanceID, "group": group})
if err != nil {
return err
}
if existing > 0 {
return nil
}
var groups []string
if err := db.Col("secrets").Distinct(ctx, "group",
bson.M{"instance_id": instanceID}).Decode(&groups); err != nil {
return err
}
if !license.WithinLimit(len(groups), st.Limits.MaxSecretGroups) {
return &LimitError{Limit: "max_secret_groups", Current: len(groups), Max: st.Limits.MaxSecretGroups}
}
return nil
}
// CheckChannelLimit refuses a new notification channel at the cap.
func CheckChannelLimit(instanceID string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
n, err := db.Col("notification_channels").CountDocuments(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return err
}
if !license.WithinLimit(int(n), st.Limits.MaxChannels) {
return &LimitError{Limit: "max_channels", Current: int(n), Max: st.Limits.MaxChannels}
}
return nil
}
// LicenseUsage reports current counts, so the UI can say "12 of 3 servers"
// honestly when an instance is over its cap rather than pretending.
func LicenseUsage(instanceID string) (servers, secretGroups, channels int) {
ctx, cancel := limitCtx()
defer cancel()
if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
servers = int(n)
}
var groups []string
if err := db.Col("secrets").Distinct(ctx, "group",
bson.M{"instance_id": instanceID}).Decode(&groups); err == nil {
secretGroups = len(groups)
}
if n, err := db.Col("notification_channels").CountDocuments(ctx,
bson.M{"instance_id": instanceID}); err == nil {
channels = int(n)
}
return
}
+3
View File
@@ -128,6 +128,9 @@ func RevealSecret(instanceID, group, key string) (string, error) {
}
func UpsertSecrets(instanceID, group string, values map[string]string) error {
if err := CheckSecretGroupLimit(instanceID, group); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+3
View File
@@ -31,6 +31,9 @@ func HashToken(token string) string {
}
func CreateServer(instanceID string) (*models.Server, string, error) {
if err := CheckServerLimit(instanceID); err != nil {
return nil, "", err
}
token, err := generateToken(32)
if err != nil {
return nil, "", err