feat(felis): add Operator break-glass op behind an operation menu
When a staff account already exists, the break-glass console now opens on a thin top-level menu (menuModel) where account operations are peers rather than tails of one wizard: provision/reset the Owner, or add an Operator. A fresh machine with no Owner skips the menu and goes straight to Owner bootstrap, since minting an Operator first would create a staff account the login gate rejects. The Operator path reuses ownerModel via a bgOperation discriminator. It is insert-only (performAddOperator -> InsertOperator), wraps a duplicate username as api.ErrConflict and routes back to the provision form for a retry rather than tearing down, and deliberately never flips the global local_auth toggle the way the Owner thread does. The post-exit summary and audit trail distinguish the two outcomes (isOperator); only the Owner provision claims local-password login was enabled. Tests cover the operator-model defaults, path selection (insert vs upsert and the local-auth gate), conflict-retry versus generic teardown, isOperator propagation, and the root menu routing for both fresh and admin-present machines.
This commit is contained in:
6 files changed
+513
-28
No files matched your search
+27
-11
@@ -39,16 +39,25 @@ import (
|
||||
// this is root and can edit Postgres directly — but it produces an honest trail
|
||||
// for an honest operator, which is the point.
|
||||
//
|
||||
// The console opens on a thin top-level router (stepMenu) so that operations
|
||||
// are peers, not tails of one wizard. Two are wired today: (1) provision/reset
|
||||
// the Owner — the thin thread above — and (2) an OPTIONAL Cloudflare Tunnel +
|
||||
// Access edge (internal/cfsetup), kept "锦上添花": it is reachable WITHOUT touching
|
||||
// the Owner credential, supported but never required, and gated entirely on the
|
||||
// operator's own Cloudflare account. The remaining ops (halt, sync, S3) land in a
|
||||
// later phase as further menu peers. The edge flow's verifiable logic lives in
|
||||
// cfsetup (fail-closed policy, ingress, gating, all unit-tested); what this file
|
||||
// adds for it is the untested bubbletea shell plus a `tea.ExecProcess` suspension
|
||||
// for the interactive `cloudflared tunnel login` browser consent.
|
||||
// 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
|
||||
// operations are wired today: (1) provision/reset the Owner — the thin thread above,
|
||||
// which also re-enables local-password login — and (2) add an Operator: an
|
||||
// insert-only mint of an additional staff admin (provisionOperator) that
|
||||
// deliberately never touches the global local_auth toggle. On a fresh machine (no
|
||||
// Owner yet) the menu is skipped: bootstrapping the first Owner is the only sensible
|
||||
// operation, and adding an Operator first would mint a staff account the login gate
|
||||
// still rejects. The remaining ops (halt, sync, S3) land in a later phase as further
|
||||
// menu peers.
|
||||
//
|
||||
// An OPTIONAL Cloudflare Tunnel + Access edge (internal/cfsetup) is offered by the
|
||||
// first-run SETUP flow (runSetupTUI / the connection chooser), not by this
|
||||
// break-glass menu, though its helpers live in this file. It is kept "锦上添花":
|
||||
// reachable WITHOUT touching the Owner credential, supported but never required, and
|
||||
// gated entirely on the operator's own Cloudflare account. The edge flow's verifiable
|
||||
// logic lives in cfsetup (fail-closed policy, ingress, gating, all unit-tested); what
|
||||
// this file adds for it is the untested bubbletea shell plus a `tea.ExecProcess`
|
||||
// suspension for the interactive `cloudflared tunnel login` browser consent.
|
||||
|
||||
// breakGlassOverrideToken is the literal an operator must type to proceed when no
|
||||
// admin credential could be verified. Requiring an explicit, deliberate word (not a
|
||||
@@ -147,7 +156,13 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
// outcome — and any generated one-time password — survives in scrollback long
|
||||
// enough for the operator to log in.
|
||||
if res.provisioned {
|
||||
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
|
||||
if res.isOperator {
|
||||
// Adding an Operator does NOT flip local_auth_enabled (performAddOperator),
|
||||
// so the summary must not claim it did — only the Owner thread enables login.
|
||||
fmt.Fprintf(stdout, "\nfelis breakGlass: Operator account %q provisioned.\n", res.username)
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
|
||||
if res.displayPassword != "" {
|
||||
// A one-time password was generated (recovery / root override). It is shown,
|
||||
@@ -500,6 +515,7 @@ func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error
|
||||
// post-exit summary. provisioned is false on cancel.
|
||||
type breakGlassResult struct {
|
||||
provisioned bool
|
||||
isOperator bool // an Operator was added rather than the Owner provisioned
|
||||
mode string
|
||||
accountable string
|
||||
osUser string
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
// bgOperation is the account operation the break-glass menu dispatches to. Its
|
||||
// zero value is bgProvisionOwner so any ownerModel built without an explicit
|
||||
// operation keeps the original "provision the Owner" behaviour — the menu and the
|
||||
// operator path are strictly additive.
|
||||
type bgOperation int
|
||||
|
||||
const (
|
||||
bgProvisionOwner bgOperation = iota
|
||||
bgAddOperator
|
||||
)
|
||||
|
||||
// menuChoiceMsg is emitted to the root once the operator picks an operation. The
|
||||
// menu screen has no database handle of its own, so it reports the choice upward
|
||||
// and lets the root (which holds ctx/store) build the next screen.
|
||||
type menuChoiceMsg struct{ op bgOperation }
|
||||
|
||||
// menuModel is the thin top-level router the break-glass console opens on when a
|
||||
// staff account already exists, making the account operations peers rather than
|
||||
// tails of one wizard. It is shown only in recovery (adminExists): on a fresh
|
||||
// machine bootstrapping the first Owner is the only sensible operation, and adding
|
||||
// an Operator first would mint a staff account the login gate still rejects, so the
|
||||
// console skips straight to Owner provisioning there.
|
||||
type menuModel struct {
|
||||
form *huh.Form
|
||||
choice bgOperation
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newMenuModel() *menuModel {
|
||||
m := &menuModel{}
|
||||
m.form = m.build()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *menuModel) build() *huh.Form {
|
||||
return m.sized(newFelisForm(huh.NewGroup(
|
||||
huh.NewSelect[bgOperation]().
|
||||
Title("Break-glass console").
|
||||
Description("Local root recovery. Choose an operation.").
|
||||
Value(&m.choice).
|
||||
Options(
|
||||
// Owner-provision is first so it is the default: it is the common
|
||||
// recovery flow, and landing on it keeps that path a single Enter.
|
||||
huh.NewOption("Provision or reset the Owner account", bgProvisionOwner),
|
||||
huh.NewOption("Add an Operator account", bgAddOperator),
|
||||
),
|
||||
// A dim footnote spelling out the one behavioural difference that matters:
|
||||
// Owner-reset re-enables local-password login, operator-add never touches the
|
||||
// global auth toggle.
|
||||
huh.NewNote().Description(
|
||||
"Owner reset re-enables local-password login. Adding an Operator mints an "+
|
||||
"additional staff admin and leaves the global auth toggle untouched."),
|
||||
)))
|
||||
}
|
||||
|
||||
func (m *menuModel) sized(f *huh.Form) *huh.Form {
|
||||
if m.width > 0 {
|
||||
return f.WithWidth(m.width).WithHeight(m.height)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (m *menuModel) setSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
if m.form != nil {
|
||||
m.form = m.form.WithWidth(w).WithHeight(h)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *menuModel) Init() tea.Cmd { return m.form.Init() }
|
||||
|
||||
func (m *menuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "ctrl+c", "esc":
|
||||
// Backing out of the top-level menu cancels the whole console — no account
|
||||
// is created and the durable summary reports "no changes made".
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
form, cmd := m.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
m.form = f
|
||||
}
|
||||
switch m.form.State {
|
||||
case huh.StateCompleted:
|
||||
op := m.choice
|
||||
return m, func() tea.Msg { return menuChoiceMsg{op: op} }
|
||||
case huh.StateAborted:
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *menuModel) View() string { return m.form.View() }
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// These tests cover the break-glass operator path that the menu added: the new
|
||||
// menuModel, the operation discriminator on ownerModel, and the root routing that
|
||||
// turns a menu choice into the right screen. Like tui_root_test.go they inject the
|
||||
// inter-screen messages directly and run the screens' outgoing commands by hand, so
|
||||
// no database, terminal, or huh key-replay is involved — only the wiring is under
|
||||
// test (the core provisioning logic is covered in breakglass_test.go).
|
||||
|
||||
func TestNewOperatorModelDefaults(t *testing.T) {
|
||||
m := newOperatorModel(context.Background(), &fakeOwnerStore{}, "root")
|
||||
if m.operation != bgAddOperator {
|
||||
t.Errorf("operation = %v, want bgAddOperator", m.operation)
|
||||
}
|
||||
if !m.adminExists {
|
||||
t.Error("adminExists = false, want true — adding an Operator presupposes an existing admin")
|
||||
}
|
||||
if m.step != owAuth {
|
||||
t.Errorf("step = %v, want owAuth — the operator path always authenticates first", m.step)
|
||||
}
|
||||
// The username must NOT default to "owner" the way the Owner flow does: the
|
||||
// operator path is insert-only, so a defaulted name would hit ErrConflict on the
|
||||
// happy path every single time.
|
||||
if m.ownerUser != "" {
|
||||
t.Errorf("ownerUser = %q, want empty (no default for the insert-only operator path)", m.ownerUser)
|
||||
}
|
||||
if m.subject() != "Operator" {
|
||||
t.Errorf("subject() = %q, want Operator", m.subject())
|
||||
}
|
||||
|
||||
// The Owner flow is the zero value and is unchanged by the discriminator.
|
||||
ow := newOwnerModel(context.Background(), &fakeOwnerStore{}, "root", true)
|
||||
if ow.operation != bgProvisionOwner {
|
||||
t.Errorf("owner operation = %v, want bgProvisionOwner", ow.operation)
|
||||
}
|
||||
if ow.ownerUser != "owner" || ow.subject() != "Owner" {
|
||||
t.Errorf("owner defaults changed: ownerUser=%q subject=%q", ow.ownerUser, ow.subject())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionCmdSelectsPathByOperation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("operator path inserts and leaves local auth untouched", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
m := newOperatorModel(ctx, f, "root")
|
||||
m.mode, m.accountable, m.username, m.attempt = "recovery", "root", "ops-new", "root"
|
||||
|
||||
out := m.provisionCmd()()
|
||||
msg, ok := out.(owProvisionMsg)
|
||||
if !ok {
|
||||
t.Fatalf("provisionCmd produced %T, want owProvisionMsg", out)
|
||||
}
|
||||
if msg.err != nil {
|
||||
t.Fatalf("operator provision: %v", msg.err)
|
||||
}
|
||||
// performAddOperator is insert-only and never uses the Owner upsert path.
|
||||
if len(f.inserts) != 1 {
|
||||
t.Fatalf("want 1 insert (performAddOperator), got %d", len(f.inserts))
|
||||
}
|
||||
if len(f.upserts) != 0 {
|
||||
t.Errorf("want 0 owner upserts, got %d — the operator path must not use UpsertOwner", len(f.upserts))
|
||||
}
|
||||
// The operator path must NOT flip the global local-auth gate (Owner-only).
|
||||
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
|
||||
t.Error("the operator path flipped local auth — only the Owner thread may")
|
||||
}
|
||||
// No password was typed, so a one-time credential is surfaced to hand off.
|
||||
if msg.outcome.displayPassword == "" {
|
||||
t.Error("want a generated one-time password to hand to the new operator")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("owner path upserts and enables local auth", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
m := newOwnerModel(ctx, f, "root", true)
|
||||
m.mode, m.accountable, m.username = "recovery", "root", "owner"
|
||||
|
||||
out := m.provisionCmd()()
|
||||
msg, ok := out.(owProvisionMsg)
|
||||
if !ok {
|
||||
t.Fatalf("provisionCmd produced %T, want owProvisionMsg", out)
|
||||
}
|
||||
if msg.err != nil {
|
||||
t.Fatalf("owner provision: %v", msg.err)
|
||||
}
|
||||
// performBreakGlass upserts the single Owner and enables local-password login.
|
||||
if len(f.upserts) != 1 || len(f.inserts) != 0 {
|
||||
t.Fatalf("want 1 upsert and 0 inserts (performBreakGlass), got upserts=%d inserts=%d", len(f.upserts), len(f.inserts))
|
||||
}
|
||||
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
|
||||
t.Error("the owner path did not enable local auth — the thin thread requires it")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOwnerModelProvisionErrorRouting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// A wrapped conflict, exactly as provisionOperator surfaces it.
|
||||
conflict := fmt.Errorf("operator %q already exists: %w", "owner", api.ErrConflict)
|
||||
|
||||
t.Run("an operator name clash returns to the provision form with a prompt", func(t *testing.T) {
|
||||
m := newOperatorModel(ctx, &fakeOwnerStore{}, "root")
|
||||
m.mode, m.accountable = "recovery", "root"
|
||||
|
||||
next, _ := m.Update(owProvisionMsg{err: conflict})
|
||||
om, ok := next.(*ownerModel)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T, want *ownerModel", next)
|
||||
}
|
||||
if om.step != owProvision {
|
||||
t.Fatalf("step = %v, want owProvision — a taken name is recoverable, not fatal", om.step)
|
||||
}
|
||||
// provisionErr is what buildProvisionForm keys the "already taken — choose
|
||||
// another name" prompt off of, so a still-matchable conflict here is what makes
|
||||
// the retry legible to the operator.
|
||||
if !errors.Is(om.provisionErr, api.ErrConflict) {
|
||||
t.Errorf("provisionErr = %v, want it to wrap api.ErrConflict so the form can prompt for another name", om.provisionErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-conflict error tears the console down", func(t *testing.T) {
|
||||
m := newOperatorModel(ctx, &fakeOwnerStore{}, "root")
|
||||
|
||||
next, cmd := m.Update(owProvisionMsg{err: errors.New("boom")})
|
||||
om := next.(*ownerModel)
|
||||
if om.step == owProvision {
|
||||
t.Error("a generic store fault must not be treated as a retryable name clash")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("a fatal provision error should return a failure command")
|
||||
}
|
||||
res, ok := cmd().(ownerResultMsg)
|
||||
if !ok {
|
||||
t.Fatalf("failure cmd produced %T, want ownerResultMsg", cmd())
|
||||
}
|
||||
if res.err == nil {
|
||||
t.Error("ownerResultMsg.err = nil, want the surfaced failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a conflict on the Owner path is not a retry", func(t *testing.T) {
|
||||
// Defensive: the Owner upserts and so never conflicts, but were one ever to
|
||||
// surface it must end the session rather than loop the form — only the
|
||||
// insert-only operator path is retryable.
|
||||
m := newOwnerModel(ctx, &fakeOwnerStore{}, "root", true)
|
||||
|
||||
next, cmd := m.Update(owProvisionMsg{err: conflict})
|
||||
om := next.(*ownerModel)
|
||||
if om.provisionErr != nil {
|
||||
t.Error("the Owner path recorded a retryable conflict; only the operator path retries")
|
||||
}
|
||||
if res, ok := cmd().(ownerResultMsg); !ok || res.err == nil {
|
||||
t.Error("an Owner-path conflict should tear down via an error result")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOwnerResultCmdCarriesIsOperator(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
op := newOperatorModel(ctx, &fakeOwnerStore{}, "root")
|
||||
op.username, op.displayPassword, op.mode, op.accountable = "ops", "pw", "recovery", "root"
|
||||
if res := op.ownerResultCmd()().(ownerResultMsg); !res.isOperator {
|
||||
t.Error("operator result.isOperator = false, want true")
|
||||
}
|
||||
|
||||
ow := newOwnerModel(ctx, &fakeOwnerStore{}, "root", true)
|
||||
ow.username, ow.mode, ow.accountable = "owner", "recovery", "root"
|
||||
if res := ow.ownerResultCmd()().(ownerResultMsg); res.isOperator {
|
||||
t.Error("owner result.isOperator = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootBreakGlassOpensMenuWhenAdminExists(t *testing.T) {
|
||||
m := newTestRoot(true, consoleModeBreakGlass, "")
|
||||
if m.stage != stageMenu {
|
||||
t.Fatalf("stage = %v, want stageMenu", m.stage)
|
||||
}
|
||||
if _, ok := m.screen.(*menuModel); !ok {
|
||||
t.Fatalf("screen = %T, want *menuModel", m.screen)
|
||||
}
|
||||
|
||||
// Picking "add Operator" adopts an ownerModel wired for the operator path.
|
||||
m = drive(t, m, menuChoiceMsg{op: bgAddOperator})
|
||||
if m.stage != stageOwner {
|
||||
t.Fatalf("after the menu choice, stage = %v, want stageOwner", m.stage)
|
||||
}
|
||||
om, ok := m.screen.(*ownerModel)
|
||||
if !ok {
|
||||
t.Fatalf("screen = %T, want *ownerModel", m.screen)
|
||||
}
|
||||
if om.operation != bgAddOperator {
|
||||
t.Errorf("operation = %v, want bgAddOperator", om.operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootBreakGlassMenuOwnerChoice(t *testing.T) {
|
||||
m := newTestRoot(true, consoleModeBreakGlass, "")
|
||||
m = drive(t, m, menuChoiceMsg{op: bgProvisionOwner})
|
||||
om, ok := m.screen.(*ownerModel)
|
||||
if !ok {
|
||||
t.Fatalf("screen = %T, want *ownerModel", m.screen)
|
||||
}
|
||||
if om.operation != bgProvisionOwner {
|
||||
t.Errorf("operation = %v, want bgProvisionOwner", om.operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootBreakGlassSkipsMenuOnFreshMachine(t *testing.T) {
|
||||
// No admin yet: bootstrapping the first Owner is the only sensible op, so the menu
|
||||
// is skipped and the console opens straight on the Owner screen.
|
||||
m := newTestRoot(false, consoleModeBreakGlass, "")
|
||||
if m.stage != stageOwner {
|
||||
t.Fatalf("stage = %v, want stageOwner (no menu on a fresh machine)", m.stage)
|
||||
}
|
||||
om, ok := m.screen.(*ownerModel)
|
||||
if !ok {
|
||||
t.Fatalf("screen = %T, want *ownerModel", m.screen)
|
||||
}
|
||||
if om.operation != bgProvisionOwner {
|
||||
t.Errorf("operation = %v, want bgProvisionOwner", om.operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuModelCancelQuits(t *testing.T) {
|
||||
if m := newMenuModel(); m.choice != bgProvisionOwner {
|
||||
t.Errorf("default choice = %v, want bgProvisionOwner (Owner reset is the common path)", m.choice)
|
||||
}
|
||||
// Backing out of the top-level menu cancels the whole console.
|
||||
for _, kt := range []tea.KeyType{tea.KeyEsc, tea.KeyCtrlC} {
|
||||
m := newMenuModel()
|
||||
_, cmd := m.Update(key(kt))
|
||||
if cmd == nil {
|
||||
t.Fatalf("%v on the menu should return a command (tea.Quit)", kt)
|
||||
}
|
||||
if msg := cmd(); !isQuit(msg) {
|
||||
t.Errorf("%v on the menu = %T, want tea.Quit", kt, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
-11
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
@@ -37,19 +39,28 @@ const (
|
||||
// override, owner details) are huh forms; the async phases (verifying,
|
||||
// provisioning) show a spinner; the done phase shows the credential card. The
|
||||
// outward contract is unchanged: it emits an ownerResultMsg when finished.
|
||||
//
|
||||
// The same model serves the Add-Operator break-glass operation: the Owner and
|
||||
// Operator flows are identical in shape (authenticate or override → collect a
|
||||
// username → provision → show a one-time credential), so an operation discriminator
|
||||
// switches the few differences (which provision function runs, the on-screen
|
||||
// labels, whether a username is defaulted) rather than forking a near-duplicate
|
||||
// model. The zero value, bgProvisionOwner, is the original Owner behaviour.
|
||||
type ownerModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
osUser string
|
||||
adminExists bool
|
||||
operation bgOperation
|
||||
mode string // "bootstrap", "recovery", "root_override"
|
||||
accountable string
|
||||
attempt string
|
||||
|
||||
step owStep
|
||||
form *huh.Form
|
||||
sp spinner.Model
|
||||
working string
|
||||
step owStep
|
||||
form *huh.Form
|
||||
sp spinner.Model
|
||||
working string
|
||||
provisionErr error // last provision failure routed back to the form (operator name clash)
|
||||
|
||||
width, height int
|
||||
|
||||
@@ -92,8 +103,41 @@ func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminEx
|
||||
return m
|
||||
}
|
||||
|
||||
// newOperatorModel builds the model for the Add-Operator break-glass operation. It
|
||||
// always starts at admin authentication: adding an Operator presupposes an existing
|
||||
// admin (that is why the menu only offers it when one exists), so there is no
|
||||
// bootstrap branch and the password is always generated. The username is left empty
|
||||
// on purpose — defaulting it to "owner" (as the Owner flow does) would make the
|
||||
// happy path insert a duplicate and hit ErrConflict on every attempt.
|
||||
func newOperatorModel(ctx context.Context, store ownerStore, osUser string) *ownerModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = tuiLabel
|
||||
|
||||
m := &ownerModel{
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
osUser: osUser,
|
||||
adminExists: true,
|
||||
operation: bgAddOperator,
|
||||
sp: sp,
|
||||
}
|
||||
m.step = owAuth
|
||||
m.form = m.buildAuthForm()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *ownerModel) Init() tea.Cmd { return m.form.Init() }
|
||||
|
||||
// subject is the human label for the account being provisioned, branching every
|
||||
// on-screen string and the durable summary between the two operations.
|
||||
func (m *ownerModel) subject() string {
|
||||
if m.operation == bgAddOperator {
|
||||
return "Operator"
|
||||
}
|
||||
return "Owner"
|
||||
}
|
||||
|
||||
func (m *ownerModel) setSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
if m.form != nil {
|
||||
@@ -133,6 +177,17 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case owProvisionMsg:
|
||||
if msg.err != nil {
|
||||
// A taken Operator username is the expected, recoverable outcome of the
|
||||
// insert-only operator path (refusing the clash is the whole reason it is
|
||||
// insert-only, not an upsert). Route back to the form with a note so the
|
||||
// operator can pick another name, rather than tearing down the console —
|
||||
// any other error is a genuine fault and still ends the session.
|
||||
if m.operation == bgAddOperator && errors.Is(msg.err, api.ErrConflict) {
|
||||
m.provisionErr = msg.err
|
||||
m.step = owProvision
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
return m, m.form.Init()
|
||||
}
|
||||
return m, m.failCmd(msg.err)
|
||||
}
|
||||
m.step = owDone
|
||||
@@ -215,7 +270,7 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
|
||||
case owProvision:
|
||||
m.username = strings.TrimSpace(m.ownerUser)
|
||||
m.step = owWorking
|
||||
m.working = "Provisioning Owner account…"
|
||||
m.working = "Provisioning " + m.subject() + " account…"
|
||||
return m, tea.Batch(m.sp.Tick, m.provisionCmd())
|
||||
}
|
||||
return m, nil
|
||||
@@ -235,8 +290,16 @@ func (m *ownerModel) provisionCmd() tea.Cmd {
|
||||
ownerPassword: password,
|
||||
attemptedAdmin: m.attempt,
|
||||
}
|
||||
// performAddOperator and performBreakGlass share a signature; the operation
|
||||
// discriminator selects which one runs. The operator path is insert-only and
|
||||
// never flips local auth (see performAddOperator); the Owner path upserts and
|
||||
// enables local-password login.
|
||||
perform := performBreakGlass
|
||||
if m.operation == bgAddOperator {
|
||||
perform = performAddOperator
|
||||
}
|
||||
return func() tea.Msg {
|
||||
out, err := performBreakGlass(m.ctx, m.store, op)
|
||||
out, err := perform(m.ctx, m.store, op)
|
||||
return owProvisionMsg{outcome: out, err: err}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +316,7 @@ func (m *ownerModel) ownerResultCmd() tea.Cmd {
|
||||
mode: m.mode,
|
||||
accountable: m.accountable,
|
||||
auditWarning: m.auditWarning,
|
||||
isOperator: m.operation == bgAddOperator,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +358,9 @@ func (m *ownerModel) buildOverrideForm() *huh.Form {
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildProvisionForm() *huh.Form {
|
||||
subject := m.subject() // "Owner" | "Operator"
|
||||
lower := strings.ToLower(subject)
|
||||
|
||||
desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
|
||||
switch m.mode {
|
||||
case "recovery":
|
||||
@@ -301,15 +368,30 @@ func (m *ownerModel) buildProvisionForm() *huh.Form {
|
||||
case "root_override":
|
||||
desc = "Root override — a one-time password will be generated."
|
||||
}
|
||||
if m.operation == bgAddOperator {
|
||||
// Operator-add never bootstraps (an admin is already present to authorize it),
|
||||
// so it is always one of the generated-password modes.
|
||||
switch m.mode {
|
||||
case "recovery":
|
||||
desc = fmt.Sprintf("Add an Operator — authenticated as %q; a one-time password will be generated.", m.accountable)
|
||||
case "root_override":
|
||||
desc = "Add an Operator (root override) — a one-time password will be generated."
|
||||
}
|
||||
}
|
||||
if m.provisionErr != nil {
|
||||
// The only error routed back to this form is a username clash on the insert-only
|
||||
// operator path; show a concrete prompt to choose another name.
|
||||
desc = "That username is already taken — choose a different one.\n\n" + desc
|
||||
}
|
||||
|
||||
fields := []huh.Field{
|
||||
huh.NewNote().Title("Owner account").Description(desc),
|
||||
huh.NewNote().Title(subject + " account").Description(desc),
|
||||
huh.NewInput().
|
||||
Title("Owner username").
|
||||
Title(subject + " username").
|
||||
Value(&m.ownerUser).
|
||||
Validate(requiredField("owner username")),
|
||||
Validate(requiredField(lower + " username")),
|
||||
huh.NewInput().
|
||||
Title("Owner email").
|
||||
Title(subject + " email").
|
||||
Description("optional").
|
||||
Placeholder("[email protected]").
|
||||
Value(&m.ownerEmail),
|
||||
@@ -368,7 +450,7 @@ func (m *ownerModel) View() string {
|
||||
|
||||
func (m *ownerModel) doneView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n")
|
||||
b.WriteString(tuiSuccessBanner(m.subject()+" account is ready.") + "\n\n")
|
||||
|
||||
var box strings.Builder
|
||||
box.WriteString(tuiLabel.Render("username ") + m.username + "\n")
|
||||
|
||||
+29
-2
@@ -57,6 +57,7 @@ type ownerResultMsg struct {
|
||||
mode string
|
||||
accountable string
|
||||
auditWarning string
|
||||
isOperator bool // true when an Operator was added rather than the Owner provisioned
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -89,6 +90,10 @@ const (
|
||||
stageOwner
|
||||
stageConnect
|
||||
stageSummary
|
||||
// stageMenu is the break-glass operation menu. It is appended last so the
|
||||
// setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed
|
||||
// in break-glass mode, so this stage never reaches it.
|
||||
stageMenu
|
||||
)
|
||||
|
||||
// setupRailSteps is the one progress rail shared by the whole first-run flow,
|
||||
@@ -145,8 +150,18 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
|
||||
},
|
||||
}
|
||||
if mode == consoleModeBreakGlass {
|
||||
rm.stage = stageOwner
|
||||
rm.screen = newOwnerModel(ctx, store, osUser, adminExists)
|
||||
if adminExists {
|
||||
// A staff account exists, so account operations are peers: open on the menu
|
||||
// (provision/reset Owner, or add Operator).
|
||||
rm.stage = stageMenu
|
||||
rm.screen = newMenuModel()
|
||||
} else {
|
||||
// Fresh machine: bootstrapping the first Owner is the only sensible op, so skip
|
||||
// the menu and go straight to it (offering "add Operator" here would mint a
|
||||
// staff account the login gate still rejects).
|
||||
rm.stage = stageOwner
|
||||
rm.screen = newOwnerModel(ctx, store, osUser, adminExists)
|
||||
}
|
||||
} else {
|
||||
rm.stage = stagePreflight
|
||||
rm.screen = newPreflightModel(dbURL, rootDomain)
|
||||
@@ -182,12 +197,24 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.stage = stageOwner
|
||||
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
|
||||
|
||||
case menuChoiceMsg:
|
||||
// The break-glass menu picked an account operation; build its screen. Both reuse
|
||||
// stageOwner (the rail is suppressed in break-glass, so the stage is only a label).
|
||||
m.stage = stageOwner
|
||||
switch msg.op {
|
||||
case bgAddOperator:
|
||||
return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser))
|
||||
default:
|
||||
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists))
|
||||
}
|
||||
|
||||
case ownerResultMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.result.provisioned = true
|
||||
m.result.isOperator = msg.isOperator
|
||||
m.result.username = msg.username
|
||||
m.result.displayPassword = msg.displayPassword
|
||||
m.result.mode = msg.mode
|
||||
|
||||
@@ -149,14 +149,19 @@ func TestRootRerunLandsOnStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootBreakGlassQuitsAfterOwner(t *testing.T) {
|
||||
// Break-glass starts at owner and must quit on owner completion without
|
||||
// entering the connection chooser.
|
||||
// Break-glass with a staff account present opens on the operation menu; choosing
|
||||
// "provision/reset the Owner" lands on the owner screen, which must quit on
|
||||
// completion without entering the connection chooser (that step is setup-only).
|
||||
m := newTestRoot(true, consoleModeBreakGlass, "")
|
||||
if m.stage != stageMenu {
|
||||
t.Fatalf("break-glass initial stage = %v, want stageMenu", m.stage)
|
||||
}
|
||||
m = drive(t, m, menuChoiceMsg{op: bgProvisionOwner})
|
||||
if m.stage != stageOwner {
|
||||
t.Fatalf("break-glass initial stage = %v, want stageOwner", m.stage)
|
||||
t.Fatalf("after the menu choice, stage = %v, want stageOwner", m.stage)
|
||||
}
|
||||
if _, ok := m.screen.(*ownerModel); !ok {
|
||||
t.Fatalf("break-glass initial screen = %T, want *ownerModel", m.screen)
|
||||
t.Fatalf("after the menu choice, screen = %T, want *ownerModel", m.screen)
|
||||
}
|
||||
|
||||
next, cmd := m.Update(ownerResultMsg{username: "owner", displayPassword: "pw", mode: "recovery"})
|
||||
|
||||
Reference in new issue
Block a user