Files
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

180 lines
5.2 KiB
Go

// Package instanceconfig describes the environment the running backend
// resolved, so an operator can see what a variable actually became.
//
// It never reads a second source of truth: every resolver calls the same
// config helper the runtime uses, so the page cannot drift from behaviour.
// Nothing here writes: the environment is authoritative.
package instanceconfig
import (
"os"
"strings"
"github.com/warmbly/warmbly/internal/config"
)
// Groups the admin panel renders as sections.
const (
GroupDeployment = "deployment"
GroupAddresses = "addresses"
GroupDatabase = "database"
GroupCache = "cache"
GroupMail = "mail"
GroupAuth = "auth"
GroupEncryption = "encryption"
GroupStorage = "storage"
GroupEventBus = "eventbus"
GroupWorkers = "workers"
GroupTracking = "tracking"
GroupCaptcha = "captcha"
GroupObservability = "observability"
)
// Where a resolved value came from.
const (
SourceEnv = "env"
SourceDefault = "default"
SourceDerived = "derived"
SourceUnset = "unset"
)
// Whether a change needs a restart.
const (
ChangeBootOnly = "boot-only"
ChangePerRequest = "per-request"
)
// PublishedDefaults are the working secret values shipped in docker-compose.yml
// and the Makefile. They are public in this repository, so a deployment still
// running on one is not protected by it at all.
//
// This mirrors insecureDefaults in cmd/backend/boot.go; the two must stay
// identical or the health check and the boot refusal will disagree.
var PublishedDefaults = map[string]string{
"AUTH_SECRET": "local-dev-auth-secret-minimum-32-characters-long",
"KMS_LOCAL_MASTER_KEY": "Xr0JA7gqF2POy29a7MRByyqddivTNt8WOyKsOXklazk=",
"CREDENTIALS_ENCRYPTION_KEY": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"INTERNAL_API_TOKEN": "local-dev-internal-token",
"SECRET_KEY_BASE": "local-development-secret-key-base-minimum-64-characters-for-phoenix",
}
// Runtime carries the facts only cmd/backend knows after boot: values it
// derived rather than read, and the outcome of its own preflight. A nil
// Runtime is valid; every resolver falls back to the environment.
type Runtime struct {
CORSOrigins []string
WebAuthnRPID string
WebAuthnRPOrigins []string
OIDCRedirectURL string
OIDCConfigured bool
// OIDCDiscoveryErr is the boot-time discovery failure, empty when discovery
// succeeded or was never attempted.
OIDCDiscoveryErr string
MailTransportKind string
MailTransportDesc string
MailDelivers bool
PasskeysUsable bool
// InsecureDefaults are the variables boot found still holding a published
// default value.
InsecureDefaults []string
Policy *config.AuthPolicy
WebsocketURL string
}
// Entry describes one self-host-relevant environment variable.
type Entry struct {
Key string
// Aliases are older names for the same setting. Any of them being set
// makes the source env.
Aliases []string
Group string
// RuntimeChangeable is ChangeBootOnly or ChangePerRequest.
RuntimeChangeable string
// Effect is one sentence saying what the resolved value does.
Effect string
DocsAnchor string
// WhenUnset is the source reported when no variable is set but a value
// still resolves. SourceDefault when empty.
WhenUnset string
Resolve func(rt *Runtime) string
}
// Resolved is one row of GET /admin/instance/config.
type Resolved struct {
Key string `json:"key"`
Value string `json:"value"`
// Source is one of the Source* constants.
Source string `json:"source"`
Sensitive bool `json:"sensitive"`
Set bool `json:"set"`
// Fingerprint is the first 4 hex characters of SHA-256 of a sensitive
// value, so two services can be compared without either being disclosed.
Fingerprint string `json:"fingerprint"`
Group string `json:"group"`
Effect string `json:"effect"`
Docs string `json:"docs"`
// RuntimeChangeable is one of the Change* constants.
RuntimeChangeable string `json:"runtime_changeable"`
}
// Entries resolves the whole table in declaration order.
func Entries(rt *Runtime) []Resolved {
out := make([]Resolved, 0, len(table))
for _, e := range table {
out = append(out, e.resolve(rt))
}
return out
}
func (e Entry) resolve(rt *Runtime) Resolved {
value := ""
if e.Resolve != nil {
value = strings.TrimSpace(e.Resolve(rt))
}
r := Resolved{
Key: e.Key,
Group: e.Group,
Effect: e.Effect,
Docs: e.DocsAnchor,
RuntimeChangeable: e.RuntimeChangeable,
Sensitive: Sensitive(e.Key),
Set: e.envSet(),
}
r.Source = e.source(r.Set, value)
// A sensitive value never leaves the process; the fingerprint is what an
// operator compares across services.
if r.Sensitive {
r.Fingerprint = Fingerprint(value)
return r
}
r.Value = value
return r
}
func (e Entry) envSet() bool {
if strings.TrimSpace(os.Getenv(e.Key)) != "" {
return true
}
for _, alias := range e.Aliases {
if strings.TrimSpace(os.Getenv(alias)) != "" {
return true
}
}
return false
}
func (e Entry) source(set bool, value string) string {
if set {
return SourceEnv
}
if value == "" {
return SourceUnset
}
if e.WhenUnset != "" {
return e.WhenUnset
}
return SourceDefault
}