From d26acc20ae09c9fe4fad76039b0db3a8fcee6d31 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Mon, 20 Jul 2026 11:10:23 +0900 Subject: [PATCH] feat(api): let in-game staff manage any server without claiming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web face has always granted staff the run of the fleet (isOwnerOrAdmin passes an admin for stop/command/console/access on any node), but the internal face explicitly had "no admin tier": a linked administrator in game could only wake servers they owned or that autostartPolicy permitted. The only way to manage another player's (or an unclaimed) server from inside the game was to claim it — seizing ownership and burning the admin's own quota. Give authorizeWakeByUUID the admin tier on the same trust anchor the op-login approve already uses: verified online-mode UUID -> account link -> stored role. A linked staff member now wakes ANY node under any policy (so `/felis go` works fleet-wide without claiming); the owner bypass and the policy gates are unchanged, and an unlinked UUID still fails safe. Centralize the staff-role rule while at it: staffRole(role) in auth.go (admin, plus owner as its superset) now backs Principal.IsAdmin, the session ViaAdminAccess grading, the op-login approve gate and the new wake tier. That also fixes a real hole in the approve gate, which required role=admin exactly: an Owner manually promoted to role='owner' per migration 0011's upgrade note would have been refused by their own in-game approval door. The lobby menu still renders "Claim & Start" on ownerless tiles — claiming becomes optional for staff rather than the only entry — so the velocity plugin needs no change. --- internal/api/auth.go | 10 +++++- internal/api/handlers_internal.go | 35 +++++++++++++-------- internal/api/handlers_internal_wake_test.go | 24 ++++++++++++++ internal/api/handlers_op_login.go | 10 +++--- internal/api/handlers_op_login_test.go | 19 +++++++++-- internal/api/session.go | 2 +- 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/internal/api/auth.go b/internal/api/auth.go index f244cab..4346f3b 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -40,12 +40,20 @@ type Principal struct { ViaSession bool } +// staffRole reports whether a stored user role carries staff standing: admin, +// or owner (the superset of admin, see IsAdmin). This is the single place the +// role set is spelled out — every staff gate (web IsAdmin, session grading, +// the in-game op-login approve and admin wake) routes through it. +func staffRole(role string) bool { + return role == "admin" || role == "owner" +} + // IsAdmin reports whether the principal may perform admin-tier operations. // Both the role claim and the admin Access path are required: a role=admin // session arriving on panel.* must not bypass the Zero-Trust boundary. // An owner implicitly passes this check (the owner role is a superset of admin). func (p *Principal) IsAdmin() bool { - return p != nil && (p.Role == "admin" || p.Role == "owner") && p.ViaAdminAccess + return p != nil && staffRole(p.Role) && p.ViaAdminAccess } // IsOwner reports whether the principal holds the platform-level owner role diff --git a/internal/api/handlers_internal.go b/internal/api/handlers_internal.go index b2d765a..aff4f75 100644 --- a/internal/api/handlers_internal.go +++ b/internal/api/handlers_internal.go @@ -302,29 +302,38 @@ func (a *API) handleInternalMenuStatus(w http.ResponseWriter, r *http.Request) { // authorizeWakeByUUID is the internal-face counterpart of authorizeWake (spec // §9.4): it applies the autostartPolicy gate for a wake driven by velocity, where // the joining player is known only by their verified online-mode UUID rather than -// a web Principal. There is no admin tier on this path — a raw UUID carries no -// panel role — but the owner bypass still applies, mirroring the external gate: -// the owner waking their own server by domain passes under any policy. An unlinked -// UUID (no account_links row) cannot establish ownership and falls through to the -// policy gate, so ownerOnly/unset fails safe exactly as on the web face. +// a web Principal. The admin tier rides the same trust anchor as the op-login +// approve — online-mode auth plus the account link plus the stored staff role — +// so a linked administrator wakes ANY node without claiming it, mirroring the +// external gate's IsAdmin bypass. The owner bypass applies as before, and an +// unlinked UUID (no account_links row) carries no standing at all and falls +// through to the policy gate, so ownerOnly/unset fails safe exactly as on the +// web face. func (a *API) authorizeWakeByUUID(ctx context.Context, mcUUID string, info *ServerInfo, rec *ServerRecord) error { // public needs no identity at all — skip the account_links resolution. if info.AutostartPolicy == string(v1alpha1.AutostartPublic) { return nil } - // Owner bypass: resolve the UUID to its linked user and compare to the owner. - // A missing link is not an error here — it just means "not the owner". - if rec != nil && rec.OwnerID != "" { - switch userID, err := a.Repo.UserByMCUUID(ctx, mcUUID); { + // Resolve the UUID to its linked user once; staff role or ownership grants + // the bypass. A missing link is not an error here — it just means "no + // standing", and a link pointing at a vanished user reads the same way. + switch userID, err := a.Repo.UserByMCUUID(ctx, mcUUID); { + case err == nil: + switch u, err := a.Repo.UserByID(ctx, userID); { case err == nil: - if userID == rec.OwnerID { + if staffRole(u.Role) { return nil } - case errors.Is(err, ErrNotFound): - // unlinked UUID → fall through to the policy gate - default: + case !errors.Is(err, ErrNotFound): return err } + if rec != nil && rec.OwnerID != "" && userID == rec.OwnerID { + return nil + } + case errors.Is(err, ErrNotFound): + // unlinked UUID → fall through to the policy gate + default: + return err } switch info.AutostartPolicy { case string(v1alpha1.AutostartAllowlist): diff --git a/internal/api/handlers_internal_wake_test.go b/internal/api/handlers_internal_wake_test.go index 18d95ce..1a64456 100644 --- a/internal/api/handlers_internal_wake_test.go +++ b/internal/api/handlers_internal_wake_test.go @@ -100,6 +100,30 @@ func TestInternalWakeAutostartGate(t *testing.T) { } }) + t.Run("ownerOnly: a linked admin wakes someone else's server without claiming", func(t *testing.T) { + api, cl := newInternalWakeAPI("ownerOnly") + repo := api.Repo.(*fakeRepo) + repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} + repo.links[wakeUUID] = "a1" + repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Role: "admin"} + if w := internalWake(api, body); w.Code != http.StatusAccepted { + t.Fatalf("admin: code = %d body %s", w.Code, w.Body.String()) + } + if cl.desired["survival"] != v1alpha1.DesiredRunning { + t.Fatalf("desiredState = %q, want Running", cl.desired["survival"]) + } + }) + + t.Run("allowlist: a linked admin bypasses the list", func(t *testing.T) { + api, _ := newInternalWakeAPI("allowlist") + repo := api.Repo.(*fakeRepo) + repo.links[wakeUUID] = "a1" + repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Role: "owner"} // owner ⊇ admin + if w := internalWake(api, body); w.Code != http.StatusAccepted { + t.Fatalf("staff off-list: code = %d body %s", w.Code, w.Body.String()) + } + }) + t.Run("allowlist: only a listed UUID wakes", func(t *testing.T) { api, _ := newInternalWakeAPI("allowlist") repo := api.Repo.(*fakeRepo) diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go index 3c0595a..08be3e4 100644 --- a/internal/api/handlers_op_login.go +++ b/internal/api/handlers_op_login.go @@ -367,10 +367,10 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required")) return } - // Resolve the in-game approver to a linked account and require admin. An unlinked - // UUID or a non-admin player may never vouch for an op.console login. All three - // refusals share one response so a caller cannot tell "not linked" from "linked but - // not staff". + // Resolve the in-game approver to a linked account and require a staff role + // (admin, or the owner superset). An unlinked UUID or a non-staff player may + // never vouch for an op.console login. All three refusals share one response so + // a caller cannot tell "not linked" from "linked but not staff". notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login") approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID) switch { @@ -390,7 +390,7 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - if approver.Role != "admin" { + if !staffRole(approver.Role) { writeError(w, r, notAdmin) return } diff --git a/internal/api/handlers_op_login_test.go b/internal/api/handlers_op_login_test.go index cf006cc..d009ca9 100644 --- a/internal/api/handlers_op_login_test.go +++ b/internal/api/handlers_op_login_test.go @@ -329,9 +329,10 @@ func TestOpLoginFinishUniform(t *testing.T) { }) } -// TestOpLoginApproveGate pins the in-game approval gate: only a linked role=admin UUID -// may vouch (all refusals share one 403 not_admin), a missing/no-longer-pending request -// is 404, and a bare request without an approver UUID is 400. +// TestOpLoginApproveGate pins the in-game approval gate: only a linked staff UUID +// (role admin or owner) may vouch (all refusals share one 403 not_admin), a +// missing/no-longer-pending request is 404, and a bare request without an +// approver UUID is 400. func TestOpLoginApproveGate(t *testing.T) { plantPending := func(repo *fakeRepo) string { repo.opLogins["r1"] = &fakeOpLogin{ @@ -362,6 +363,18 @@ func TestOpLoginApproveGate(t *testing.T) { } }) + t.Run("a linked owner-role approver vouches too", func(t *testing.T) { + // The owner role is a superset of admin (auth.go staffRole), so a manually + // promoted Owner (migration 0011) must pass the in-game approve gate. + api, repo, _ := seedOpLoginAPI(t) + repo.staff["boss"] = &StaffUser{ID: "b1", Username: "boss", Role: "owner"} + repo.links["cccccccc-cccc-cccc-cccc-cccccccccccc"] = "b1" + id := plantPending(repo) + if w := approveOp(api.InternalHandler(), id, "cccccccc-cccc-cccc-cccc-cccccccccccc"); w.Code != http.StatusOK { + t.Fatalf("owner-role approver: code = %d body %s, want 200", w.Code, w.Body.String()) + } + }) + t.Run("missing approver_uuid -> 400", func(t *testing.T) { api, repo, _ := seedOpLoginAPI(t) id := plantPending(repo) diff --git a/internal/api/session.go b/internal/api/session.go index 965124e..12883eb 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" || u.Role == "owner") && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), + ViaAdminAccess: staffRole(u.Role) && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), EmailVerified: u.EmailVerified, ViaSession: true, }, nil