From 33b5ec0788198ca5e73a59d1d8b6854e341e2cd2 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 14:22:12 +0000 Subject: [PATCH] feat: Add a per-instance API token lifetime cap A pointer with absent meaning no cap, so an upgrade allows never-expire tokens exactly as before and an instance opts into the policy. It governs issuance only: changing it never invalidates a token that already exists. --- server/internal/api/handlers.go | 11 ++++++++++- server/internal/services/settings.go | 5 ++++- shared/models/settings.go | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index e9a8b96..02ea004 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -566,12 +566,17 @@ func saveSettings(c *gin.Context) { Alerts models.AlertSettings `json:"alerts"` WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"` LocalLoginEnabled *bool `json:"local_login_enabled"` + APITokenMaxDays *int `json:"api_token_max_days"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil { + if body.APITokenMaxDays != nil && *body.APITokenMaxDays < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"}) + return + } + if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil { if errors.Is(err, services.ErrLockout) { c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"}) return @@ -580,6 +585,10 @@ func saveSettings(c *gin.Context) { return } services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated") + if body.APITokenMaxDays != nil { + services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "", + fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays)) + } c.JSON(http.StatusOK, gin.H{"saved": true}) } diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index 08e4a5a..2747608 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -119,7 +119,7 @@ func ResolveSecretsReadToken(token string) (string, bool) { return s.InstanceID, true } -func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool) error { +func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error { if alerts.OfflineThresholdMinutes <= 0 { alerts.OfflineThresholdMinutes = 5 } @@ -149,6 +149,9 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays if localLoginEnabled != nil { set["local_login_enabled"] = *localLoginEnabled } + if apiTokenMaxDays != nil { + set["api_token_max_days"] = *apiTokenMaxDays + } _, err := db.Col("settings").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": set, "$setOnInsert": bson.M{"instance_id": instanceID}}, diff --git a/shared/models/settings.go b/shared/models/settings.go index 4b2dc75..e9b6dad 100644 --- a/shared/models/settings.go +++ b/shared/models/settings.go @@ -39,6 +39,18 @@ type Settings struct { // WorkflowLogRetentionDays is: absent must mean the default, not zero. // Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept. VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"` + + // APITokenMaxDays caps how long a newly created API token may live. + // + // A pointer for the same reason the retention fields are: absent must mean + // the default, and the default here is no cap at all — never-expire tokens + // are allowed until an instance decides otherwise, so an upgrade changes + // nothing. Nil or 0 is no cap. A positive value refuses both a longer + // expiry and a token with no expiry. + // + // It is a policy on issuance, not on use: raising or lowering it never + // invalidates a token that already exists. + APITokenMaxDays *int `bson:"api_token_max_days,omitempty" json:"api_token_max_days,omitempty"` } // LocalLoginEnabled reads the setting with its absent-means-on default. Every @@ -49,3 +61,13 @@ func LocalLoginEnabled(s *Settings) bool { } return *s.LocalLoginEnabled } + +// APITokenMaxDays reads the token lifetime cap with its absent-means-uncapped +// default. 0 means no cap. Every caller must go through this rather than +// dereferencing the field. +func APITokenMaxDays(s *Settings) int { + if s == nil || s.APITokenMaxDays == nil || *s.APITokenMaxDays < 0 { + return 0 + } + return *s.APITokenMaxDays +}