feat(server): licence API, mutation gate and feature gates

RequireActiveLicense is mounted on the /api group so new routes are gated by
where they live. GET /api/servers/new is named explicitly: it mints a
pre-registration token, so it mutates despite the method.
This commit is contained in:
2026-07-24 15:13:35 +01:00
parent 855537c535
commit 120c9c6735
5 changed files with 260 additions and 5 deletions
+3
View File
@@ -39,6 +39,9 @@ func createChannel(c *gin.Context) {
}
created, err := services.CreateChannel(auth.InstanceID(c), &ch)
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+17 -4
View File
@@ -37,7 +37,14 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
// Deny by default: every non-GET route under /api is gated unless it is on
// the exemption list in licence.go. A route added later is covered because
// of where it is mounted, not because someone remembered.
apiGroup.Use(RequireActiveLicense())
{
apiGroup.GET("/license", getLicence)
apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
apiGroup.GET("/servers", listServers)
apiGroup.POST("/servers", createServer)
apiGroup.GET("/servers/new", newServer)
@@ -76,8 +83,8 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
apiGroup.POST("/console/connect", consoleConnect)
apiGroup.GET("/console/tunnel", consoleTunnel)
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
@@ -90,8 +97,8 @@ func RegisterRoutes(r *gin.Engine) {
instance.POST("/users", createInstanceUser)
instance.PUT("/users/:id/role", updateInstanceUserRole)
instance.DELETE("/users/:id", deleteInstanceUser)
instance.GET("/oidc", getInstanceOIDC)
instance.PUT("/oidc", putInstanceOIDC)
instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC)
instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC)
}
}
}
@@ -108,6 +115,9 @@ func listServers(c *gin.Context) {
func createServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -121,6 +131,9 @@ func createServer(c *gin.Context) {
func newServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"errors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/mrhid6/vantage/shared/license"
)
// licenceExemptPaths are routes that must work while a licence is expired or
// missing, because they are how a customer recovers or stays safe.
//
// /api/license pasting a valid licence is the way out of degraded mode
// apply-updates security patching is never paywalled
//
// All DELETE requests are exempt separately (see RequireActiveLicense): a
// customer downgraded below their current usage must be able to delete their
// way back under the cap.
var licenceExemptPaths = map[string]bool{
"/api/license": true,
}
// mutatingGETs are routes that change state despite their method. GET is
// otherwise always allowed through, so these have to be named explicitly:
// GET /api/servers/new mints a pre-registration token, which is a creation.
var mutatingGETs = map[string]bool{
"/api/servers/new": true,
}
func licenceExempt(c *gin.Context) bool {
if c.Request.Method == http.MethodDelete {
return true
}
if licenceExemptPaths[c.FullPath()] {
return true
}
if c.FullPath() == "/api/servers/:id/apply-updates" {
return true
}
return false
}
// RequireActiveLicense blocks mutating requests when the licence is not valid.
//
// Mounted on the /api group, so a route added tomorrow is gated because of where
// it lives rather than because someone remembered. GET and HEAD always pass —
// reading is never blocked.
func RequireActiveLicense() gin.HandlerFunc {
return func(c *gin.Context) {
if (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) &&
!mutatingGETs[c.FullPath()] {
c.Next()
return
}
if licenceExempt(c) {
c.Next()
return
}
st := services.GetLicenseState(auth.InstanceID(c))
if st.Active() {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "license_required",
"state": st.Status,
"reason": st.Reason,
})
}
}
// RequireFeature blocks a route when the licence does not grant a feature.
func RequireFeature(name string) gin.HandlerFunc {
return func(c *gin.Context) {
st := services.GetLicenseState(auth.InstanceID(c))
if st.Feature(name) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "feature_unavailable",
"feature": name,
})
}
}
type licenceResponse struct {
InstanceID string `json:"instance_id"`
State license.State `json:"state"`
Reason string `json:"reason,omitempty"`
Tier string `json:"tier,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
DaysRemaining *int `json:"days_remaining,omitempty"`
Limits license.Limits `json:"limits"`
Features map[string]bool `json:"features"`
Usage licenceUsageResponse `json:"usage"`
Source string `json:"source"`
}
type licenceUsageResponse struct {
Servers int `json:"servers"`
SecretGroups int `json:"secret_groups"`
Channels int `json:"channels"`
}
func getLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
st := services.GetLicenseState(instanceID)
servers, groups, channels := services.LicenseUsage(instanceID)
resp := licenceResponse{
InstanceID: instanceID,
State: st.Status,
Reason: st.Reason,
Tier: st.Tier,
ExpiresAt: st.ExpiresAt,
Limits: st.Limits,
Features: st.Features,
Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels},
Source: st.Source,
}
if st.ExpiresAt != nil {
d := int(time.Until(*st.ExpiresAt).Hours() / 24)
resp.DaysRemaining = &d
}
c.JSON(http.StatusOK, resp)
}
var (
licencePostMu sync.Mutex
licencePostCounts = map[string][]time.Time{}
)
const licencePostLimit = 10
// licencePostAllowed permits 10 attempts per instance per hour.
func licencePostAllowed(instanceID string) bool {
cutoff := time.Now().Add(-time.Hour)
licencePostMu.Lock()
defer licencePostMu.Unlock()
kept := licencePostCounts[instanceID][:0]
for _, t := range licencePostCounts[instanceID] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= licencePostLimit {
licencePostCounts[instanceID] = kept
return false
}
licencePostCounts[instanceID] = append(kept, time.Now())
return true
}
func postLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
if !licencePostAllowed(instanceID) {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many licence attempts. Try again later.",
})
return
}
var body struct {
Blob string `json:"blob"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"})
return
}
st, err := services.StoreLicense(instanceID, body.Blob)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": licenceRejectionMessage(err.Error(), instanceID),
"reason": err.Error(),
})
return
}
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
"licence accepted (tier "+st.Tier+")")
c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
}
// licenceRejectionMessage turns a machine reason into something a person can act
// on. The instance ID is included in the mismatch case because that is the one
// piece of information the customer needs and cannot guess.
func licenceRejectionMessage(reason, instanceID string) string {
switch reason {
case license.ReasonBadSignature:
return "This licence key is not valid. Check it was copied in full."
case license.ReasonDeploymentMismatch:
return "This licence is for Vantage Cloud and cannot be used on a self-hosted install."
case license.ReasonInstanceMismatch:
return "This licence was issued for a different instance. Your instance ID is " + instanceID + "."
case license.ReasonNoLicense:
return "No licence key was provided."
default:
return "This licence could not be accepted."
}
}
// limitStatus maps a LimitError to a 403 body. Handlers that create countable
// resources call this so the UI gets a machine-readable limit name.
func limitStatus(c *gin.Context, err error) bool {
var le *services.LimitError
if !errors.As(err, &le) {
return false
}
c.JSON(http.StatusForbidden, gin.H{
"error": "limit_exceeded",
"limit": le.Limit,
"current": le.Current,
"max": le.Max,
})
return true
}
+6
View File
@@ -91,6 +91,9 @@ func createSecretGroup(c *gin.Context) {
}
}
if err := services.UpsertSecrets(auth.InstanceID(c), body.Group, body.Values); err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -134,6 +137,9 @@ func putSecretGroup(c *gin.Context) {
}
}
if err := services.UpsertSecrets(auth.InstanceID(c), group, values); err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+7 -1
View File
@@ -63,7 +63,13 @@ func providerForInstance(ctx context.Context, c *gin.Context, instanceID string)
func HandleOIDCStart(c *gin.Context) {
inst, ok := InstanceFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"})
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown instance host"})
return
}
// Losing the feature stops new SSO logins. It deliberately does not touch
// session validation, so nobody is evicted mid-session.
if !services.GetLicenseState(inst.InstanceID).Feature("oidc") {
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
return
}
ctx := c.Request.Context()