Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/ota/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func run(args []string, stdout, stderr *os.File) int {
pollSec = fs.Int("poll-interval", 0, "override Logs tail poll interval in seconds")
showVersion = fs.Bool("version", false, "print version and exit")
checkMode = fs.Bool("check", false, "probe Okta API once and print a plain-text diagnostic (no TUI)")
readOnly = fs.Bool("readonly", false, "block every write op (edit forms, status pickers, palette write commands) and surface a toast")
)
if err := fs.Parse(args); err != nil {
// flag.ErrHelp is not an error — user asked for help.
Expand All @@ -83,6 +84,7 @@ func run(args []string, stdout, stderr *os.File) int {
TokenEnv: *tokenEnv,
Debug: *debugMode,
PollSec: *pollSec,
ReadOnly: *readOnly,
}, stdout, stderr)
}

Expand All @@ -103,6 +105,7 @@ func run(args []string, stdout, stderr *os.File) int {
TokenEnv: *tokenEnv,
Debug: *debugMode,
PollSec: *pollSec,
ReadOnly: *readOnly,
})

var rootModel tea.Model = wireModel
Expand Down
33 changes: 33 additions & 0 deletions cmd/ota/readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

// QW-2 — smoke test that the --readonly CLI flag registers and parses
// cleanly. Combined with --version so run() short-circuits before it
// tries to launch the Bubbletea program.

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_Run_ReadOnlyFlag_ParsesCleanly(t *testing.T) {
// t.Setenv isolates any lingering env; --version returns 0 without
// touching config so this test is safe in CI.
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
require.NoError(t, err)

code := run([]string{"--readonly", "--version"}, stdout, stderr)
assert.Equal(t, 0, code, "`--readonly --version` must exit cleanly (flag registered)")

// Confirm the unknown-flag path is NOT hit — bad flags print
// "flag provided but not defined" to stderr and return 2.
_ = stderr.Close()
stderrBytes, err := os.ReadFile(stderr.Name())
require.NoError(t, err)
assert.NotContains(t, string(stderrBytes), "flag provided but not defined",
"--readonly must be a registered flag, not an unknown one")
}
5 changes: 5 additions & 0 deletions cmd/ota/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ type WireInput struct {
TokenEnv string
Debug bool
PollSec int
// ReadOnly gates every UI write flow when true — edit forms, status
// pickers, and palette write commands surface a toast instead of
// running. Threaded through --readonly on the ota CLI (QW-2).
ReadOnly bool
}

// Wire is the single explicit dependency-assembly point. It loads config,
Expand Down Expand Up @@ -134,6 +138,7 @@ func Wire(ctx context.Context, in WireInput) (app.Model, config.Config, error) {
// override via cfg.OktaStatusEndpoint when self-hosted Okta
// orgs run a different statuspage (rare).
OktaStatusEndpoint: oktastatus.DefaultEndpoint,
ReadOnly: in.ReadOnly,
})
return model, cfg, nil
}
Expand Down
109 changes: 109 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ func (s Screen) String() string {
// tests can assert against the router state without reaching into internals.
func ActiveScreenName(m Model) string { return m.active.String() }

// isWriteScreen reports whether s is one of the edit-form screens the
// --readonly gate blocks entry to (QW-2). Palette / SwitchScreen paths
// route through this so `:edit`, `:group-edit`, etc. surface the same
// toast the OpenXEditMsg handlers do when read-only is active.
func isWriteScreen(s Screen) bool {
switch s {
case ScreenUserEdit, ScreenGroupEdit, ScreenRuleEdit, ScreenPolicyEdit:
return true
}
return false
}

// resetNav replaces the entire stack with the supplied root and
// updates m.active to match. Used by `:` palette commands —
// "navigate to <res>" is the operator declaring a fresh root, so
Expand Down Expand Up @@ -181,6 +193,24 @@ func (m Model) now() time.Time {
return time.Now()
}

// blockedByReadOnly reports whether a write-flow entry point should be
// short-circuited by the --readonly gate (QW-2). Returns (true,
// toastCmd) so callers can pattern-match:
//
// if blocked, cmd := m.blockedByReadOnly(); blocked {
// return m, cmd
// }
//
// The toast is the single canonical read-only feedback surface —
// every gated entry point uses the same text so operators recognise
// the guardrail regardless of which key they pressed.
func (m Model) blockedByReadOnly() (bool, tea.Cmd) {
if !m.deps.ReadOnly {
return false, nil
}
return true, toastCmdInfo("read-only mode — write ops disabled")
}

// Overlay identifies the active overlay, if any.
type Overlay int

Expand Down Expand Up @@ -387,6 +417,14 @@ type Deps struct {
// outbound HTTP. main.go sets it to the public default.
OktaStatusEndpoint string

// ReadOnly gates every UI write flow (edit form entry, status
// picker open, palette write commands like :reset-password /
// :unlock / :reset-mfa). When true the shell surfaces a toast
// and refuses to push the screen / open the modal. Read flows
// (list, detail, search, filter, logs tail) stay 100% functional.
// Threaded through the ota CLI's --readonly flag (QW-2).
ReadOnly bool

// Optional initial state for tests / direct embedding.
InitialScreen Screen
}
Expand Down Expand Up @@ -680,6 +718,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// string back to the existing UserActionKind enum so the
// y/N confirmation modal fires (issue #125 flow stays the
// single source of truth for destructive ops).
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
switch msg.Kind {
case "reset-password":
return m.openActionConfirm(UserActionResetPassword)
Expand All @@ -700,6 +741,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case shared.RunRuleActionMsg:
// Group Rule lifecycle dispatcher (issue #188 v0.2.2).
// Same gate-via-confirm pattern as RunUserActionMsg.
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
switch msg.Kind {
case "activate":
return m.openRuleActionConfirm(RuleActionActivate)
Expand Down Expand Up @@ -739,13 +783,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// `:` palette commands declare a fresh root — wipe the nav
// stack so the operator's mental model of "Esc walks back
// through the chain I drilled into" stays clean.
if isWriteScreen(msg.Target) {
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
}
m.resetNav(msg.Target)
m.overlay = OverlayNone
m.paletteInput = ""
updated, cmd := m.ensureScreen(m.active)
return updated, cmd
case SwitchScreenMsg:
if s, ok := screenFromName(msg.Target); ok {
if isWriteScreen(s) {
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
}
Comment on lines 796 to +802

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

While SwitchScreenMsg and ScreenChangeMsg are properly guarded with isWriteScreen checks, shared.OpenScreenMsg (handled right below at line 798) also resolves targets via screenFromName (which can return write screens like ScreenUserEdit or ScreenGroupEdit) but lacks the isWriteScreen read-only check. To prevent potential bypasses where a cross-screen drill-down could navigate to a write screen in read-only mode, please add the same guard to shared.OpenScreenMsg.

m.resetNav(s)
m.overlay = OverlayNone
m.paletteInput = ""
Expand All @@ -759,6 +813,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// wherever the operator came from rather than silently
// dropping the trail.
if s, ok := screenFromName(msg.Target); ok {
if isWriteScreen(s) {
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
}
m.pushNav(s)
m.overlay = OverlayNone
updated, cmd := m.ensureScreen(m.active)
Expand Down Expand Up @@ -814,6 +873,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// rebuilding the EditModel so each entry fires a fresh GET
// (AC-1.3 — cache distrust). Existing entries are discarded so
// the operator never lands on a stale form when re-entering.
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
delete(m.screens, ScreenUserEdit)
m.editTargetID = msg.ID
m.pushNav(ScreenUserEdit)
Expand All @@ -827,6 +889,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// is in a terminal state (no valid transitions) so the
// operator gets actionable feedback instead of an empty
// modal.
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
picker := NewUserStatusPickerModel(msg.User)
if picker.Empty() {
return m, toastCmdInfo("no status transitions for " + string(msg.User.Status))
Expand All @@ -835,6 +900,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.overlay = OverlayStatusPicker
return m, nil
case shared.OpenRuleStatusPickerMsg:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
picker := NewRuleStatusPickerModel(msg.Rule)
if picker.Empty() {
return m, toastCmdInfo("no status transitions for " + string(msg.Rule.Status))
Expand All @@ -843,6 +911,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.overlay = OverlayStatusPicker
return m, nil
case shared.OpenPolicyStatusPickerMsg:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
picker := NewPolicyStatusPickerModel(msg.Policy)
if picker.Empty() {
if msg.Policy.System {
Expand All @@ -854,6 +925,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.overlay = OverlayStatusPicker
return m, nil
case shared.OpenAppStatusPickerMsg:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
picker := NewAppStatusPickerModel(msg.App)
if picker.Empty() {
return m, toastCmdInfo("no status transitions for " + string(msg.App.Status))
Expand All @@ -862,6 +936,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.overlay = OverlayStatusPicker
return m, nil
case shared.OpenAuthenticatorStatusPickerMsg:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
picker := NewAuthenticatorStatusPickerModel(msg.Authenticator)
if picker.Empty() {
return m, toastCmdInfo("no status transitions for " + string(msg.Authenticator.Status))
Expand Down Expand Up @@ -906,6 +983,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// buildScreen build a fresh EditModel that fires the initial
// GET. Existing edit frame discarded so a re-entry never
// lands on a stale form.
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
delete(m.screens, ScreenGroupEdit)
m.groupEditTargetID = msg.ID
m.pushNav(ScreenGroupEdit)
Expand Down Expand Up @@ -938,6 +1018,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, refreshScreenCmd()
case shared.OpenRuleEditMsg:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
delete(m.screens, ScreenRuleEdit)
m.ruleEditTargetID = msg.ID
m.pushNav(ScreenRuleEdit)
Expand Down Expand Up @@ -966,6 +1049,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, refreshScreenCmd()
case shared.OpenPolicyEditMsg:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
delete(m.screens, ScreenPolicyEdit)
m.policyEditTargetID = msg.ID
m.pushNav(ScreenPolicyEdit)
Expand Down Expand Up @@ -1898,6 +1984,20 @@ type filterInputStater interface {
// drops trailing entries first.
func (m Model) composeChromeBadges() []shared.ChromeBadge {
var out []shared.ChromeBadge
// QW-2 — READONLY chrome badge leads the status row so operators
// see the guardrail at a glance. Warning tone (yellow) mirrors the
// server-filter / query prompts — same "you're operating a modal
// gate" visual cue, distinct from the red Danger tone reserved for
// mid-flight destructive ops. Value is set to "on" so the
// formatStatusBadge path applies the Warning tone (bare Value
// collapses to a muted `[KEY]` rendering that ignores Tone).
if m.deps.ReadOnly {
out = append(out, shared.ChromeBadge{
Key: "READONLY",
Value: "on",
Tone: shared.BadgeWarning,
})
}
if m.overlay == OverlayActionConfirm {
switch {
case m.pendingRule.Kind != RuleActionNone:
Expand Down Expand Up @@ -2834,10 +2934,19 @@ func (m Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, c
}
case paletteCmdResetPassword:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
return m.openActionConfirm(UserActionResetPassword)
case paletteCmdUnlock:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
return m.openActionConfirm(UserActionUnlock)
case paletteCmdResetFactors:
if blocked, cmd := m.blockedByReadOnly(); blocked {
return m, cmd
}
return m.openActionConfirm(UserActionResetFactors)
case paletteCmdPolicyType:
// Issue #165: jump straight to the typed list, replacing
Expand Down
Loading