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 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. // IsAdmin reports whether the principal may perform admin-tier operations.
// Both the role claim and the admin Access path are required: a role=admin // 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. // 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). // An owner implicitly passes this check (the owner role is a superset of admin).
func (p *Principal) IsAdmin() bool { 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 // 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 // authorizeWakeByUUID is the internal-face counterpart of authorizeWake (spec
// §9.4): it applies the autostartPolicy gate for a wake driven by velocity, where // §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 // 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 // a web Principal. The admin tier rides the same trust anchor as the op-login
// panel role — but the owner bypass still applies, mirroring the external gate: // approve — online-mode auth plus the account link plus the stored staff role —
// the owner waking their own server by domain passes under any policy. An unlinked // so a linked administrator wakes ANY node without claiming it, mirroring the
// UUID (no account_links row) cannot establish ownership and falls through to the // external gate's IsAdmin bypass. The owner bypass applies as before, and an
// policy gate, so ownerOnly/unset fails safe exactly as on the web face. // 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 { 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. // public needs no identity at all — skip the account_links resolution.
if info.AutostartPolicy == string(v1alpha1.AutostartPublic) { if info.AutostartPolicy == string(v1alpha1.AutostartPublic) {
return nil return nil
} }
// Owner bypass: resolve the UUID to its linked user and compare to the owner. // Resolve the UUID to its linked user once; staff role or ownership grants
// A missing link is not an error here — it just means "not the owner". // the bypass. A missing link is not an error here — it just means "no
if rec != nil && rec.OwnerID != "" { // standing", and a link pointing at a vanished user reads the same way.
switch userID, err := a.Repo.UserByMCUUID(ctx, mcUUID); { switch userID, err := a.Repo.UserByMCUUID(ctx, mcUUID); {
case err == nil:
switch u, err := a.Repo.UserByID(ctx, userID); {
case err == nil: case err == nil:
if userID == rec.OwnerID { if staffRole(u.Role) {
return nil return nil
} }
case errors.Is(err, ErrNotFound): case !errors.Is(err, ErrNotFound):
// unlinked UUID → fall through to the policy gate
default:
return err 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 { switch info.AutostartPolicy {
case string(v1alpha1.AutostartAllowlist): 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) { t.Run("allowlist: only a listed UUID wakes", func(t *testing.T) {
api, _ := newInternalWakeAPI("allowlist") api, _ := newInternalWakeAPI("allowlist")
repo := api.Repo.(*fakeRepo) 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")) writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
return return
} }
// Resolve the in-game approver to a linked account and require admin. An unlinked // Resolve the in-game approver to a linked account and require a staff role
// UUID or a non-admin player may never vouch for an op.console login. All three // (admin, or the owner superset). An unlinked UUID or a non-staff player may
// refusals share one response so a caller cannot tell "not linked" from "linked but // never vouch for an op.console login. All three refusals share one response so
// not staff". // 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") notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID) approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID)
switch { switch {
@@ -390,7 +390,7 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return return
} }
if approver.Role != "admin" { if !staffRole(approver.Role) {
writeError(w, r, notAdmin) writeError(w, r, notAdmin)
return 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 // TestOpLoginApproveGate pins the in-game approval gate: only a linked staff UUID
// may vouch (all refusals share one 403 not_admin), a missing/no-longer-pending request // (role admin or owner) may vouch (all refusals share one 403 not_admin), a
// is 404, and a bare request without an approver UUID is 400. // missing/no-longer-pending request is 404, and a bare request without an
// approver UUID is 400.
func TestOpLoginApproveGate(t *testing.T) { func TestOpLoginApproveGate(t *testing.T) {
plantPending := func(repo *fakeRepo) string { plantPending := func(repo *fakeRepo) string {
repo.opLogins["r1"] = &fakeOpLogin{ 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) { t.Run("missing approver_uuid -> 400", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t) api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo) id := plantPending(repo)
+1 -1
View File
@@ -159,7 +159,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
UserID: u.ID, UserID: u.ID,
Email: u.Email, Email: u.Email,
Role: u.Role, 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, EmailVerified: u.EmailVerified,
ViaSession: true, ViaSession: true,
}, nil }, nil