feat(breakglass): 恢复模式用邮件验证码证明管理员身份,发不出或验不过走带原因审计的 OVERRIDE (#13)
This commit is contained in:
8 files changed
+1071
-134
No files matched your search
+61
-57
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"felis.lolicon.best/internal/api"
|
"felis.lolicon.best/internal/api"
|
||||||
"felis.lolicon.best/internal/config"
|
"felis.lolicon.best/internal/config"
|
||||||
|
"felis.lolicon.best/internal/platform"
|
||||||
"felis.lolicon.best/internal/store"
|
"felis.lolicon.best/internal/store"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -33,13 +34,15 @@ import (
|
|||||||
//
|
//
|
||||||
// Root is necessary but NOT sufficient for accountability: root is machine
|
// Root is necessary but NOT sufficient for accountability: root is machine
|
||||||
// authority, not a human identity, so the console additionally captures WHO is
|
// authority, not a human identity, so the console additionally captures WHO is
|
||||||
// breaking the glass. When a staff account already exists it asks the operator to
|
// breaking the glass. When a staff account already exists the operator names one
|
||||||
// authenticate as an existing admin (the verified identity is the accountable
|
// and types the one-time code the console mails to its verified address
|
||||||
// actor); when none exists yet it bootstraps the first Owner from the typed
|
// (breakglass_otp.go); that account is then the accountable actor. When no code can
|
||||||
// credential and attributes the act to the OS user. The audit row records the
|
// be sent or proven, the typed OVERRIDE proceeds as the OS user and the audit row
|
||||||
// difference. This attribution is best-effort, not tamper-proof — whoever runs
|
// says why. When no staff account exists yet it bootstraps the first Owner and
|
||||||
// this is root and can edit Postgres directly — but it produces an honest trail
|
// attributes the act to the OS user. The audit row records which of these
|
||||||
// for an honest operator, which is the point.
|
// happened. This attribution is best-effort, not tamper-proof — whoever runs this
|
||||||
|
// is root and can edit Postgres directly — but it produces an honest trail for an
|
||||||
|
// honest operator, which is the point.
|
||||||
//
|
//
|
||||||
// When a staff account already exists the console opens on a thin top-level menu
|
// When a staff account already exists the console opens on a thin top-level menu
|
||||||
// (menuModel) so that operations are peers, not tails of one wizard. Two account
|
// (menuModel) so that operations are peers, not tails of one wizard. Two account
|
||||||
@@ -62,7 +65,7 @@ import (
|
|||||||
// suspension for the interactive `cloudflared tunnel login` browser consent.
|
// suspension for the interactive `cloudflared tunnel login` browser consent.
|
||||||
|
|
||||||
// breakGlassOverrideToken is the literal an operator must type to proceed when no
|
// breakGlassOverrideToken is the literal an operator must type to proceed when no
|
||||||
// admin credential could be verified. Requiring an explicit, deliberate word (not a
|
// admin could be verified by a mailed code. Requiring an explicit, deliberate word (not a
|
||||||
// bare Enter) keeps the unverified root override from happening by reflex.
|
// bare Enter) keeps the unverified root override from happening by reflex.
|
||||||
const breakGlassOverrideToken = "OVERRIDE"
|
const breakGlassOverrideToken = "OVERRIDE"
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
|||||||
repo := api.NewPGRepo(drv.DB())
|
repo := api.NewPGRepo(drv.DB())
|
||||||
|
|
||||||
// Decide bootstrap (no admin yet → typed credential mints the first Owner) vs
|
// Decide bootstrap (no admin yet → typed credential mints the first Owner) vs
|
||||||
// recovery (an admin exists → the operator must authenticate as one) BEFORE the
|
// recovery (an admin exists → the operator proves one with a mailed code) BEFORE the
|
||||||
// alt-screen TUI takes over, so a database fault surfaces as a plain error.
|
// alt-screen TUI takes over, so a database fault surfaces as a plain error.
|
||||||
adminExists, err := repo.AdminExists(ctx)
|
adminExists, err := repo.AdminExists(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,7 +153,12 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists)
|
// Recovery mails its code through [smtp]; the relay is opened only if a code is
|
||||||
|
// asked for.
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
recovery := recoveryConfig{open: hostRecoveryMailer(cfg.SMTP, platform.DefaultControlNamespace), host: host}
|
||||||
|
|
||||||
|
res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists, recovery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -174,6 +182,9 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local session sign-in is ENABLED.\n", res.username)
|
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local session sign-in is ENABLED.\n", res.username)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
|
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
|
||||||
|
if res.mode == "root_override" {
|
||||||
|
fmt.Fprintln(stdout, "No admin was proven by an email code; the audit row records this run as an unverified root override and why.")
|
||||||
|
}
|
||||||
if res.setupTokenURL != "" {
|
if res.setupTokenURL != "" {
|
||||||
fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
|
fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
|
||||||
}
|
}
|
||||||
@@ -258,29 +269,6 @@ func newOwnerID() string {
|
|||||||
return "usr-" + hex.EncodeToString(b[:])
|
return "usr-" + hex.EncodeToString(b[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticateAdmin resolves a typed admin username for recovery-mode attribution.
|
|
||||||
// Password verification is gone (passwordless design); Phase 3 replaces this with
|
|
||||||
// email-OTP recovery. For now it confirms the named admin exists.
|
|
||||||
func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matched string, ok bool, err error) {
|
|
||||||
username = strings.TrimSpace(username)
|
|
||||||
if username == "" {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
u, err := s.UserByUsername(ctx, username)
|
|
||||||
if errors.Is(err, api.ErrNotFound) {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
// Staff means admin OR owner: recovery attribution must accept the Owner (the
|
|
||||||
// primary break-glass identity), not just plain admins.
|
|
||||||
if u.Role != "admin" && u.Role != "owner" {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
return u.Username, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// provisionOwner mints or resets the single Owner account direct-to-Postgres,
|
// provisionOwner mints or resets the single Owner account direct-to-Postgres,
|
||||||
// passwordless. The account is role=owner with no password — the Owner completes
|
// passwordless. The account is role=owner with no password — the Owner completes
|
||||||
// passwordless login setup via the web setup-token flow after `felis setup`.
|
// passwordless login setup via the web setup-token flow after `felis setup`.
|
||||||
@@ -371,6 +359,10 @@ type breakGlassOp struct {
|
|||||||
ownerUsername string
|
ownerUsername string
|
||||||
ownerEmail string
|
ownerEmail string
|
||||||
attemptedAdmin string // recovery / override: the admin username the operator typed
|
attemptedAdmin string // recovery / override: the admin username the operator typed
|
||||||
|
verifiedBy string // recovery: how the admin was proven (verifiedByEmailOTP)
|
||||||
|
codeSentTo string // recovery: the address the proving code went to
|
||||||
|
otpSkipped string // root_override: why no code proved an admin (otpSkip*)
|
||||||
|
otpSkipDetail string // root_override: what failed, when something did
|
||||||
}
|
}
|
||||||
|
|
||||||
// breakGlassOutcome is what performBreakGlass reports back to the TUI.
|
// breakGlassOutcome is what performBreakGlass reports back to the TUI.
|
||||||
@@ -494,16 +486,7 @@ func auditSetupMCBind(ctx context.Context, s ownerStore, osUser, mcUUID, authSou
|
|||||||
// does not fail the recovery if this write fails — and intentionally honest: it
|
// does not fail the recovery if this write fails — and intentionally honest: it
|
||||||
// records attribution, it does not prove it (a malicious root can edit the row).
|
// records attribution, it does not prove it (a malicious root can edit the row).
|
||||||
func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
||||||
payload := map[string]any{
|
blob, err := json.Marshal(breakGlassPayload(op, "owner"))
|
||||||
"mode": op.mode,
|
|
||||||
"owner": op.ownerUsername,
|
|
||||||
"os_user": op.osUser,
|
|
||||||
"verified": op.mode == "recovery",
|
|
||||||
}
|
|
||||||
if op.attemptedAdmin != "" {
|
|
||||||
payload["admin_account"] = op.attemptedAdmin
|
|
||||||
}
|
|
||||||
blob, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -515,6 +498,33 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// breakGlassPayload is the who/how both account audits carry, with the account the
|
||||||
|
// run wrote under subjectKey. verified is true only for a run a mailed code proved;
|
||||||
|
// such a run names the address the code went to, and an override names why no code
|
||||||
|
// proved anyone.
|
||||||
|
func breakGlassPayload(op breakGlassOp, subjectKey string) map[string]any {
|
||||||
|
payload := map[string]any{
|
||||||
|
"mode": op.mode,
|
||||||
|
subjectKey: op.ownerUsername,
|
||||||
|
"os_user": op.osUser,
|
||||||
|
"verified": op.verifiedBy != "",
|
||||||
|
}
|
||||||
|
if op.attemptedAdmin != "" {
|
||||||
|
payload["admin_account"] = op.attemptedAdmin
|
||||||
|
}
|
||||||
|
if op.verifiedBy != "" {
|
||||||
|
payload["verified_by"] = op.verifiedBy
|
||||||
|
payload["code_sent_to"] = op.codeSentTo
|
||||||
|
}
|
||||||
|
if op.otpSkipped != "" {
|
||||||
|
payload["otp_skipped"] = op.otpSkipped
|
||||||
|
if op.otpSkipDetail != "" {
|
||||||
|
payload["otp_skip_detail"] = op.otpSkipDetail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
// performAddOperator mints a NEW Operator account and records a best-effort
|
// performAddOperator mints a NEW Operator account and records a best-effort
|
||||||
// accountability row. It mirrors performBreakGlass — passwordless — with two
|
// accountability row. It mirrors performBreakGlass — passwordless — with two
|
||||||
// deliberate differences. (1) It provisions insert-only (provisionOperator), so it
|
// deliberate differences. (1) It provisions insert-only (provisionOperator), so it
|
||||||
@@ -538,16 +548,7 @@ func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (bre
|
|||||||
// break_glass.operator_create action, naming the new account under an "operator" key
|
// break_glass.operator_create action, naming the new account under an "operator" key
|
||||||
// rather than "owner".
|
// rather than "owner".
|
||||||
func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error {
|
||||||
payload := map[string]any{
|
blob, err := json.Marshal(breakGlassPayload(op, "operator"))
|
||||||
"mode": op.mode,
|
|
||||||
"operator": op.ownerUsername,
|
|
||||||
"os_user": op.osUser,
|
|
||||||
"verified": op.mode == "recovery",
|
|
||||||
}
|
|
||||||
if op.attemptedAdmin != "" {
|
|
||||||
payload["admin_account"] = op.attemptedAdmin
|
|
||||||
}
|
|
||||||
blob, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -626,16 +627,19 @@ const (
|
|||||||
cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/"
|
cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
|
func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, recovery recoveryConfig) (breakGlassResult, error) {
|
||||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass)
|
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass, recovery)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runSetupTUI never reaches recovery: setup with a staff account present lands on
|
||||||
|
// the status screen, so it has no relay to hand over.
|
||||||
func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
|
func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup)
|
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) {
|
func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) {
|
||||||
rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode)
|
rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode)
|
||||||
|
rm.recovery = recovery
|
||||||
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
|
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return breakGlassResult{}, err
|
return breakGlassResult{}, err
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/api"
|
||||||
|
"felis.lolicon.best/internal/config"
|
||||||
|
"felis.lolicon.best/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recovery mode proves who is breaking the glass (#13). Naming a staff account is
|
||||||
|
// where it starts: the console then mails a one-time code to that account's verified
|
||||||
|
// address, and only that code, typed within recoveryCodeTTL, makes the run a
|
||||||
|
// recovery attributed to the account. Every other ending — no such account, no
|
||||||
|
// verified address, no relay, a send that fails, a wrong or late code, or the
|
||||||
|
// operator giving up on the mail — leads to the typed OVERRIDE, which the audit row
|
||||||
|
// records as an unverified root_override together with the reason (otp_skipped).
|
||||||
|
// The code goes through the same [smtp] relay as the panel's login codes, so with
|
||||||
|
// that relay down recovery still works, as an override that says why.
|
||||||
|
|
||||||
|
const (
|
||||||
|
recoveryCodeTTL = 10 * time.Minute
|
||||||
|
recoveryCodeAttempts = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reasons a run fell back to the override, recorded as otp_skipped.
|
||||||
|
const (
|
||||||
|
otpSkipUnknownAdmin = "unknown_admin"
|
||||||
|
otpSkipNoVerifiedEmail = "no_verified_email"
|
||||||
|
otpSkipNoRelay = "no_relay"
|
||||||
|
otpSkipSendFailed = "send_failed"
|
||||||
|
otpSkipCodeExpired = "code_expired"
|
||||||
|
otpSkipCodeRejected = "code_rejected"
|
||||||
|
otpSkipByOperator = "operator_skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verifiedByEmailOTP is the audit's verified_by for a recovery the mailed code proved.
|
||||||
|
const verifiedByEmailOTP = "email_otp"
|
||||||
|
|
||||||
|
// recoveryMailer is the one relay call a recovery code needs; *mail.SMTP has it.
|
||||||
|
type recoveryMailer interface {
|
||||||
|
SendNotice(ctx context.Context, email, subject, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoveryConfig is what the console needs to mail a recovery code. open resolves
|
||||||
|
// the relay only when a code is about to go out, so a console used to halt a server
|
||||||
|
// never touches [smtp] or the cluster; its error says why no relay is available.
|
||||||
|
// host names this machine in the mail. The zero value has no relay.
|
||||||
|
type recoveryConfig struct {
|
||||||
|
open func(ctx context.Context) (recoveryMailer, error)
|
||||||
|
host string
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r recoveryConfig) clock() time.Time {
|
||||||
|
if r.now != nil {
|
||||||
|
return r.now()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoveryCode is one mailed code: its value, when it stops working, and how many
|
||||||
|
// wrong codes were typed against it.
|
||||||
|
type recoveryCode struct {
|
||||||
|
value string
|
||||||
|
expires time.Time
|
||||||
|
failures int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecoveryCode(now time.Time) (*recoveryCode, error) {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate recovery code: %w", err)
|
||||||
|
}
|
||||||
|
return &recoveryCode{value: fmt.Sprintf("%06d", n.Int64()), expires: now.Add(recoveryCodeTTL)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type codeVerdict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
codeAccepted codeVerdict = iota
|
||||||
|
codeWrong
|
||||||
|
codeExpired
|
||||||
|
codeExhausted
|
||||||
|
)
|
||||||
|
|
||||||
|
// check compares a typed code in constant time. Each wrong code counts; the one
|
||||||
|
// that reaches recoveryCodeAttempts exhausts the code, which then accepts nothing,
|
||||||
|
// and neither does an expired one.
|
||||||
|
func (c *recoveryCode) check(typed string, now time.Time) codeVerdict {
|
||||||
|
if c.failures >= recoveryCodeAttempts {
|
||||||
|
return codeExhausted
|
||||||
|
}
|
||||||
|
if !now.Before(c.expires) {
|
||||||
|
return codeExpired
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(typed)), []byte(c.value)) == 1 {
|
||||||
|
return codeAccepted
|
||||||
|
}
|
||||||
|
c.failures++
|
||||||
|
if c.failures >= recoveryCodeAttempts {
|
||||||
|
return codeExhausted
|
||||||
|
}
|
||||||
|
return codeWrong
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recoveryCode) attemptsLeft() int { return recoveryCodeAttempts - c.failures }
|
||||||
|
|
||||||
|
// recoveryStart is where naming an admin led: a code on its way to that admin, or
|
||||||
|
// the reason the run has to fall back to the override.
|
||||||
|
type recoveryStart struct {
|
||||||
|
admin *api.StaffUser // the named staff account; nil when none matched
|
||||||
|
code *recoveryCode // set when the code went out
|
||||||
|
skip string // otpSkip* when it did not
|
||||||
|
detail string // what failed, for the override screen and the audit row
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAdmin loads the staff account (admin or owner) a typed username names, or
|
||||||
|
// nil when there is none.
|
||||||
|
func resolveAdmin(ctx context.Context, s ownerStore, username string) (*api.StaffUser, error) {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if username == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
u, err := s.UserByUsername(ctx, username)
|
||||||
|
if errors.Is(err, api.ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Staff means admin or owner: the Owner is the primary break-glass identity.
|
||||||
|
if u.Role != "admin" && u.Role != "owner" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// beginRecovery resolves the named admin and mails it a recovery code. Only a
|
||||||
|
// datastore or entropy fault is an error; every other way the code cannot go out is
|
||||||
|
// a recoveryStart with skip set.
|
||||||
|
func beginRecovery(ctx context.Context, s ownerStore, rc recoveryConfig, username, osUser string, op bgOperation) (recoveryStart, error) {
|
||||||
|
admin, err := resolveAdmin(ctx, s, username)
|
||||||
|
if err != nil {
|
||||||
|
return recoveryStart{}, err
|
||||||
|
}
|
||||||
|
if admin == nil {
|
||||||
|
return recoveryStart{skip: otpSkipUnknownAdmin}, nil
|
||||||
|
}
|
||||||
|
st := recoveryStart{admin: admin}
|
||||||
|
// An address nobody ever proved vouches for nobody.
|
||||||
|
email := strings.TrimSpace(admin.Email)
|
||||||
|
if email == "" || !admin.EmailVerified {
|
||||||
|
st.skip = otpSkipNoVerifiedEmail
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
if rc.open == nil {
|
||||||
|
st.skip, st.detail = otpSkipNoRelay, "this console has no mail relay"
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
relay, err := rc.open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
st.skip, st.detail = otpSkipNoRelay, err.Error()
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
code, err := newRecoveryCode(rc.clock())
|
||||||
|
if err != nil {
|
||||||
|
return recoveryStart{}, err
|
||||||
|
}
|
||||||
|
sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
subject, body := recoveryMail(code.value, rc.host, osUser, admin.Username, op)
|
||||||
|
if err := relay.SendNotice(sendCtx, email, subject, body); err != nil {
|
||||||
|
st.skip, st.detail = otpSkipSendFailed, err.Error()
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
st.code = code
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoveryMail words the code mail. It says where, by whom and for what the console
|
||||||
|
// was opened, so an admin who did not ask for it learns that root on that machine is
|
||||||
|
// in someone else's hands.
|
||||||
|
func recoveryMail(code, host, osUser, admin string, op bgOperation) (subject, body string) {
|
||||||
|
what, whatZH := "reset the Owner account", "重置 Owner 账号"
|
||||||
|
if op == bgAddOperator {
|
||||||
|
what, whatZH = "add an Operator account", "添加 Operator 账号"
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
host = "the Felis host"
|
||||||
|
}
|
||||||
|
minutes := int(recoveryCodeTTL / time.Minute)
|
||||||
|
subject = "Felis break-glass recovery code / 紧急恢复验证码"
|
||||||
|
body = fmt.Sprintf(`Someone with root on %[1]s (OS user %[2]s) opened felis breakGlass and named your staff account %[3]q to %[4]s.
|
||||||
|
|
||||||
|
Recovery code: %[6]s
|
||||||
|
It works for %[7]d minutes.
|
||||||
|
|
||||||
|
If this was not you, root on that machine is in someone else's hands: change its credentials and read the audit log for break_glass entries.
|
||||||
|
|
||||||
|
有人在 %[1]s 上以 root 身份(系统用户 %[2]s)打开了 felis breakGlass,指名你的管理员账号 %[3]q 来%[5]s。
|
||||||
|
|
||||||
|
恢复验证码:%[6]s
|
||||||
|
%[7]d 分钟内有效。
|
||||||
|
|
||||||
|
如果不是你本人,这台机器的 root 已落入他人之手:请更换它的凭据,并查看审计日志中的 break_glass 记录。
|
||||||
|
`, host, osUser, admin, what, whatZH, code, minutes)
|
||||||
|
return subject, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskEmail keeps the first character of the local part and the domain, enough for
|
||||||
|
// the operator to recognise the address without putting it on screen whole.
|
||||||
|
func maskEmail(email string) string {
|
||||||
|
at := strings.LastIndex(email, "@")
|
||||||
|
if at <= 0 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return email[:1] + strings.Repeat("*", max(at-1, 3)) + email[at:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostRecoveryMailer opens the [smtp] relay from the host the way the watchdog does:
|
||||||
|
// the password is the env var password_ref names when that is set, else the
|
||||||
|
// felis-smtp Secret, whose absence means a relay without AUTH.
|
||||||
|
func hostRecoveryMailer(c config.SMTPConfig, controlNS string) func(context.Context) (recoveryMailer, error) {
|
||||||
|
return func(ctx context.Context) (recoveryMailer, error) {
|
||||||
|
if strings.TrimSpace(c.Host) == "" {
|
||||||
|
return nil, errors.New("[smtp] is not configured in felis.toml")
|
||||||
|
}
|
||||||
|
if ref := c.PasswordRef; ref != "" && os.Getenv(ref) != "" {
|
||||||
|
return smtpRelay(c, os.Getenv(ref)), nil
|
||||||
|
}
|
||||||
|
cl, err := buildSystemServerClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reach the cluster for the relay password: %w", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
password, err := smtpSecretPassword(ctx, cl, controlNS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read the relay password from %s/%s: %w", controlNS, platform.SMTPSecretName, err)
|
||||||
|
}
|
||||||
|
return smtpRelay(c, password), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/api"
|
||||||
|
"felis.lolicon.best/internal/config"
|
||||||
|
"felis.lolicon.best/internal/mail"
|
||||||
|
"felis.lolicon.best/internal/platform"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover the recovery proof (#13): the mailed code's rules, where
|
||||||
|
// naming an admin leads, what the mail says, how the console walks from a name to
|
||||||
|
// a proven (or overridden) run, and what the audit row then records. No mail
|
||||||
|
// leaves the process: the relay is a fake that keeps what it was handed.
|
||||||
|
|
||||||
|
type sentMail struct{ to, subject, body string }
|
||||||
|
|
||||||
|
type fakeRelay struct {
|
||||||
|
sent []sentMail
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRelay) SendNotice(_ context.Context, to, subject, body string) error {
|
||||||
|
if r.err != nil {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
r.sent = append(r.sent, sentMail{to, subject, body})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentCode pulls the code out of the one mail the relay carried.
|
||||||
|
func (r *fakeRelay) sentCode(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
if len(r.sent) != 1 {
|
||||||
|
t.Fatalf("relay carried %d mails, want 1", len(r.sent))
|
||||||
|
}
|
||||||
|
_, after, ok := strings.Cut(r.sent[0].body, "Recovery code: ")
|
||||||
|
if !ok || len(after) < 6 {
|
||||||
|
t.Fatalf("mail carries no recovery code:\n%s", r.sent[0].body)
|
||||||
|
}
|
||||||
|
return after[:6]
|
||||||
|
}
|
||||||
|
|
||||||
|
func relayConfig(r *fakeRelay, now func() time.Time) recoveryConfig {
|
||||||
|
return recoveryConfig{
|
||||||
|
open: func(context.Context) (recoveryMailer, error) { return r, nil },
|
||||||
|
host: "felis-host-1",
|
||||||
|
now: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifiedAdmin(username, email string) *api.StaffUser {
|
||||||
|
return &api.StaffUser{ID: "usr-" + username, Username: username, Role: "owner", Email: email, EmailVerified: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryCodeRules(t *testing.T) {
|
||||||
|
t0 := time.Date(2026, 9, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
t.Run("a fresh code is six digits and lives for the TTL", func(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
c, err := newRecoveryCode(t0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !isRecoveryCodeShape(c.value) {
|
||||||
|
t.Fatalf("code %q is not six digits", c.value)
|
||||||
|
}
|
||||||
|
if !c.expires.Equal(t0.Add(recoveryCodeTTL)) {
|
||||||
|
t.Fatalf("expires = %v, want %v", c.expires, t0.Add(recoveryCodeTTL))
|
||||||
|
}
|
||||||
|
seen[c.value] = true
|
||||||
|
}
|
||||||
|
if len(seen) < 2 {
|
||||||
|
t.Error("twenty codes were all the same")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the right code is accepted, surrounding space ignored", func(t *testing.T) {
|
||||||
|
c := &recoveryCode{value: "042917", expires: t0.Add(recoveryCodeTTL)}
|
||||||
|
if v := c.check(" 042917 ", t0); v != codeAccepted {
|
||||||
|
t.Errorf("check = %v, want accepted", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong codes count down and the last one exhausts it", func(t *testing.T) {
|
||||||
|
c := &recoveryCode{value: "042917", expires: t0.Add(recoveryCodeTTL)}
|
||||||
|
for i := 1; i < recoveryCodeAttempts; i++ {
|
||||||
|
if v := c.check("000000", t0); v != codeWrong {
|
||||||
|
t.Fatalf("wrong code %d: check = %v, want wrong", i, v)
|
||||||
|
}
|
||||||
|
if left := c.attemptsLeft(); left != recoveryCodeAttempts-i {
|
||||||
|
t.Fatalf("after %d wrong codes attemptsLeft = %d, want %d", i, left, recoveryCodeAttempts-i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.check("000000", t0); v != codeExhausted {
|
||||||
|
t.Fatalf("wrong code %d: check = %v, want exhausted", recoveryCodeAttempts, v)
|
||||||
|
}
|
||||||
|
if v := c.check("042917", t0); v != codeExhausted {
|
||||||
|
t.Errorf("the right code after exhaustion: check = %v, want exhausted", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an expired code accepts nothing", func(t *testing.T) {
|
||||||
|
c := &recoveryCode{value: "042917", expires: t0.Add(recoveryCodeTTL)}
|
||||||
|
if v := c.check("042917", t0.Add(recoveryCodeTTL-time.Second)); v != codeAccepted {
|
||||||
|
t.Fatalf("a second before expiry: check = %v, want accepted", v)
|
||||||
|
}
|
||||||
|
c = &recoveryCode{value: "042917", expires: t0.Add(recoveryCodeTTL)}
|
||||||
|
if v := c.check("042917", t0.Add(recoveryCodeTTL)); v != codeExpired {
|
||||||
|
t.Errorf("at expiry: check = %v, want expired", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeginRecovery(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
t0 := time.Date(2026, 9, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
clock := func() time.Time { return t0 }
|
||||||
|
|
||||||
|
t.Run("mails a code to the named admin's verified address", func(t *testing.T) {
|
||||||
|
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": verifiedAdmin("root", "[email protected]")}}
|
||||||
|
r := &fakeRelay{}
|
||||||
|
st, err := beginRecovery(ctx, f, relayConfig(r, clock), "root", "alice", bgAddOperator)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.code == nil || st.skip != "" {
|
||||||
|
t.Fatalf("start = %+v, want a code and no skip", st)
|
||||||
|
}
|
||||||
|
if st.admin == nil || st.admin.Username != "root" {
|
||||||
|
t.Fatalf("start.admin = %+v, want root", st.admin)
|
||||||
|
}
|
||||||
|
if got := r.sentCode(t); got != st.code.value {
|
||||||
|
t.Errorf("mailed code %q, want the code the console checks (%q)", got, st.code.value)
|
||||||
|
}
|
||||||
|
m := r.sent[0]
|
||||||
|
if m.to != "[email protected]" {
|
||||||
|
t.Errorf("mail went to %q, want [email protected]", m.to)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"felis-host-1", "OS user alice", `"root"`, "add an Operator account", "添加 Operator 账号", "10 minutes"} {
|
||||||
|
if !strings.Contains(m.body, want) {
|
||||||
|
t.Errorf("mail body lacks %q:\n%s", want, m.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !st.code.expires.Equal(t0.Add(recoveryCodeTTL)) {
|
||||||
|
t.Errorf("code expires %v, want %v", st.code.expires, t0.Add(recoveryCodeTTL))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the Owner reset is named as such", func(t *testing.T) {
|
||||||
|
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": verifiedAdmin("root", "[email protected]")}}
|
||||||
|
r := &fakeRelay{}
|
||||||
|
if _, err := beginRecovery(ctx, f, relayConfig(r, clock), "root", "alice", bgProvisionOwner); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body := r.sent[0].body; !strings.Contains(body, "reset the Owner account") || !strings.Contains(body, "重置 Owner 账号") {
|
||||||
|
t.Errorf("mail body does not name the Owner reset:\n%s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each way the code cannot go out ends in a skip reason and no mail.
|
||||||
|
unverified := verifiedAdmin("root", "[email protected]")
|
||||||
|
unverified.EmailVerified = false
|
||||||
|
noEmail := verifiedAdmin("root", "")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user *api.StaffUser
|
||||||
|
rc func(r *fakeRelay) recoveryConfig
|
||||||
|
skip string
|
||||||
|
detail string
|
||||||
|
wantAdmin bool
|
||||||
|
relayError error
|
||||||
|
}{
|
||||||
|
{name: "unknown admin", user: nil, rc: func(r *fakeRelay) recoveryConfig { return relayConfig(r, clock) }, skip: otpSkipUnknownAdmin},
|
||||||
|
{name: "unverified address", user: unverified, rc: func(r *fakeRelay) recoveryConfig { return relayConfig(r, clock) }, skip: otpSkipNoVerifiedEmail, wantAdmin: true},
|
||||||
|
{name: "no address", user: noEmail, rc: func(r *fakeRelay) recoveryConfig { return relayConfig(r, clock) }, skip: otpSkipNoVerifiedEmail, wantAdmin: true},
|
||||||
|
{name: "no relay wired", user: verifiedAdmin("root", "[email protected]"), rc: func(*fakeRelay) recoveryConfig { return recoveryConfig{} }, skip: otpSkipNoRelay, detail: "this console has no mail relay", wantAdmin: true},
|
||||||
|
{name: "relay cannot open", user: verifiedAdmin("root", "[email protected]"), rc: func(*fakeRelay) recoveryConfig {
|
||||||
|
return recoveryConfig{open: func(context.Context) (recoveryMailer, error) {
|
||||||
|
return nil, errors.New("[smtp] is not configured in felis.toml")
|
||||||
|
}}
|
||||||
|
}, skip: otpSkipNoRelay, detail: "[smtp] is not configured in felis.toml", wantAdmin: true},
|
||||||
|
{name: "send fails", user: verifiedAdmin("root", "[email protected]"), rc: func(r *fakeRelay) recoveryConfig { return relayConfig(r, clock) },
|
||||||
|
skip: otpSkipSendFailed, detail: "554 relay refused", wantAdmin: true, relayError: errors.New("554 relay refused")},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := &fakeOwnerStore{users: map[string]*api.StaffUser{}}
|
||||||
|
if tc.user != nil {
|
||||||
|
f.users["root"] = tc.user
|
||||||
|
}
|
||||||
|
r := &fakeRelay{err: tc.relayError}
|
||||||
|
st, err := beginRecovery(ctx, f, tc.rc(r), "root", "alice", bgProvisionOwner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.code != nil || st.skip != tc.skip || st.detail != tc.detail {
|
||||||
|
t.Errorf("start = {code:%v skip:%q detail:%q}, want no code, skip %q, detail %q", st.code, st.skip, st.detail, tc.skip, tc.detail)
|
||||||
|
}
|
||||||
|
if (st.admin != nil) != tc.wantAdmin {
|
||||||
|
t.Errorf("start.admin = %+v, want present=%v", st.admin, tc.wantAdmin)
|
||||||
|
}
|
||||||
|
if len(r.sent) != 0 {
|
||||||
|
t.Errorf("relay carried %d mails, want none", len(r.sent))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a datastore fault is an error", func(t *testing.T) {
|
||||||
|
f := &fakeOwnerStore{userErr: errors.New("db down")}
|
||||||
|
if _, err := beginRecovery(ctx, f, relayConfig(&fakeRelay{}, clock), "root", "alice", bgProvisionOwner); err == nil {
|
||||||
|
t.Fatal("want the store fault")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaskEmail(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"[email protected]": "a****@example.com",
|
||||||
|
"[email protected]": "a***@example.com",
|
||||||
|
"@example.com": "***",
|
||||||
|
"nonsense": "***",
|
||||||
|
} {
|
||||||
|
if got := maskEmail(in); got != want {
|
||||||
|
t.Errorf("maskEmail(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostRecoveryMailer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("no [smtp] host is no relay", func(t *testing.T) {
|
||||||
|
_, err := hostRecoveryMailer(config.SMTPConfig{}, "felis")(ctx)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "[smtp]") {
|
||||||
|
t.Fatalf("err = %v, want it to name [smtp]", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the password_ref env var supplies the password", func(t *testing.T) {
|
||||||
|
t.Setenv("FELIS_TEST_RELAY_PW", "from-env")
|
||||||
|
off := false
|
||||||
|
c := config.SMTPConfig{Host: "mail.example.com", Port: 2525, From: "[email protected]", Username: "felis", PasswordRef: "FELIS_TEST_RELAY_PW", RequireTLS: &off}
|
||||||
|
got, err := hostRecoveryMailer(c, "felis")(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
relay, ok := got.(*mail.SMTP)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("relay is %T, want *mail.SMTP", got)
|
||||||
|
}
|
||||||
|
if relay.Password != "from-env" || relay.Host != "mail.example.com" || relay.Port != 2525 || relay.From != "[email protected]" || relay.Username != "felis" || relay.RequireTLS {
|
||||||
|
t.Errorf("relay = %+v, want the [smtp] fields with the env password and TLS as configured", *relay)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSMTPSecretPassword(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
scheme := haltScheme(t)
|
||||||
|
|
||||||
|
t.Run("reads the password key", func(t *testing.T) {
|
||||||
|
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Namespace: "felis", Name: platform.SMTPSecretName},
|
||||||
|
Data: map[string][]byte{platform.SMTPSecretPasswordKey: []byte("s3cret")},
|
||||||
|
}).Build()
|
||||||
|
if pw, err := smtpSecretPassword(ctx, cl, "felis"); err != nil || pw != "s3cret" {
|
||||||
|
t.Errorf("smtpSecretPassword = (%q, %v), want (s3cret, nil)", pw, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a missing Secret is a relay without AUTH", func(t *testing.T) {
|
||||||
|
cl := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||||
|
if pw, err := smtpSecretPassword(ctx, cl, "felis"); err != nil || pw != "" {
|
||||||
|
t.Errorf("smtpSecretPassword = (%q, %v), want (\"\", nil)", pw, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("any other read failure is an error", func(t *testing.T) {
|
||||||
|
cl := fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{
|
||||||
|
Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
|
||||||
|
return apierrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, platform.SMTPSecretName, errors.New("rbac"))
|
||||||
|
},
|
||||||
|
}).Build()
|
||||||
|
if _, err := smtpSecretPassword(ctx, cl, "felis"); err == nil {
|
||||||
|
t.Fatal("want the read failure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoveryModel is an Owner-reset console for admin "root" (verified address
|
||||||
|
// [email protected]), run by OS user alice, with the fake relay behind it.
|
||||||
|
func recoveryModel(t *testing.T, f *fakeOwnerStore, r *fakeRelay, now func() time.Time) *ownerModel {
|
||||||
|
t.Helper()
|
||||||
|
if f.users == nil {
|
||||||
|
f.users = map[string]*api.StaffUser{"root": verifiedAdmin("root", "[email protected]")}
|
||||||
|
}
|
||||||
|
return newOwnerModel(context.Background(), f, "alice", true).withRecovery(relayConfig(r, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameAdmin submits the admin-name form and feeds the result of the send back in.
|
||||||
|
func nameAdmin(t *testing.T, m *ownerModel, name string) *ownerModel {
|
||||||
|
t.Helper()
|
||||||
|
m.authUser = name
|
||||||
|
_, cmd := m.onFormComplete()
|
||||||
|
if m.step != owWorking {
|
||||||
|
t.Fatalf("after naming the admin step = %v, want owWorking", m.step)
|
||||||
|
}
|
||||||
|
msg := findMsg[owAuthMsg](t, cmd)
|
||||||
|
next, _ := m.Update(msg)
|
||||||
|
return next.(*ownerModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMsg runs a (possibly batched) command and returns the first T it produces.
|
||||||
|
func findMsg[T any](t *testing.T, cmd tea.Cmd) T {
|
||||||
|
t.Helper()
|
||||||
|
var zero T
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatalf("no command, want one producing %T", zero)
|
||||||
|
}
|
||||||
|
switch msg := cmd().(type) {
|
||||||
|
case T:
|
||||||
|
return msg
|
||||||
|
case tea.BatchMsg:
|
||||||
|
for _, c := range msg {
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got, ok := c().(T); ok {
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("command produced no %T", zero)
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeCode submits the code form with typed.
|
||||||
|
func typeCode(t *testing.T, m *ownerModel, typed string) {
|
||||||
|
t.Helper()
|
||||||
|
if m.step != owCode {
|
||||||
|
t.Fatalf("step = %v, want owCode", m.step)
|
||||||
|
}
|
||||||
|
m.codeInput = typed
|
||||||
|
m.onFormComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// provisionAudit finishes the run as an Owner reset and returns its audit payload.
|
||||||
|
func provisionAudit(t *testing.T, m *ownerModel, f *fakeOwnerStore) (api.AuditEntry, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
if m.step != owProvision {
|
||||||
|
t.Fatalf("step = %v, want owProvision", m.step)
|
||||||
|
}
|
||||||
|
m.username = "owner"
|
||||||
|
msg := m.provisionCmd()().(owProvisionMsg)
|
||||||
|
if msg.err != nil {
|
||||||
|
t.Fatalf("provision: %v", msg.err)
|
||||||
|
}
|
||||||
|
return auditOf(t, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryConsoleFlow(t *testing.T) {
|
||||||
|
t0 := time.Date(2026, 9, 26, 8, 0, 0, 0, time.UTC)
|
||||||
|
fixed := func() time.Time { return t0 }
|
||||||
|
|
||||||
|
t.Run("the mailed code proves the admin and the audit says so", func(t *testing.T) {
|
||||||
|
f, r := &fakeOwnerStore{}, &fakeRelay{}
|
||||||
|
m := nameAdmin(t, recoveryModel(t, f, r, fixed), "root")
|
||||||
|
typeCode(t, m, r.sentCode(t))
|
||||||
|
if m.mode != "recovery" || m.accountable != "root" {
|
||||||
|
t.Fatalf("mode=%q accountable=%q, want recovery attributed to root", m.mode, m.accountable)
|
||||||
|
}
|
||||||
|
e, payload := provisionAudit(t, m, f)
|
||||||
|
if e.Actor != "root" || e.Action != "break_glass.recovery" {
|
||||||
|
t.Errorf("audit = %+v, want actor=root action=break_glass.recovery", e)
|
||||||
|
}
|
||||||
|
if payload["verified"] != true || payload["verified_by"] != verifiedByEmailOTP || payload["code_sent_to"] != "[email protected]" || payload["os_user"] != "alice" {
|
||||||
|
t.Errorf("payload = %v, want verified by email_otp to [email protected], os_user alice", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a wrong code asks again, and the last wrong one leads to the override", func(t *testing.T) {
|
||||||
|
f, r := &fakeOwnerStore{}, &fakeRelay{}
|
||||||
|
m := nameAdmin(t, recoveryModel(t, f, r, fixed), "root")
|
||||||
|
wrong := "000000"
|
||||||
|
if r.sentCode(t) == wrong {
|
||||||
|
wrong = "111111"
|
||||||
|
}
|
||||||
|
for i := 1; i < recoveryCodeAttempts; i++ {
|
||||||
|
typeCode(t, m, wrong)
|
||||||
|
if m.step != owCode || !strings.Contains(m.codeNote, "wrong") {
|
||||||
|
t.Fatalf("wrong code %d: step=%v note=%q, want the code form again with a note", i, m.step, m.codeNote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typeCode(t, m, wrong)
|
||||||
|
if m.step != owOverride || m.skip != otpSkipCodeRejected {
|
||||||
|
t.Fatalf("after %d wrong codes step=%v skip=%q, want the override for code_rejected", recoveryCodeAttempts, m.step, m.skip)
|
||||||
|
}
|
||||||
|
m.onFormComplete() // OVERRIDE typed
|
||||||
|
e, payload := provisionAudit(t, m, f)
|
||||||
|
if e.Actor != "alice" || e.Action != "break_glass.root_override" {
|
||||||
|
t.Errorf("audit = %+v, want actor=alice action=break_glass.root_override", e)
|
||||||
|
}
|
||||||
|
if payload["verified"] != false || payload["otp_skipped"] != otpSkipCodeRejected || payload["admin_account"] != "root" {
|
||||||
|
t.Errorf("payload = %v, want unverified, otp_skipped=code_rejected, admin_account=root", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a late code leads to the override", func(t *testing.T) {
|
||||||
|
now := t0
|
||||||
|
f, r := &fakeOwnerStore{}, &fakeRelay{}
|
||||||
|
m := nameAdmin(t, recoveryModel(t, f, r, func() time.Time { return now }), "root")
|
||||||
|
now = t0.Add(recoveryCodeTTL)
|
||||||
|
typeCode(t, m, r.sentCode(t))
|
||||||
|
if m.step != owOverride || m.skip != otpSkipCodeExpired {
|
||||||
|
t.Fatalf("step=%v skip=%q, want the override for code_expired", m.step, m.skip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OVERRIDE at the code prompt goes on unverified, saying the operator skipped", func(t *testing.T) {
|
||||||
|
f, r := &fakeOwnerStore{}, &fakeRelay{}
|
||||||
|
m := nameAdmin(t, recoveryModel(t, f, r, fixed), "root")
|
||||||
|
typeCode(t, m, breakGlassOverrideToken)
|
||||||
|
if m.mode != "root_override" || m.accountable != "alice" {
|
||||||
|
t.Fatalf("mode=%q accountable=%q, want root_override as alice", m.mode, m.accountable)
|
||||||
|
}
|
||||||
|
_, payload := provisionAudit(t, m, f)
|
||||||
|
if payload["verified"] != false || payload["otp_skipped"] != otpSkipByOperator {
|
||||||
|
t.Errorf("payload = %v, want unverified, otp_skipped=operator_skipped", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a relay failure leads to the override naming it", func(t *testing.T) {
|
||||||
|
f, r := &fakeOwnerStore{}, &fakeRelay{err: errors.New("dial tcp 10.0.0.9:587: connect: connection refused")}
|
||||||
|
m := nameAdmin(t, recoveryModel(t, f, r, fixed), "root")
|
||||||
|
if m.step != owOverride || m.skip != otpSkipSendFailed {
|
||||||
|
t.Fatalf("step=%v skip=%q, want the override for send_failed", m.step, m.skip)
|
||||||
|
}
|
||||||
|
if reason := m.overrideReason(); !strings.Contains(reason, "connection refused") {
|
||||||
|
t.Errorf("override reason %q does not name the failure", reason)
|
||||||
|
}
|
||||||
|
m.onFormComplete()
|
||||||
|
_, payload := provisionAudit(t, m, f)
|
||||||
|
if payload["otp_skipped"] != otpSkipSendFailed || !strings.Contains(payload["otp_skip_detail"].(string), "connection refused") {
|
||||||
|
t.Errorf("payload = %v, want otp_skipped=send_failed with the relay's error", payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("esc at the code prompt starts over and forgets the code", func(t *testing.T) {
|
||||||
|
f, r := &fakeOwnerStore{}, &fakeRelay{}
|
||||||
|
m := nameAdmin(t, recoveryModel(t, f, r, fixed), "root")
|
||||||
|
code := r.sentCode(t)
|
||||||
|
next, _ := m.Update(key(tea.KeyEsc))
|
||||||
|
m = next.(*ownerModel)
|
||||||
|
if m.step != owAuth || m.code != nil || m.admin != nil {
|
||||||
|
t.Fatalf("after esc step=%v code=%v admin=%v, want owAuth with the attempt forgotten", m.step, m.code, m.admin)
|
||||||
|
}
|
||||||
|
// The old code cannot be replayed: the next name mails a new one.
|
||||||
|
m = nameAdmin(t, m, "root")
|
||||||
|
if len(r.sent) != 2 {
|
||||||
|
t.Fatalf("relay carried %d mails, want a second one for the new attempt", len(r.sent))
|
||||||
|
}
|
||||||
|
_, fresh, _ := strings.Cut(r.sent[1].body, "Recovery code: ")
|
||||||
|
if m.code.value != fresh[:6] {
|
||||||
|
t.Errorf("the console checks %q, want the newly mailed %q (old one was %q)", m.code.value, fresh[:6], code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootHandsRecoveryToAccountOperations(t *testing.T) {
|
||||||
|
for _, op := range []bgOperation{bgProvisionOwner, bgAddOperator} {
|
||||||
|
m := newTestRoot(true, consoleModeBreakGlass, "")
|
||||||
|
m.recovery = recoveryConfig{host: "felis-host-1"}
|
||||||
|
m = drive(t, m, menuChoiceMsg{op: op})
|
||||||
|
om, ok := m.screen.(*ownerModel)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("op %v: screen = %T, want *ownerModel", op, m.screen)
|
||||||
|
}
|
||||||
|
if om.recovery.host != "felis-host-1" {
|
||||||
|
t.Errorf("op %v: the account screen has no relay config; its codes could never go out", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,71 +269,57 @@ func TestEnableLocalAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticateAdmin(t *testing.T) {
|
func TestResolveAdmin(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Password verification is gone (passwordless design): authenticateAdmin now only
|
// resolveAdmin only finds the staff account a typed name points at; proving the
|
||||||
// resolves the named admin so recovery can attribute the audit to a real identity.
|
// operator holds it is the mailed code's job (beginRecovery).
|
||||||
// The security boundary is the break-glass root gate, not a typed secret.
|
|
||||||
|
|
||||||
t.Run("resolves an existing admin for attribution", func(t *testing.T) {
|
t.Run("resolves an existing admin", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin("root")}}
|
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin("root")}}
|
||||||
matched, ok, err := authenticateAdmin(ctx, f, "root")
|
u, err := resolveAdmin(ctx, f, " root ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("authenticateAdmin: %v", err)
|
t.Fatalf("resolveAdmin: %v", err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if u == nil || u.Username != "root" {
|
||||||
t.Fatal("ok = false, want true for an existing admin")
|
t.Fatalf("resolveAdmin = %+v, want the root admin", u)
|
||||||
}
|
|
||||||
if matched != "root" {
|
|
||||||
t.Errorf("matched = %q, want root", matched)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("a non-admin role can never attribute a break-glass", func(t *testing.T) {
|
t.Run("a non-admin role is no staff account", func(t *testing.T) {
|
||||||
player := mkAdmin("alice")
|
player := mkAdmin("alice")
|
||||||
player.Role = "user" // a player row is not staff
|
player.Role = "user" // a player row is not staff
|
||||||
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"alice": player}}
|
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"alice": player}}
|
||||||
_, ok, err := authenticateAdmin(ctx, f, "alice")
|
if u, err := resolveAdmin(ctx, f, "alice"); u != nil || err != nil {
|
||||||
if err != nil {
|
t.Errorf("resolveAdmin(player) = (%+v, %v), want (nil, nil)", u, err)
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Error("ok = true, want false for a non-admin role")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("the owner role attributes like an admin", func(t *testing.T) {
|
t.Run("the owner role counts as staff", func(t *testing.T) {
|
||||||
owner := mkAdmin("root")
|
owner := mkAdmin("root")
|
||||||
owner.Role = "owner" // the platform owner is staff too (migration 0011)
|
owner.Role = "owner" // the platform owner is staff too (migration 0011)
|
||||||
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": owner}}
|
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": owner}}
|
||||||
matched, ok, err := authenticateAdmin(ctx, f, "root")
|
if u, err := resolveAdmin(ctx, f, "root"); err != nil || u == nil {
|
||||||
if err != nil || !ok || matched != "root" {
|
t.Fatalf("resolveAdmin(owner) = (%+v, %v), want the owner", u, err)
|
||||||
t.Fatalf("authenticateAdmin(owner) = (%q, %v, %v), want (root, true, nil)", matched, ok, err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("an unknown user is a non-match, not an error", func(t *testing.T) {
|
t.Run("an unknown user is nil, not an error", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{}
|
if u, err := resolveAdmin(ctx, &fakeOwnerStore{}, "nobody"); u != nil || err != nil {
|
||||||
_, ok, err := authenticateAdmin(ctx, f, "nobody")
|
t.Errorf("resolveAdmin(unknown) = (%+v, %v), want (nil, nil)", u, err)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
t.Error("ok = true, want false for an unknown user")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("an empty username is a non-match with no store call", func(t *testing.T) {
|
t.Run("an empty username makes no store call", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{userErr: errors.New("must not be called")}
|
f := &fakeOwnerStore{userErr: errors.New("must not be called")}
|
||||||
if _, ok, err := authenticateAdmin(ctx, f, ""); ok || err != nil {
|
if u, err := resolveAdmin(ctx, f, " "); u != nil || err != nil {
|
||||||
t.Errorf("empty username: ok=%v err=%v, want false,nil", ok, err)
|
t.Errorf("empty username: (%+v, %v), want (nil, nil)", u, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("a datastore fault is surfaced", func(t *testing.T) {
|
t.Run("a datastore fault is surfaced", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{userErr: errors.New("db down")}
|
f := &fakeOwnerStore{userErr: errors.New("db down")}
|
||||||
if _, _, err := authenticateAdmin(ctx, f, "root"); err == nil {
|
if _, err := resolveAdmin(ctx, f, "root"); err == nil {
|
||||||
t.Fatal("want error when the store fails")
|
t.Fatal("want error when the store fails")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -401,6 +387,8 @@ func TestPerformBreakGlass(t *testing.T) {
|
|||||||
osUser: "alice",
|
osUser: "alice",
|
||||||
ownerUsername: "owner",
|
ownerUsername: "owner",
|
||||||
attemptedAdmin: "root",
|
attemptedAdmin: "root",
|
||||||
|
verifiedBy: verifiedByEmailOTP,
|
||||||
|
codeSentTo: "[email protected]",
|
||||||
}
|
}
|
||||||
out, err := performBreakGlass(ctx, f, op)
|
out, err := performBreakGlass(ctx, f, op)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -425,6 +413,23 @@ func TestPerformBreakGlass(t *testing.T) {
|
|||||||
if payload["admin_account"] != "root" {
|
if payload["admin_account"] != "root" {
|
||||||
t.Errorf("payload.admin_account = %v, want root", payload["admin_account"])
|
t.Errorf("payload.admin_account = %v, want root", payload["admin_account"])
|
||||||
}
|
}
|
||||||
|
if payload["verified_by"] != verifiedByEmailOTP || payload["code_sent_to"] != "[email protected]" {
|
||||||
|
t.Errorf("payload = %v, want verified_by=email_otp [email protected]", payload)
|
||||||
|
}
|
||||||
|
if _, present := payload["otp_skipped"]; present {
|
||||||
|
t.Error("a proven recovery carries no otp_skipped")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("recovery without a proof is recorded unverified", func(t *testing.T) {
|
||||||
|
f := &fakeOwnerStore{}
|
||||||
|
op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"}
|
||||||
|
if _, err := performBreakGlass(ctx, f, op); err != nil {
|
||||||
|
t.Fatalf("performBreakGlass: %v", err)
|
||||||
|
}
|
||||||
|
if _, payload := auditOf(t, f); payload["verified"] != false {
|
||||||
|
t.Errorf("payload.verified = %v, want false: only a mailed code verifies", payload["verified"])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("root override records an unverified row attributed to the OS user", func(t *testing.T) {
|
t.Run("root override records an unverified row attributed to the OS user", func(t *testing.T) {
|
||||||
@@ -435,6 +440,8 @@ func TestPerformBreakGlass(t *testing.T) {
|
|||||||
osUser: "alice",
|
osUser: "alice",
|
||||||
ownerUsername: "owner",
|
ownerUsername: "owner",
|
||||||
attemptedAdmin: "typo-admin",
|
attemptedAdmin: "typo-admin",
|
||||||
|
otpSkipped: otpSkipSendFailed,
|
||||||
|
otpSkipDetail: "dial tcp: connection refused",
|
||||||
}
|
}
|
||||||
if _, err := performBreakGlass(ctx, f, op); err != nil {
|
if _, err := performBreakGlass(ctx, f, op); err != nil {
|
||||||
t.Fatalf("performBreakGlass: %v", err)
|
t.Fatalf("performBreakGlass: %v", err)
|
||||||
@@ -450,6 +457,13 @@ func TestPerformBreakGlass(t *testing.T) {
|
|||||||
if payload["admin_account"] != "typo-admin" {
|
if payload["admin_account"] != "typo-admin" {
|
||||||
t.Errorf("payload.admin_account = %v, want typo-admin", payload["admin_account"])
|
t.Errorf("payload.admin_account = %v, want typo-admin", payload["admin_account"])
|
||||||
}
|
}
|
||||||
|
// Why no code proved anyone is part of the record.
|
||||||
|
if payload["otp_skipped"] != otpSkipSendFailed || payload["otp_skip_detail"] != "dial tcp: connection refused" {
|
||||||
|
t.Errorf("payload = %v, want otp_skipped=send_failed with its detail", payload)
|
||||||
|
}
|
||||||
|
if _, present := payload["verified_by"]; present {
|
||||||
|
t.Error("an override carries no verified_by")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("an audit failure does not fail the recovery", func(t *testing.T) {
|
t.Run("an audit failure does not fail the recovery", func(t *testing.T) {
|
||||||
@@ -616,6 +630,8 @@ func TestPerformAddOperator(t *testing.T) {
|
|||||||
ownerUsername: "ops-jordan",
|
ownerUsername: "ops-jordan",
|
||||||
ownerEmail: "[email protected]",
|
ownerEmail: "[email protected]",
|
||||||
attemptedAdmin: "root",
|
attemptedAdmin: "root",
|
||||||
|
verifiedBy: verifiedByEmailOTP,
|
||||||
|
codeSentTo: "[email protected]",
|
||||||
}
|
}
|
||||||
out, err := performAddOperator(ctx, f, op)
|
out, err := performAddOperator(ctx, f, op)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -642,14 +658,14 @@ func TestPerformAddOperator(t *testing.T) {
|
|||||||
if _, present := payload["owner"]; present {
|
if _, present := payload["owner"]; present {
|
||||||
t.Error("payload.owner present, want the new account under the operator key")
|
t.Error("payload.owner present, want the new account under the operator key")
|
||||||
}
|
}
|
||||||
if payload["verified"] != true || payload["admin_account"] != "root" {
|
if payload["verified"] != true || payload["admin_account"] != "root" || payload["verified_by"] != verifiedByEmailOTP {
|
||||||
t.Errorf("payload = %v, want verified=true admin_account=root", payload)
|
t.Errorf("payload = %v, want verified=true admin_account=root verified_by=email_otp", payload)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("root override records an unverified operator row", func(t *testing.T) {
|
t.Run("root override records an unverified operator row", func(t *testing.T) {
|
||||||
f := &fakeOwnerStore{}
|
f := &fakeOwnerStore{}
|
||||||
op := breakGlassOp{mode: "root_override", accountable: "alice", osUser: "alice", ownerUsername: "ops", attemptedAdmin: "typo-admin"}
|
op := breakGlassOp{mode: "root_override", accountable: "alice", osUser: "alice", ownerUsername: "ops", attemptedAdmin: "typo-admin", otpSkipped: otpSkipUnknownAdmin}
|
||||||
if _, err := performAddOperator(ctx, f, op); err != nil {
|
if _, err := performAddOperator(ctx, f, op); err != nil {
|
||||||
t.Fatalf("performAddOperator: %v", err)
|
t.Fatalf("performAddOperator: %v", err)
|
||||||
}
|
}
|
||||||
@@ -657,8 +673,8 @@ func TestPerformAddOperator(t *testing.T) {
|
|||||||
t.Fatalf("want 1 insert, got %d", len(f.inserts))
|
t.Fatalf("want 1 insert, got %d", len(f.inserts))
|
||||||
}
|
}
|
||||||
_, payload := auditOf(t, f)
|
_, payload := auditOf(t, f)
|
||||||
if payload["verified"] != false {
|
if payload["verified"] != false || payload["otp_skipped"] != otpSkipUnknownAdmin {
|
||||||
t.Errorf("payload.verified = %v, want false for root_override", payload["verified"])
|
t.Errorf("payload = %v, want verified=false otp_skipped=unknown_admin", payload)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+147
-28
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/api"
|
"felis.lolicon.best/internal/api"
|
||||||
|
|
||||||
@@ -14,9 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type owAuthMsg struct {
|
type owAuthMsg struct {
|
||||||
matched string
|
start recoveryStart
|
||||||
ok bool
|
err error
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type owProvisionMsg struct {
|
type owProvisionMsg struct {
|
||||||
@@ -29,14 +29,15 @@ type owStep int
|
|||||||
const (
|
const (
|
||||||
owAuth owStep = iota
|
owAuth owStep = iota
|
||||||
owOverride
|
owOverride
|
||||||
|
owCode // typing the recovery code mailed to the named admin
|
||||||
owProvision
|
owProvision
|
||||||
owWorking
|
owWorking
|
||||||
owDone
|
owDone
|
||||||
owError
|
owError
|
||||||
)
|
)
|
||||||
|
|
||||||
// ownerModel collects the owner account. The input phases (admin auth, root
|
// ownerModel collects the owner account. The input phases (admin name, recovery
|
||||||
// override, owner details) are huh forms; the async phases (verifying,
|
// code, root override, owner details) are huh forms; the async phases (verifying,
|
||||||
// provisioning) show a spinner; the done phase shows the credential card. The
|
// provisioning) show a spinner; the done phase shows the credential card. The
|
||||||
// outward contract is unchanged: it emits an ownerResultMsg when finished.
|
// outward contract is unchanged: it emits an ownerResultMsg when finished.
|
||||||
//
|
//
|
||||||
@@ -55,6 +56,18 @@ type ownerModel struct {
|
|||||||
mode string // "bootstrap", "recovery", "root_override"
|
mode string // "bootstrap", "recovery", "root_override"
|
||||||
accountable string
|
accountable string
|
||||||
attempt string
|
attempt string
|
||||||
|
recovery recoveryConfig
|
||||||
|
|
||||||
|
// Recovery proof: the admin the typed name resolved to, the code mailed to it,
|
||||||
|
// and once settled either how it was proven or why the run fell back to the
|
||||||
|
// override.
|
||||||
|
admin *api.StaffUser
|
||||||
|
code *recoveryCode
|
||||||
|
codeNote string // "wrong code" line shown above a rebuilt code form
|
||||||
|
verifiedBy string
|
||||||
|
codeSentTo string
|
||||||
|
skip string // otpSkip*
|
||||||
|
skipDetail string
|
||||||
|
|
||||||
step owStep
|
step owStep
|
||||||
form *huh.Form
|
form *huh.Form
|
||||||
@@ -66,6 +79,7 @@ type ownerModel struct {
|
|||||||
|
|
||||||
// huh-bound form values
|
// huh-bound form values
|
||||||
authUser string
|
authUser string
|
||||||
|
codeInput string
|
||||||
overrideTok string
|
overrideTok string
|
||||||
ownerUser string
|
ownerUser string
|
||||||
ownerEmail string
|
ownerEmail string
|
||||||
@@ -124,6 +138,12 @@ func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *own
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withRecovery hands the model the relay its recovery codes go through.
|
||||||
|
func (m *ownerModel) withRecovery(r recoveryConfig) *ownerModel {
|
||||||
|
m.recovery = r
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ownerModel) Init() tea.Cmd { return m.form.Init() }
|
func (m *ownerModel) Init() tea.Cmd { return m.form.Init() }
|
||||||
|
|
||||||
// subject is the human label for the account being provisioned, branching every
|
// subject is the human label for the account being provisioned, branching every
|
||||||
@@ -152,7 +172,7 @@ func (m *ownerModel) sized(f *huh.Form) *huh.Form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *ownerModel) isFormStep() bool {
|
func (m *ownerModel) isFormStep() bool {
|
||||||
return m.step == owAuth || m.step == owOverride || m.step == owProvision
|
return m.step == owAuth || m.step == owOverride || m.step == owCode || m.step == owProvision
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -161,16 +181,15 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
return m, m.failCmd(msg.err)
|
return m, m.failCmd(msg.err)
|
||||||
}
|
}
|
||||||
if msg.ok {
|
m.admin = msg.start.admin
|
||||||
m.mode = "recovery"
|
if msg.start.code != nil {
|
||||||
m.accountable = msg.matched
|
m.code = msg.start.code
|
||||||
m.step = owProvision
|
m.codeNote = ""
|
||||||
m.form = m.sized(m.buildProvisionForm())
|
m.step = owCode
|
||||||
|
m.form = m.sized(m.buildCodeForm())
|
||||||
return m, m.form.Init()
|
return m, m.form.Init()
|
||||||
}
|
}
|
||||||
m.step = owOverride
|
return m.toOverride(msg.start.skip, msg.start.detail)
|
||||||
m.form = m.sized(m.buildOverrideForm())
|
|
||||||
return m, m.form.Init()
|
|
||||||
|
|
||||||
case owProvisionMsg:
|
case owProvisionMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
@@ -221,7 +240,9 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.step == owOverride {
|
if m.step == owOverride || m.step == owCode {
|
||||||
|
// Start over: a code mailed for the old attempt dies with it.
|
||||||
|
m.admin, m.code, m.skip, m.skipDetail = nil, nil, "", ""
|
||||||
m.step = owAuth
|
m.step = owAuth
|
||||||
m.form = m.sized(m.buildAuthForm())
|
m.form = m.sized(m.buildAuthForm())
|
||||||
return m, m.form.Init()
|
return m, m.form.Init()
|
||||||
@@ -253,18 +274,41 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
|
|||||||
case owAuth:
|
case owAuth:
|
||||||
m.attempt = strings.TrimSpace(m.authUser)
|
m.attempt = strings.TrimSpace(m.authUser)
|
||||||
m.step = owWorking
|
m.step = owWorking
|
||||||
m.working = "Verifying admin…"
|
m.working = "Sending a recovery code…"
|
||||||
user := m.authUser
|
user, rc, osUser, op := m.authUser, m.recovery, m.osUser, m.operation
|
||||||
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
||||||
matched, ok, err := authenticateAdmin(m.ctx, m.store, user)
|
start, err := beginRecovery(m.ctx, m.store, rc, user, osUser, op)
|
||||||
return owAuthMsg{matched: matched, ok: ok, err: err}
|
return owAuthMsg{start: start, err: err}
|
||||||
})
|
})
|
||||||
|
case owCode:
|
||||||
|
typed := strings.TrimSpace(m.codeInput)
|
||||||
|
m.codeInput = ""
|
||||||
|
if typed == breakGlassOverrideToken {
|
||||||
|
m.skip, m.skipDetail = otpSkipByOperator, ""
|
||||||
|
m.code = nil
|
||||||
|
return m.proceedAsRoot()
|
||||||
|
}
|
||||||
|
switch m.code.check(typed, m.recovery.clock()) {
|
||||||
|
case codeAccepted:
|
||||||
|
m.mode = "recovery"
|
||||||
|
m.accountable = m.admin.Username
|
||||||
|
m.verifiedBy = verifiedByEmailOTP
|
||||||
|
m.codeSentTo = m.admin.Email
|
||||||
|
m.code = nil
|
||||||
|
m.step = owProvision
|
||||||
|
m.form = m.sized(m.buildProvisionForm())
|
||||||
|
return m, m.form.Init()
|
||||||
|
case codeWrong:
|
||||||
|
m.codeNote = fmt.Sprintf("That code is wrong. %d attempts left.", m.code.attemptsLeft())
|
||||||
|
m.form = m.sized(m.buildCodeForm())
|
||||||
|
return m, m.form.Init()
|
||||||
|
case codeExpired:
|
||||||
|
return m.toOverride(otpSkipCodeExpired, "")
|
||||||
|
default:
|
||||||
|
return m.toOverride(otpSkipCodeRejected, fmt.Sprintf("%d wrong codes", recoveryCodeAttempts))
|
||||||
|
}
|
||||||
case owOverride:
|
case owOverride:
|
||||||
m.mode = "root_override"
|
return m.proceedAsRoot()
|
||||||
m.accountable = m.osUser
|
|
||||||
m.step = owProvision
|
|
||||||
m.form = m.sized(m.buildProvisionForm())
|
|
||||||
return m, m.form.Init()
|
|
||||||
case owProvision:
|
case owProvision:
|
||||||
m.username = strings.TrimSpace(m.ownerUser)
|
m.username = strings.TrimSpace(m.ownerUser)
|
||||||
m.step = owWorking
|
m.step = owWorking
|
||||||
@@ -274,6 +318,24 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toOverride records why no code proved an admin and asks for the typed OVERRIDE.
|
||||||
|
func (m *ownerModel) toOverride(skip, detail string) (tea.Model, tea.Cmd) {
|
||||||
|
m.skip, m.skipDetail = skip, detail
|
||||||
|
m.code = nil
|
||||||
|
m.step = owOverride
|
||||||
|
m.form = m.sized(m.buildOverrideForm())
|
||||||
|
return m, m.form.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// proceedAsRoot is the typed OVERRIDE: the run goes on as the OS user, unverified.
|
||||||
|
func (m *ownerModel) proceedAsRoot() (tea.Model, tea.Cmd) {
|
||||||
|
m.mode = "root_override"
|
||||||
|
m.accountable = m.osUser
|
||||||
|
m.step = owProvision
|
||||||
|
m.form = m.sized(m.buildProvisionForm())
|
||||||
|
return m, m.form.Init()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ownerModel) provisionCmd() tea.Cmd {
|
func (m *ownerModel) provisionCmd() tea.Cmd {
|
||||||
op := breakGlassOp{
|
op := breakGlassOp{
|
||||||
mode: m.mode,
|
mode: m.mode,
|
||||||
@@ -282,6 +344,10 @@ func (m *ownerModel) provisionCmd() tea.Cmd {
|
|||||||
ownerUsername: m.username,
|
ownerUsername: m.username,
|
||||||
ownerEmail: m.ownerEmail,
|
ownerEmail: m.ownerEmail,
|
||||||
attemptedAdmin: m.attempt,
|
attemptedAdmin: m.attempt,
|
||||||
|
verifiedBy: m.verifiedBy,
|
||||||
|
codeSentTo: m.codeSentTo,
|
||||||
|
otpSkipped: m.skip,
|
||||||
|
otpSkipDetail: m.skipDetail,
|
||||||
}
|
}
|
||||||
// performAddOperator and performBreakGlass share a signature; the operation
|
// performAddOperator and performBreakGlass share a signature; the operation
|
||||||
// discriminator selects which one runs. The operator path is insert-only and
|
// discriminator selects which one runs. The operator path is insert-only and
|
||||||
@@ -320,7 +386,7 @@ func (m *ownerModel) buildAuthForm() *huh.Form {
|
|||||||
return m.sized(newFelisForm(huh.NewGroup(
|
return m.sized(newFelisForm(huh.NewGroup(
|
||||||
huh.NewNote().
|
huh.NewNote().
|
||||||
Title("Admin authentication").
|
Title("Admin authentication").
|
||||||
Description("A staff account already exists. Identify yourself to continue."),
|
Description("A staff account already exists. Name yours: a one-time code goes to its verified email address."),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Admin username").
|
Title("Admin username").
|
||||||
Value(&m.authUser).
|
Value(&m.authUser).
|
||||||
@@ -328,11 +394,64 @@ func (m *ownerModel) buildAuthForm() *huh.Form {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ownerModel) buildCodeForm() *huh.Form {
|
||||||
|
desc := fmt.Sprintf("A recovery code went to %s, the verified address of %q. It works for %d minutes.\n\n"+
|
||||||
|
"No mail? Type %s to go on as OS user %q with root authority; the audit log records that as an unverified override. Esc starts over.",
|
||||||
|
maskEmail(m.admin.Email), m.admin.Username, int(recoveryCodeTTL/time.Minute), breakGlassOverrideToken, m.osUser)
|
||||||
|
if m.codeNote != "" {
|
||||||
|
desc = m.codeNote + "\n\n" + desc
|
||||||
|
}
|
||||||
|
return m.sized(newFelisForm(huh.NewGroup(
|
||||||
|
huh.NewNote().Title("Email verification").Description(desc),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Recovery code").
|
||||||
|
Value(&m.codeInput).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == breakGlassOverrideToken || isRecoveryCodeShape(s) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("enter the 6-digit code, or " + breakGlassOverrideToken)
|
||||||
|
}),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecoveryCodeShape(s string) bool {
|
||||||
|
if len(s) != 6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// overrideReason says why no code proved an admin, first line of the override form.
|
||||||
|
func (m *ownerModel) overrideReason() string {
|
||||||
|
switch m.skip {
|
||||||
|
case otpSkipUnknownAdmin:
|
||||||
|
return fmt.Sprintf("No staff account is named %q.", m.attempt)
|
||||||
|
case otpSkipNoVerifiedEmail:
|
||||||
|
return fmt.Sprintf("%q has no verified email address, so no recovery code can reach it.", m.admin.Username)
|
||||||
|
case otpSkipNoRelay:
|
||||||
|
return "No mail relay can send a recovery code: " + m.skipDetail + "."
|
||||||
|
case otpSkipSendFailed:
|
||||||
|
return "The recovery code could not be sent: " + m.skipDetail + "."
|
||||||
|
case otpSkipCodeExpired:
|
||||||
|
return "The recovery code expired."
|
||||||
|
case otpSkipCodeRejected:
|
||||||
|
return fmt.Sprintf("%d wrong codes; that code no longer works.", recoveryCodeAttempts)
|
||||||
|
}
|
||||||
|
return "No admin was verified."
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ownerModel) buildOverrideForm() *huh.Form {
|
func (m *ownerModel) buildOverrideForm() *huh.Form {
|
||||||
return m.sized(newFelisForm(huh.NewGroup(
|
return m.sized(newFelisForm(huh.NewGroup(
|
||||||
huh.NewNote().
|
huh.NewNote().
|
||||||
Title("Root override").
|
Title("Root override").
|
||||||
Description(fmt.Sprintf("That credential did not match. Proceed as OS user %q with root authority by typing the confirmation token.", m.osUser)),
|
Description(m.overrideReason()+"\n\n"+fmt.Sprintf("Proceed as OS user %q with root authority by typing the confirmation token. The audit log records this run as an unverified root override and the reason above. Esc starts over.", m.osUser)),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Type "+breakGlassOverrideToken+" to confirm").
|
Title("Type "+breakGlassOverrideToken+" to confirm").
|
||||||
Value(&m.overrideTok).
|
Value(&m.overrideTok).
|
||||||
@@ -352,14 +471,14 @@ func (m *ownerModel) buildProvisionForm() *huh.Form {
|
|||||||
desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
|
desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case "recovery":
|
case "recovery":
|
||||||
desc = fmt.Sprintf("Authenticated as %q.", m.accountable)
|
desc = fmt.Sprintf("Verified as %q by an email code.", m.accountable)
|
||||||
case "root_override":
|
case "root_override":
|
||||||
desc = "Root override — the Owner will be reset."
|
desc = "Root override — the Owner will be reset."
|
||||||
}
|
}
|
||||||
if m.operation == bgAddOperator {
|
if m.operation == bgAddOperator {
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case "recovery":
|
case "recovery":
|
||||||
desc = fmt.Sprintf("Add an Operator — authenticated as %q.", m.accountable)
|
desc = fmt.Sprintf("Add an Operator — verified as %q by an email code.", m.accountable)
|
||||||
case "root_override":
|
case "root_override":
|
||||||
desc = "Add an Operator (root override)."
|
desc = "Add an Operator (root override)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ type rootModel struct {
|
|||||||
accessAud string
|
accessAud string
|
||||||
namespace string // minecraft workload namespace (cfg.K8s.Namespace); target of the halt op
|
namespace string // minecraft workload namespace (cfg.K8s.Namespace); target of the halt op
|
||||||
adminExists bool
|
adminExists bool
|
||||||
|
recovery recoveryConfig // how the account operations mail a recovery code
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) *rootModel {
|
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) *rootModel {
|
||||||
@@ -221,7 +222,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.stage = stageOwner
|
m.stage = stageOwner
|
||||||
switch msg.op {
|
switch msg.op {
|
||||||
case bgAddOperator:
|
case bgAddOperator:
|
||||||
return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser))
|
return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser).withRecovery(m.recovery))
|
||||||
case bgHaltServer:
|
case bgHaltServer:
|
||||||
return m.adopt(newHaltModel(m.ctx, m.store, m.namespace, m.osUser))
|
return m.adopt(newHaltModel(m.ctx, m.store, m.namespace, m.osUser))
|
||||||
case bgSyncBackup:
|
case bgSyncBackup:
|
||||||
@@ -229,7 +230,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Service + service token the peer dials live in the control namespace.
|
// Service + service token the peer dials live in the control namespace.
|
||||||
return m.adopt(newBackupModel(m.ctx, m.namespace, platform.DefaultControlNamespace, m.osUser))
|
return m.adopt(newBackupModel(m.ctx, m.namespace, platform.DefaultControlNamespace, m.osUser))
|
||||||
default:
|
default:
|
||||||
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists))
|
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists).withRecovery(m.recovery))
|
||||||
}
|
}
|
||||||
|
|
||||||
case haltResultMsg:
|
case haltResultMsg:
|
||||||
|
|||||||
+17
-7
@@ -187,16 +187,26 @@ func usesMirroredScanDB(cfg *config.Config) bool {
|
|||||||
// forgets it when the Secret is gone (a relay without AUTH). An env var named by
|
// forgets it when the Secret is gone (a relay without AUTH). An env var named by
|
||||||
// [smtp] password_ref, when set, wins at send time instead.
|
// [smtp] password_ref, when set, wins at send time instead.
|
||||||
func refreshSMTPPassword(ctx context.Context, cl client.Client, ns string, state *watchdog.State, stderr io.Writer) {
|
func refreshSMTPPassword(ctx context.Context, cl client.Client, ns string, state *watchdog.State, stderr io.Writer) {
|
||||||
|
password, err := smtpSecretPassword(ctx, cl, ns)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis watchdog: read %s/%s (keeping the cached relay password): %v\n", ns, platform.SMTPSecretName, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.SMTPPassword = password
|
||||||
|
}
|
||||||
|
|
||||||
|
// smtpSecretPassword reads the relay password from the felis-smtp Secret. A missing
|
||||||
|
// Secret is a relay without AUTH and reads as "".
|
||||||
|
func smtpSecretPassword(ctx context.Context, cl client.Client, ns string) (string, error) {
|
||||||
var sec corev1.Secret
|
var sec corev1.Secret
|
||||||
err := cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: platform.SMTPSecretName}, &sec)
|
err := cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: platform.SMTPSecretName}, &sec)
|
||||||
switch {
|
if apierrors.IsNotFound(err) {
|
||||||
case apierrors.IsNotFound(err):
|
return "", nil
|
||||||
state.SMTPPassword = ""
|
|
||||||
case err != nil:
|
|
||||||
fmt.Fprintf(stderr, "felis watchdog: read %s/%s (keeping the cached relay password): %v\n", ns, platform.SMTPSecretName, err)
|
|
||||||
default:
|
|
||||||
state.SMTPPassword = string(sec.Data[platform.SMTPSecretPasswordKey])
|
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(sec.Data[platform.SMTPSecretPasswordKey]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownerEmails pings PostgreSQL and returns the verified addresses of the
|
// ownerEmails pings PostgreSQL and returns the verified addresses of the
|
||||||
|
|||||||
@@ -2270,6 +2270,42 @@ sudo felis db audit-export -until 2026-01-01 -out /root/audit-2025.jsonl
|
|||||||
window is `[since, until)`. Each line is one row as JSON, oldest first. The
|
window is `[since, until)`. Each line is one row as JSON, oldest first. The
|
||||||
file is created `0600` and an existing file is never overwritten.
|
file is created `0600` and an existing file is never overwritten.
|
||||||
|
|
||||||
|
### `felis breakGlass`: the recovery code and the OVERRIDE [VM-VERIFIED]
|
||||||
|
|
||||||
|
Once a staff account exists, `sudo felis breakGlass` asks which admin or owner
|
||||||
|
is breaking the glass and mails that account's verified address a six-digit
|
||||||
|
code through the same `[smtp]` relay as the sign-in codes. The code works for
|
||||||
|
10 minutes and five wrong ones end it. The console reads the relay password
|
||||||
|
the way the watchdog does (the `password_ref` env var, else the `felis-smtp`
|
||||||
|
Secret, else no AUTH), and the mail skips the API's `max_per_hour` budget.
|
||||||
|
Only the right code makes the run a `recovery` attributed to that account; the
|
||||||
|
mail says which host and OS user asked, so an admin who did not ask learns
|
||||||
|
that root there is in other hands.
|
||||||
|
|
||||||
|
Every other ending leads to the typed `OVERRIDE`, and the screen says why:
|
||||||
|
the name matches no staff account, the account has no verified address, no
|
||||||
|
relay (`[smtp] is not configured in felis.toml`, or the Secret could not be
|
||||||
|
read), the relay refused the mail, the code expired or took five wrong tries,
|
||||||
|
or the operator typed `OVERRIDE` at the code prompt. Esc on either screen
|
||||||
|
starts over with a new code. With the relay down recovery still works, as an
|
||||||
|
unverified override that records the reason.
|
||||||
|
|
||||||
|
The audit row is `break_glass.recovery` or `break_glass.root_override`
|
||||||
|
(`break_glass.operator_create` for a new Operator account), source
|
||||||
|
`break-glass`. `verified` is true only for a run a code proved, which also
|
||||||
|
carries `verified_by: email_otp` and `code_sent_to`. An override carries
|
||||||
|
`otp_skipped` (`unknown_admin`, `no_verified_email`, `no_relay`,
|
||||||
|
`send_failed`, `code_expired`, `code_rejected`, `operator_skipped`) and, where
|
||||||
|
something failed, `otp_skip_detail`. Root can edit the row afterwards, so it
|
||||||
|
records attribution without proving it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u postgres psql felis -c "
|
||||||
|
SELECT created_at, action, actor, payload->>'verified' AS verified,
|
||||||
|
payload->>'otp_skipped' AS skipped, payload->>'otp_skip_detail' AS detail
|
||||||
|
FROM audit_logs WHERE source = 'break-glass' ORDER BY created_at DESC LIMIT 20;"
|
||||||
|
```
|
||||||
|
|
||||||
### Optional: a Cloudflare rate limiting rule in front
|
### Optional: a Cloudflare rate limiting rule in front
|
||||||
|
|
||||||
The limits above live in the API, so they hold on any edge. Behind Cloudflare
|
The limits above live in the API, so they hold on any edge. Behind Cloudflare
|
||||||
@@ -2319,4 +2355,5 @@ for 10 seconds (the Free plan's limits).
|
|||||||
| Right code refused; `otp_account_locked` / `FelisOTPAccountLocked` | §17 |
|
| Right code refused; `otp_account_locked` / `FelisOTPAccountLocked` | §17 |
|
||||||
| `FelisSignInFailures` / who is guessing, from where | §17 |
|
| `FelisSignInFailures` / who is guessing, from where | §17 |
|
||||||
| `FelisAuditWriteFailing` | §17 |
|
| `FelisAuditWriteFailing` | §17 |
|
||||||
|
| `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 |
|
||||||
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
|
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
|
||||||
Reference in new issue
Block a user