diff --git a/server/internal/services/channels.go b/server/internal/services/channels.go index b79fd68..ac6682a 100644 --- a/server/internal/services/channels.go +++ b/server/internal/services/channels.go @@ -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 diff --git a/server/internal/services/licence_limits.go b/server/internal/services/licence_limits.go new file mode 100644 index 0000000..eda74f9 --- /dev/null +++ b/server/internal/services/licence_limits.go @@ -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 +} diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index 5735832..ab76142 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -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() diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index a9d702b..2fb05b7 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -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