Files
warmbly/internal/api/handler/admin_instance.go
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

117 lines
3.8 KiB
Go

package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/instancecheck"
"github.com/warmbly/warmbly/internal/app/instanceconfig"
"github.com/warmbly/warmbly/internal/app/instancesettings"
"github.com/warmbly/warmbly/internal/errx"
)
// The Instance surface answers the two questions a self-hoster cannot answer
// from inside the product today: what did my environment actually resolve to,
// and what is wrong with this deployment right now.
// AdminInstanceConfig returns the resolved effective configuration.
//
// Read-only by design: the environment is authoritative, so no API ever writes
// a key on this page. Sensitive values never leave the process; they carry a
// short fingerprint instead, which is enough to confirm two services hold the
// same AUTH_SECRET without disclosing either.
func (h *Handler) AdminInstanceConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"entries": instanceconfig.Entries(h.InstanceRuntime)})
}
// AdminInstanceLimits returns the effective product limits. Every value is a
// compiled constant, so this exists for visibility, not for editing.
func (h *Handler) AdminInstanceLimits(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"groups": instanceconfig.Limits()})
}
// AdminInstanceHealth runs the setup and health checks and returns only the
// findings. Checks run in parallel with a per-check timeout, so this is safe
// to poll, and a check whose input is unavailable is skipped rather than
// reported as a failure.
func (h *Handler) AdminInstanceHealth(c *gin.Context) {
registry := h.InstanceChecks
if registry == nil {
// Still answer with the environment-only checks rather than an empty
// page: a page that says nothing is indistinguishable from a healthy one.
registry = instancecheck.New(instancecheck.Deps{
Runtime: h.InstanceRuntime,
Transport: h.MailTransportRef,
})
}
checks, summary := registry.Run(c.Request.Context(), instancecheck.Input{
Host: c.Request.Host,
Origin: c.GetHeader("Origin"),
Forwarded: c.GetHeader("X-Forwarded-For") != "",
})
c.JSON(http.StatusOK, gin.H{"checks": checks, "summary": summary})
}
// AdminGetInstanceSettings returns the database-backed settings document.
func (h *Handler) AdminGetInstanceSettings(c *gin.Context) {
if h.InstanceSettings == nil {
c.JSON(http.StatusOK, instancesettings.Defaults())
return
}
c.JSON(http.StatusOK, h.InstanceSettings.Get(c.Request.Context()))
}
// AdminPutInstanceSettings validates, clamps and stores the settings document.
// Absent fields keep their stored value, so a client that does not know about
// a key cannot clear it.
func (h *Handler) AdminPutInstanceSettings(c *gin.Context) {
adminID := middleware.GetAdminUserID(c)
if adminID == nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
if h.InstanceSettings == nil {
errx.JSON(c, errx.New(errx.BadRequest, "Instance settings are not available on this deployment."))
return
}
var patch instancesettings.Patch
if err := c.ShouldBindJSON(&patch); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
doc, err := h.InstanceSettings.Put(c.Request.Context(), patch, adminID)
if err != nil {
errx.JSON(c, errx.InternalError())
return
}
if h.AdminService != nil {
h.AdminService.LogAdminAction(
c.Request.Context(),
*adminID,
"update_instance_settings",
"instance",
nil,
instanceSettingsAuditDetails(doc),
c.ClientIP(),
c.Request.UserAgent(),
)
}
c.JSON(http.StatusOK, doc)
}
func instanceSettingsAuditDetails(doc instancesettings.Document) map[string]any {
details := map[string]any{
"invitations_links_enabled": doc.Invitations.LinksEnabled,
"invitations_ttl_hours": doc.Invitations.TTLHours,
"access_allow_invited_signup": doc.Access.AllowInvitedSignup,
}
return details
}