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