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:
flyemoji committed 2026-06-30 15:40:24 +09:00
1 parent ac02c69612
commit eb5875a699
6 files changed
+513 -28

No files matched your search

+29 -2
View File
@@ -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