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.
This commit is contained in:
2026-08-12 14:22:12 +00:00
parent 1b718e7c59
commit 33b5ec0788
3 changed files with 36 additions and 2 deletions
+10 -1
View File
@@ -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})
}
+4 -1
View File
@@ -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}},
+22
View File
@@ -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
}