Unverified Commit 60732a62 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(operator): gate op.console to staff and land owner setup there

The operator console (op.console.<root>) requires internal permission
verification on top of Zero-Trust: a passkey is not access. requireExternal
now refuses any non-admin principal arriving on the admin host, before any
handler, so op.console is staff-only at the door rather than per-route —
including on the passwordless demo face where Cloudflare Access is not in
front. The gate is inert on the player console (console.<root>).

Owner first-run setup is staff onboarding, so `felis setup` mints the
one-time setup URL on op.console.<root>/setup (was console.<root>). The
passkey verifier lists both console and op.console in RPOrigins so the
one-time binding asserts on either face under the shared console.<root>
RP-ID.

Session admin-access now includes role=owner, not only admin: the owner is
a superset of admin, so excluding it left IsOwner() unreachable through a
passwordless session. No path assigns role=owner yet — this is forward
consistency.

The bootstrap summary now names console.<root> the player panel and
op.console.<root> the operator console where the Owner runs setup, fixing
text that told operators not to run setup there.

Tests: op.console door gate (non-admin refused, player console unaffected,
admin passes) and owner session admin-access; the setup-bind default-host
test follows the move to op.console.
parent 26b62e91
Loading
Loading
Loading
Loading
+15 −11
Changes for cmd/felis/api.go: 15 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -228,6 +228,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
			AdminHostname: cfg.Auth.AdminHostname,
		},
		RootDomain:    cfg.Server.RootDomain,
		AdminHostname: cfg.Auth.AdminHostname,
		WakeCooldown:  30 * time.Second,
		// Bound concurrent console/build-log SSE streams per principal. Generous enough
		// for legitimate multi-tab / multi-server watching, while capping how many
@@ -246,18 +247,21 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources))
	}

	// Passkey (WebAuthn) enrollment verifier (spec §14, Phase 6). The relying party is
	// the panel (app) face: the RP id is the panel hostname and the single permitted
	// origin is that host over https, so a credential enrolled here is scoped to the
	// panel. It is wired only when auth.panel_hostname is configured; otherwise a.Passkey
	// stays nil and the enrollment begin/finish routes honestly return 503 (the
	// authenticated enrollment boundary is still enforced by the handlers). Scope is
	// ENROLLMENT only — the login/assertion path is a deferred slice, and credentials
	// enrolled under this RP id MUST be asserted under the same RP id when that slice
	// lands. An admin passkey (if ever added) is a SEPARATE relying party on the admin
	// host and is not wired here.
	// Passkey (WebAuthn) verifier (spec §14, Phase 6). One relying party spans BOTH
	// web faces: the RP id is the panel hostname (console.<root>), and because that is
	// a domain suffix of the operator host (op.console.<root>), a single credential
	// enrolled once asserts on either face — one binding, usable on the player console
	// AND the operator console. Both hosts are therefore listed as permitted origins,
	// while the RP id stays the panel host so the credential's scope is ONE relying
	// party, not two. Wired only when auth.panel_hostname is configured; otherwise
	// a.Passkey stays nil and the passkey routes honestly return 503 (the authenticated
	// enrollment boundary is still enforced by the handlers).
	if cfg.Auth.PanelHostname != "" {
		pv, err := passkey.New(cfg.Auth.PanelHostname, "Felis", []string{"https://" + cfg.Auth.PanelHostname})
		origins := []string{"https://" + cfg.Auth.PanelHostname}
		if admin := defaultAdminHostname(cfg.Server.RootDomain, cfg.Auth.AdminHostname); admin != "" && admin != cfg.Auth.PanelHostname {
			origins = append(origins, "https://"+admin)
		}
		pv, err := passkey.New(cfg.Auth.PanelHostname, "Felis", origins)
		if err != nil {
			fmt.Fprintf(stderr, "felis api: passkey verifier disabled: %v — passkey endpoints return 503\n", err)
		} else {
+9 −8
Changes for cmd/felis/breakglass.go: 9 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -388,17 +388,18 @@ func newSetupToken() (raw, hash string, err error) {
// binds their Minecraft account via a one-time link code the login gate handed
// them in-game, the bound user is promoted to role='admin' (passwordless Owner),
// local auth is enabled, and a one-time setup URL is minted for the first web
// login where the Owner verifies email / enrolls a passkey. panelHostname is the
// panel host the URL points at: the wizard enrolls the passkey, and the only wired
// WebAuthn verifier (cmd/felis/api.go) is scoped to the panel host, so the
// ceremony's origin MUST be the panel face — op.console has no verifier wired and
// cannot enroll at all. osUser is recorded as the accountable actor.
// login where the Owner verifies email / enrolls a passkey. adminHostname is the
// operator-console host the URL points at (op.console.<root>): the Owner is staff,
// so first-run onboarding belongs on the operator face, not the player panel. The
// passkey verifier's RP id is the panel host, but its permitted origins now include
// op.console (cmd/felis/api.go), so enrollment on op.console is a valid ceremony —
// one binding that works on both faces. osUser is recorded as the accountable actor.
//
// Local auth is as load-bearing here as it is in break-glass, and for a sharper
// reason: an MC-bound Owner has no password AND no email, so the setup token is
// their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth
// toggle, and token together; any failed write leaves the link code retryable.
func performSetupMCBind(ctx context.Context, s ownerStore, code, panelHostname, osUser string) (breakGlassOutcome, error) {
func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, osUser string) (breakGlassOutcome, error) {
	code = strings.TrimSpace(strings.ToUpper(code))
	if code == "" {
		return breakGlassOutcome{}, errors.New("link code is required")
@@ -423,9 +424,9 @@ func performSetupMCBind(ctx context.Context, s ownerStore, code, panelHostname,
		ownerIdentity: mcUUID,
		auditErr:      auditSetupMCBind(ctx, s, osUser, mcUUID, authSource),
	}
	host := strings.TrimSpace(panelHostname)
	host := strings.TrimSpace(adminHostname)
	if host == "" {
		host = "console.localhost"
		host = "op.console.localhost"
	}
	out.setupTokenURL = "https://" + host + "/setup?token=" + raw
	return out, nil
+5 −3
Changes for cmd/felis/breakglass_test.go: 5 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -778,14 +778,16 @@ func TestPerformSetupMCBind(t *testing.T) {
		}
	})

	t.Run("defaults the console host when panelHostname is empty", func(t *testing.T) {
	t.Run("defaults to the op.console host when adminHostname is empty", func(t *testing.T) {
		f := &fakeOwnerStore{redeemUserID: "usr-owner-1"}
		out, err := performSetupMCBind(ctx, f, "abc-123", "  ", "root")
		if err != nil {
			t.Fatalf("performSetupMCBind: %v", err)
		}
		if !strings.HasPrefix(out.setupTokenURL, "https://console.localhost/setup?token=") {
			t.Errorf("setup URL = %q, want the console.localhost default host", out.setupTokenURL)
		// The Owner is staff, so onboarding lands on the operator console, not the
		// player panel — the empty-host fallback must reflect that.
		if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") {
			t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL)
		}
	})
}
+4 −4
Changes for cmd/felis/tui_mc_bind.go: 4 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -18,7 +18,7 @@ import (
type mcBindModel struct {
	ctx       context.Context
	store     ownerStore
	panelHost string
	adminHost string
	osUser    string

	step    mcBindStep
@@ -47,14 +47,14 @@ type mcBindMsg struct {
	err     error
}

func newMCBindModel(ctx context.Context, store ownerStore, panelHost, osUser string) *mcBindModel {
func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel {
	sp := spinner.New()
	sp.Spinner = spinner.Dot
	sp.Style = tuiLabel
	m := &mcBindModel{
		ctx:       ctx,
		store:     store,
		panelHost: panelHost,
		adminHost: adminHost,
		osUser:    osUser,
		sp:        sp,
		step:      mcBindForm,
@@ -156,7 +156,7 @@ func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) {
	m.working = "Binding Minecraft account…"
	code := strings.TrimSpace(strings.ToUpper(m.linkCode))
	return m, tea.Batch(m.sp.Tick, func() tea.Msg {
		out, err := performSetupMCBind(m.ctx, m.store, code, m.panelHost, m.osUser)
		out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser)
		return mcBindMsg{outcome: out, err: err}
	})
}
+1 −1
Changes for cmd/felis/tui_root.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -211,7 +211,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
		}
		m.stage = stageOwner
		if m.mode == consoleModeSetup {
			return m.adopt(newMCBindModel(m.ctx, m.store, defaultPanelHostname(m.rootDomain, m.panelHost), m.osUser))
			return m.adopt(newMCBindModel(m.ctx, m.store, defaultAdminHostname(m.rootDomain, m.adminHost), m.osUser))
		}
		return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))

Loading