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.
This commit is contained in:
11 files changed
+125
-33
No files matched your search
+15
-11
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
+3
-3
@@ -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:"
|
||||
|
||||
@@ -92,6 +92,14 @@ type API struct {
|
||||
// never hardcoded.
|
||||
RootDomain string
|
||||
|
||||
// AdminHostname is the operator console host (op.console.<root_domain>) 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.<RootDomain> (see hostIsAdminConsole). On the player
|
||||
// console (console.<root_domain>) 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.<root>/setup?token=<raw>
|
||||
// https://op.console.<root>/setup?token=<raw>
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -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.<root_domain>) 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))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user