feat(api): let in-game staff manage any server without claiming it

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.
This commit is contained in:
flyemoji committed 2026-07-20 11:10:23 +09:00
1 parent f0b79e9edd
commit d26acc20ae
6 files changed
+77 -23

No files matched your search

+9 -1
View File
@@ -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
+22 -13
View File
@@ -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):
@@ -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)
+5 -5
View File
@@ -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
}
+16 -3
View File
@@ -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)
+1 -1
View File
@@ -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