diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 46994e6..3cbcad1 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "net/http" "os" @@ -471,12 +472,17 @@ func saveSettings(c *gin.Context) { Alerts models.AlertSettings `json:"alerts"` Email models.EmailSettings `json:"email"` WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"` + LocalLoginEnabled *bool `json:"local_login_enabled"` } 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.Email, body.WorkflowLogRetentionDays); err != nil { + if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil { + if errors.Is(err, services.ErrLockout) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/server/internal/services/auth_provider.go b/server/internal/services/auth_provider.go index d9b11fc..393f12d 100644 --- a/server/internal/services/auth_provider.go +++ b/server/internal/services/auth_provider.go @@ -11,6 +11,7 @@ import ( "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -230,6 +231,17 @@ func AckAuthProviderNotice(instanceID, providerID string) error { return err } +// IsLocalLoginEnabled fails open. A settings read error must not lock an +// instance out of its own login page, and the safe direction here is the one +// that still asks for a password. +func IsLocalLoginEnabled(instanceID string) bool { + s, err := GetSettings(instanceID) + if err != nil { + return true + } + return shared.LocalLoginEnabled(s) +} + // randomProviderID is 8 bytes hex: short enough to read in a URL, wide enough // that guessing one is not a way to enumerate an instance's providers. func randomProviderID() (string, error) { diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index eda84e6..20f9dc8 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -132,10 +132,7 @@ func ResolveSecretsReadToken(token string) (string, bool) { return s.InstanceID, true } -func SaveSettings(instanceID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - +func SaveSettings(instanceID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int, localLoginEnabled *bool) error { if alerts.OfflineThresholdMinutes <= 0 { alerts.OfflineThresholdMinutes = 5 } @@ -143,10 +140,28 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, email models.E email.SMTPPort = 587 } + // The guard lives here rather than in the handler so the settings path and + // the provider path cannot disagree about what a lockout is. + if localLoginEnabled != nil && !*localLoginEnabled { + n, err := CountEnabledAuthProviders(instanceID) + if err != nil { + return err + } + if err := CheckLockout(false, n); err != nil { + return err + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + set := bson.M{"alerts": alerts, "email": email} if retentionDays != nil { set["workflow_log_retention_days"] = *retentionDays } + if localLoginEnabled != nil { + set["local_login_enabled"] = *localLoginEnabled + } _, 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 449ac91..6b9b384 100644 --- a/shared/models/settings.go +++ b/shared/models/settings.go @@ -37,4 +37,19 @@ type Settings struct { Secrets SecretsSettings `bson:"secrets" json:"secrets"` WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` + + // LocalLoginEnabled is a pointer because it is absent on every settings + // document written before this feature existed, and a plain bool would read + // absent as disabled — turning off password login for the entire fleet at + // upgrade. Nil means enabled. + LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"` +} + +// LocalLoginEnabled reads the setting with its absent-means-on default. Every +// caller must go through this rather than dereferencing the field. +func LocalLoginEnabled(s *Settings) bool { + if s == nil || s.LocalLoginEnabled == nil { + return true + } + return *s.LocalLoginEnabled }