From 60732a62832baf370ee6baca914e1841e70a7117 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Thu, 16 Jul 2026 18:03:30 +0900 Subject: [PATCH] feat(operator): gate op.console to staff and land owner setup there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator console (op.console.) 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.). Owner first-run setup is staff onboarding, so `felis setup` mints the one-time setup URL on op.console./setup (was console.). The passkey verifier lists both console and op.console in RPOrigins so the one-time binding asserts on either face under the shared console. 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. the player panel and op.console. 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. --- cmd/felis/api.go | 30 +++++++++------- cmd/felis/breakglass.go | 17 ++++----- cmd/felis/breakglass_test.go | 8 +++-- cmd/felis/tui_mc_bind.go | 8 ++--- cmd/felis/tui_root.go | 2 +- deploy/bootstrap.sh | 6 ++-- internal/api/api.go | 8 +++++ internal/api/api_test.go | 64 ++++++++++++++++++++++++++++++++++ internal/api/handlers_setup.go | 5 +-- internal/api/middleware.go | 12 +++++++ internal/api/session.go | 2 +- 11 files changed, 127 insertions(+), 35 deletions(-) diff --git a/cmd/felis/api.go b/cmd/felis/api.go index c9b75a6..b211df8 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -227,8 +227,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { RootDomain: cfg.Server.RootDomain, AdminHostname: cfg.Auth.AdminHostname, }, - RootDomain: cfg.Server.RootDomain, - WakeCooldown: 30 * time.Second, + 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 // upstream follow connections a single caller can tie up if their streams stall. @@ -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.), and because that is + // a domain suffix of the operator host (op.console.), 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 { diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 194f959..d62ae58 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -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.): 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 diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index f59cefc..de55db0 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -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) } }) } diff --git a/cmd/felis/tui_mc_bind.go b/cmd/felis/tui_mc_bind.go index d2731eb..07963fe 100644 --- a/cmd/felis/tui_mc_bind.go +++ b/cmd/felis/tui_mc_bind.go @@ -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} }) } diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 29f4b9d..0902e3a 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -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)) diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 27c7d72..e7c59c7 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -1478,9 +1478,9 @@ summary() { echo systemctl --no-pager --full status felis-velocity 2>/dev/null | head -n 4 || true echo - log "Panel URL: https://${NODE_IP}:${FELIS_PANEL_NODEPORT}" - log "DNS alias (if your resolver supports it): https://op.console.${FELIS_ROOT_DOMAIN}:${FELIS_PANEL_NODEPORT}" - log "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit." + log "Player panel: https://console.${FELIS_ROOT_DOMAIN} — served on 443 once your edge/Cloudflare Tunnel routes it here." + log "Operator console (Op/Admin/Owner): https://op.console.${FELIS_ROOT_DOMAIN} — the Owner runs 'felis setup' and onboards here." + log "Before the edge is ready: direct + self-signed at https://${NODE_IP}:${FELIS_PANEL_NODEPORT} (browser will warn on first visit)." log "Minecraft address: ${NODE_IP}:${FELIS_GAME_PORT} (point mc.${FELIS_ROOT_DOMAIN} here)" log "The proxy authenticates against Mojang and forwards the verified profile to the" log "login gate; the backends are reachable in-cluster only. Follow it with:" diff --git a/internal/api/api.go b/internal/api/api.go index b6743e0..f8d2645 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -92,6 +92,14 @@ type API struct { // never hardcoded. RootDomain string + // AdminHostname is the operator console host (op.console.) from + // config. requireExternal refuses any non-admin principal that arrives on it, + // so op.console is staff-only at the DOOR — not merely per-route — even on the + // passwordless demo face where Cloudflare Access is not fronting it. Empty + // falls back to op.console. (see hostIsAdminConsole). On the player + // console (console.) the gate is inert. + AdminHostname string + // WakeCooldown throttles repeated wakes per server (spec §9.1: cooldown hangs // on the wake lever). Zero disables throttling. WakeCooldown time.Duration diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8fa7784..dc4f3b9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -2029,6 +2029,70 @@ func TestAdminBoundary(t *testing.T) { }) } +// TestOpConsoleDoorGate proves op.console is staff-only at the DOOR: a non-admin +// principal that reaches the operator host is refused before any handler, even on an +// app-tier route (/me) that no adminOnly wraps. Authentication is not access on +// op.console — the "internal permission verification" the model requires on top of +// Zero-Trust. The gate is scoped to the admin host, so the identical principal is +// unaffected on the player console. +func TestOpConsoleDoorGate(t *testing.T) { + opHost := "op.console." + testRoot + playerHost := "console." + testRoot + + newAPI := func(p *Principal) *API { + a := newTestAPI(newFakeRepo(), newFakeCluster()) + a.AdminHostname = opHost + a.External = staticExternal{p: p} + return a + } + + t.Run("non-admin on op.console refused at the door", func(t *testing.T) { + a := newAPI(&Principal{UserID: "u", Role: "user", ViaAdminAccess: false}) + w := do(a.ExternalHandler(), "GET", "https://"+opHost+"/api/v1/me", "", nil) + if w.Code != http.StatusForbidden { + t.Fatalf("non-admin on op.console: code = %d, want 403 (staff-only door)", w.Code) + } + }) + t.Run("same non-admin on the player console passes the door", func(t *testing.T) { + a := newAPI(&Principal{UserID: "u", Role: "user", ViaAdminAccess: false}) + w := do(a.ExternalHandler(), "GET", "https://"+playerHost+"/api/v1/me", "", nil) + if w.Code == http.StatusForbidden { + t.Fatalf("player console must not be gated by the op.console door, got 403") + } + }) + t.Run("admin on op.console reaches the handler", func(t *testing.T) { + a := newAPI(&Principal{UserID: "a", Role: "admin", ViaAdminAccess: true}) + w := do(a.ExternalHandler(), "GET", "https://"+opHost+"/api/v1/me", "", nil) + if w.Code == http.StatusForbidden { + t.Fatalf("admin must pass the op.console door, got 403") + } + }) +} + +// TestSessionAuthOwnerGetsAdminAccess guards the owner-inclusion fix: a role=owner +// local session on op.console must carry ViaAdminAccess (and thus IsOwner()). The +// owner is a superset of admin, so excluding it from the session admin-path — as the +// code once did (u.Role == "admin" only) — silently made IsOwner() unreachable via a +// passwordless session, locking the platform owner out of the operator console. +func TestSessionAuthOwnerGetsAdminAccess(t *testing.T) { + repo := newFakeRepo() + repo.settings[LocalAuthEnabledKey] = []byte("true") + repo.staff["owner"] = &StaffUser{ID: "u1", Email: "owner@" + testRoot, Role: "owner"} + token := "session-token" + repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: time.Now().Add(time.Hour)} + auth := SessionAuth{Repo: repo, RootDomain: testRoot, AdminHostname: "op.console." + testRoot} + + r := httptest.NewRequest("GET", "https://op.console."+testRoot+"/api/v1/me", nil) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + p, err := auth.Authenticate(r) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if !p.ViaAdminAccess || !p.IsOwner() { + t.Fatalf("owner via local session on op.console must carry admin access AND IsOwner, got %+v", p) + } +} + // ---- error envelope ---- func TestErrorEnvelopeHasRequestID(t *testing.T) { diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go index 64879d2..17350c4 100644 --- a/internal/api/handlers_setup.go +++ b/internal/api/handlers_setup.go @@ -11,9 +11,10 @@ import ( // Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind // flow mints a one-time token and prints a URL like: // -// https://console./setup?token= +// https://op.console./setup?token= // -// The Owner opens that URL in a browser; the SPA reads the token from the query +// The Owner is staff, so onboarding lands on the operator console; the SPA there +// reads the token from the query // string and POSTs it here. This handler consumes the token (single-use, hashed // at rest like session cookies), mints a felis_session, and returns the caller's // setup state so the frontend can guide email verification + passkey enrollment diff --git a/internal/api/middleware.go b/internal/api/middleware.go index d2ecd4f..b25ab30 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -89,6 +89,18 @@ func (a *API) requireExternal(next http.Handler) http.Handler { writeError(w, r, errUnauthorized) return } + // op.console door gate: the operator console is staff-only, so a request that + // arrives on the admin host from a non-admin principal is refused HERE, before + // any handler. Authentication alone (a player's passkey/email/bind session) is + // not access — internal permission is verified on top of it, so possessing a + // valid credential never "lets you in" to op.console. On the player console + // (console.) hostIsAdminConsole is false, so this is inert; in + // production Cloudflare Access already blocks non-staff at the edge and this is + // the defense-in-depth backstop for the passwordless (no-Zero-Trust) face. + if hostIsAdminConsole(r, a.RootDomain, a.AdminHostname) && !p.IsAdmin() { + writeError(w, r, errForbidden) + return + } ctx := context.WithValue(r.Context(), ctxKeyPrincipal, p) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/api/session.go b/internal/api/session.go index da1c93c..965124e 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -159,7 +159,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) { UserID: u.ID, Email: u.Email, Role: u.Role, - ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), + ViaAdminAccess: (u.Role == "admin" || u.Role == "owner") && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), EmailVerified: u.EmailVerified, ViaSession: true, }, nil