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