fix(auth): make the owner role real — provisioning, staff doors, panel guards

Found live while verifying the admin email-edit fix: the Owner account could
not load /api/v1/users at all. Root cause: migration 0011 adds the 'owner'
role and gates every user-administration route on it, but NOTHING ever wrote
it. break-glass (UpsertOwner), the setup MC-bind (CompleteOwnerSetup), and the
re-provision path all forced 'admin', so in a fresh install the entire
owner tier — list/create/edit/disable/delete users, quotas, sessions — was
unreachable. The role was a dead letter in the other direction too: staff
predicates that predate the role did not know it.

- UpsertOwner and CompleteOwnerSetup now write role='owner'; the username-
  conflict arm re-asserts it, which is also the documented pre-0011 promotion
  path ("re-provision via break-glass"). InsertOperator stays plain 'admin'.
- Staff doors learn the role: op-login start/finish admit the Owner; the
  player email door refuses it like any staff account; the in-game approver
  check already used staffRole.
- Reclaim protection: IsProtectedAdminLink (and the break-glass bootstrap
  switch AdminExists) count admin OR owner — the Owner must never be displaced
  by a Mojang-priority reclaim.
- Panel guards make migration 0011's claim true now that owner rows exist: an
  owner can never be demoted, deleted, or disabled through the API (only the
  local break-glass console resets the identity); username/email edits still
  work.

Tests: pgint pins both provisioning paths, the protected-link predicate and
the reset/promote semantics; hermetic suites cover the owner-admitting staff
door, the owner-refusing player door, the three panel guards, and break-glass
attribution.
This commit is contained in:
Lemon-miaow committed 2026-09-23 03:30:29 +08:00
1 parent d1ec40f738
commit e0d23780d8
16 files changed
+349 -80

No files matched your search

+15 -12
View File
@@ -80,8 +80,9 @@ type ownerStore interface {
// InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is // InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is
// insert-only: a username already taken is a conflict (api.ErrConflict), never a // insert-only: a username already taken is a conflict (api.ErrConflict), never a
// silent reset, so adding an Operator can never clobber the Owner or an existing // silent reset, so adding an Operator can never clobber the Owner or an existing
// Operator. The row is role=admin, identical in shape to the Owner — Felis has no // Operator. The row is role=admin — an Operator is staff BELOW the single
// separate operator DB role (migration 0003: staff = role=admin). // role=owner identity (migration 0011 adds that role); the two are the only
// staff roles.
InsertOperator(ctx context.Context, id, username, email string) error InsertOperator(ctx context.Context, id, username, email string) error
// CompleteOwnerSetup atomically consumes the in-game link code, creates or // CompleteOwnerSetup atomically consumes the in-game link code, creates or
// promotes the bound Owner, enables local auth, and stores the one-time setup // promotes the bound Owner, enables local auth, and stores the one-time setup
@@ -266,14 +267,16 @@ func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matc
if err != nil { if err != nil {
return "", false, err return "", false, err
} }
if u.Role != "admin" { // Staff means admin OR owner: recovery attribution must accept the Owner (the
// primary break-glass identity), not just plain admins.
if u.Role != "admin" && u.Role != "owner" {
return "", false, nil return "", false, nil
} }
return u.Username, true, nil return u.Username, true, nil
} }
// provisionOwner mints or resets the single Owner account direct-to-Postgres, // provisionOwner mints or resets the single Owner account direct-to-Postgres,
// passwordless. The account is role=admin with no password — the Owner completes // passwordless. The account is role=owner with no password — the Owner completes
// passwordless login setup via the web setup-token flow after `felis setup`. // passwordless login setup via the web setup-token flow after `felis setup`.
func provisionOwner(ctx context.Context, s ownerStore, username, email string) error { func provisionOwner(ctx context.Context, s ownerStore, username, email string) error {
username = strings.TrimSpace(username) username = strings.TrimSpace(username)
@@ -290,13 +293,13 @@ func provisionOwner(ctx context.Context, s ownerStore, username, email string) e
return nil return nil
} }
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the // provisionOperator mints a NEW Operator staff account direct-to-Postgres. It is
// Owner it is role=admin and passwordless — Felis has no separate operator DB role, // role=admin and passwordless — an additional staff admin below the single
// so an Operator is simply an additional staff admin (migration 0003). UNLIKE // role=owner identity (migrations 0003 + 0011). UNLIKE provisionOwner, which
// provisionOwner, which upserts the single Owner and resets it on a username // upserts the single Owner and resets it on a username conflict, this is
// conflict, this is insert-only: a username already taken returns api.ErrConflict // insert-only: a username already taken returns api.ErrConflict rather than
// rather than overwriting a live account, so adding an Operator can never silently // overwriting a live account, so adding an Operator can never silently clobber
// clobber the Owner's or another Operator's account. // the Owner's or another Operator's account.
func provisionOperator(ctx context.Context, s ownerStore, username, email string) error { func provisionOperator(ctx context.Context, s ownerStore, username, email string) error {
username = strings.TrimSpace(username) username = strings.TrimSpace(username)
if username == "" { if username == "" {
@@ -387,7 +390,7 @@ func newSetupToken() (raw, hash string, err error) {
// performSetupMCBind is the `felis setup` Owner-establishment path: the operator // performSetupMCBind is the `felis setup` Owner-establishment path: the operator
// binds their Minecraft account via a one-time link code the login gate handed // 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), // them in-game, the bound user is promoted to role='owner' (passwordless Owner),
// local auth is enabled, and a one-time setup URL is minted for the first web // 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. adminHostname is the // 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, // operator-console host the URL points at (op.console.<root>): the Owner is staff,
+10
View File
@@ -264,6 +264,16 @@ func TestAuthenticateAdmin(t *testing.T) {
} }
}) })
t.Run("the owner role attributes like an admin", func(t *testing.T) {
owner := mkAdmin("root")
owner.Role = "owner" // the platform owner is staff too (migration 0011)
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": owner}}
matched, ok, err := authenticateAdmin(ctx, f, "root")
if err != nil || !ok || matched != "root" {
t.Fatalf("authenticateAdmin(owner) = (%q, %v, %v), want (root, true, nil)", matched, ok, err)
}
})
t.Run("an unknown user is a non-match, not an error", func(t *testing.T) { t.Run("an unknown user is a non-match, not an error", func(t *testing.T) {
f := &fakeOwnerStore{} f := &fakeOwnerStore{}
_, ok, err := authenticateAdmin(ctx, f, "nobody") _, ok, err := authenticateAdmin(ctx, f, "nobody")
+3 -3
View File
@@ -630,15 +630,15 @@ func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool
} }
// IsProtectedAdminLink mirrors PGRepo's JOIN of account_links to users: linked, // IsProtectedAdminLink mirrors PGRepo's JOIN of account_links to users: linked,
// auth_source 'thirdparty', and the linked user an admin — no password-hash test, so // auth_source 'thirdparty', and the linked user staff (admin OR owner) — no
// an SSO Operator (role='admin', with no password) is protected like any other. // password-hash test, so an SSO Operator or the Owner is protected like any other.
func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) { func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) {
userID, ok := f.links[mcUUID] userID, ok := f.links[mcUUID]
if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty { if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty {
return false, nil return false, nil
} }
for _, u := range f.staff { for _, u := range f.staff {
if u.ID == userID && u.Role == "admin" { if u.ID == userID && staffRole(u.Role) {
return true, nil return true, nil
} }
} }
+3 -3
View File
@@ -17,13 +17,13 @@ type Principal struct {
UserID string UserID string
// Email is the audited actor identity (spec §14: audit actor = Access email). // Email is the audited actor identity (spec §14: audit actor = Access email).
Email string Email string
// Role is "admin" or "user" (mirrors users.role). // Role is "owner", "admin", or "user" (mirrors users.role).
Role string Role string
// ViaAdminAccess is true only when the request arrived through an admin-graded // ViaAdminAccess is true only when the request arrived through an admin-graded
// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR // path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
// a local session presented on the op.console host (SessionAuth, the // a local session presented on the op.console host (SessionAuth, the
// passwordless face). Admin-tier operations require it in addition to // passwordless face). Admin-tier operations require it in addition to
// Role=="admin" (spec §14: ZT is graded by operation). A role=admin session // a staff role (spec §14: ZT is graded by operation). A staff session
// arriving on the player console (console.*) never sets it. // arriving on the player console (console.*) never sets it.
ViaAdminAccess bool ViaAdminAccess bool
// EmailVerified mirrors users.email_verified. The lockdown middleware gates // EmailVerified mirrors users.email_verified. The lockdown middleware gates
@@ -49,7 +49,7 @@ func staffRole(role string) bool {
} }
// 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 staff
// 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 {
+5 -5
View File
@@ -44,11 +44,11 @@ var (
// consumed, or expired) — so handlers map it to 400, not 404. // consumed, or expired) — so handlers map it to 400, not 404.
ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired") ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired")
// ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF // ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF
// account (role=admin), which the player-console bootstrap refuses (console-tier // account (admin or owner), which the player-console bootstrap refuses
// access model). Operators authenticate at op.console behind Zero Trust, never via // (console-tier access model). Staff authenticate at op.console behind Zero Trust,
// the account-less console.<root_domain> door, so the public bootstrap provably // never via the account-less console.<root_domain> door, so the public bootstrap
// never mints a session for an admin identity. It is distinct from ErrConflict so // provably never mints a session for a staff identity. It is distinct from
// the handler answers 403 (wrong door) rather than 409 (already linked). // ErrConflict so the handler answers 403 (wrong door) rather than 409.
ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account") ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account")
// ErrEmailTaken means a verified email would collide with another account's // ErrEmailTaken means a verified email would collide with another account's
// already-verified address (spec §B email-first login foundation; the // already-verified address (spec §B email-first login foundation; the
+3 -1
View File
@@ -241,7 +241,9 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
// Code redeemed. Refuse staff here — never before the verify — so op.console keeps // Code redeemed. Refuse staff here — never before the verify — so op.console keeps
// its Zero-Trust + in-game-approval gates and this public door provably yields only // its Zero-Trust + in-game-approval gates and this public door provably yields only
// a role=user player session (mirrors handleBindRedeem's refuse-staff contract). // a role=user player session (mirrors handleBindRedeem's refuse-staff contract).
if u.Role == "admin" { // Staff means anything above role=user: an admin OR the role=owner identity. The
// player door must yield only player sessions.
if u.Role != "user" {
writeError(w, r, newError(http.StatusForbidden, "staff_account", writeError(w, r, newError(http.StatusForbidden, "staff_account",
"that account is staff; sign in at the operator console")) "that account is staff; sign in at the operator console"))
return return
+9 -1
View File
@@ -510,12 +510,20 @@ func TestLoginEmailPurposeSeparation(t *testing.T) {
// a wrong code for a staff address answers the same invalid_code as for anyone, and // a wrong code for a staff address answers the same invalid_code as for anyone, and
// the 403 costs the valid code (verify-then-refuse), so it cannot be farmed as an // the 403 costs the valid code (verify-then-refuse), so it cannot be farmed as an
// is-this-address-staff oracle. // is-this-address-staff oracle.
// Staff means admin AND owner (migration 0011): both must be refused at the
// player door, after the code proves mailbox control.
func TestLoginEmailVerifyRefusesStaff(t *testing.T) { func TestLoginEmailVerifyRefusesStaff(t *testing.T) {
for _, role := range []string{"admin", "owner"} {
t.Run(role, func(t *testing.T) { verifyStaffRefusedAtPlayerDoor(t, role) })
}
}
func verifyStaffRefusedAtPlayerDoor(t *testing.T, role string) {
repo := newFakeRepo() repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true") repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["owner"] = &StaffUser{ repo.staff["owner"] = &StaffUser{
ID: "a1", Username: "owner", Email: "[email protected]", ID: "a1", Username: "owner", Email: "[email protected]",
Role: "admin", EmailVerified: true, Role: role, EmailVerified: true,
} }
mailer := &captureMailer{} mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster()) api := newTestAPI(repo, newFakeCluster())
+5 -5
View File
@@ -38,11 +38,11 @@ import (
// in-game identity is the root of trust, so re-minting a code always re-grants a // in-game identity is the root of trust, so re-minting a code always re-grants a
// session even after email/passkey are bound. "登录并非强制,但没登录什么都干不了". // session even after email/passkey are bound. "登录并非强制,但没登录什么都干不了".
// //
// op.console stays behind Zero Trust. A code whose UUID belongs to STAFF (role=admin) // op.console stays behind Zero Trust. A code whose UUID belongs to STAFF (admin or
// is refused here (ErrPlayerBindForbidden → 403), so the public bootstrap provably // owner) is refused here (ErrPlayerBindForbidden → 403), so the public bootstrap
// never mints a session for an admin identity — the sole tier it yields is a role=user // provably never mints a session for a staff identity — the sole tier it yields is a
// player session, host-only to console.<root_domain> (never sent to op.console) and // role=user player session, host-only to console.<root_domain> (never sent to
// carrying ViaAdminAccess=false. "op.console 必须得 Auth". // op.console) and carrying ViaAdminAccess=false. "op.console 必须得 Auth".
// newUserID returns an opaque random user id (128 bits, hex), matching the shape of // newUserID returns an opaque random user id (128 bits, hex), matching the shape of
// the ids break-glass mints for staff rows. // the ids break-glass mints for staff rows.
+13 -12
View File
@@ -28,7 +28,7 @@ import (
// column keeps it from ever colliding with a console login_email or onboard code). // column keeps it from ever colliding with a console login_email or onboard code).
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending // - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
// request via velocity's /felis command (internal approve). The API's own user // request via velocity's /felis command (internal approve). The API's own user
// table is the sole authority: only a UUID linked to a role=admin account may // table is the sole authority: only a UUID linked to a staff account may
// approve (velocity's command runs for any player and relies on this check). // approve (velocity's command runs for any player and relies on this check).
// //
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed // finish mints the session only when BOTH have landed. Neither factor alone — a mailed
@@ -137,9 +137,10 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return return
} }
// op.console is the STAFF door: a non-admin who typed their address here (they belong // op.console is the STAFF door: a player who typed their address here (they belong
// on console.<root_domain>) gets the neutral response, never a request or a code. // on console.<root_domain>) gets the neutral response, never a request or a code.
if u.Role != "admin" { // Staff means admin OR owner — the Owner is the primary op.console user.
if !staffRole(u.Role) {
neutral() neutral()
return return
} }
@@ -290,15 +291,15 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err) writeError(w, r, err)
return return
} }
// Load the staff account for the session + response. Re-assert admin as defence in // Load the staff account for the session + response. Re-assert staff as defence in
// depth: only admins ever get a request minted, but the session must never be issued // depth: only staff ever get a request minted, but the session must never be issued
// to a non-admin identity even if the row were somehow otherwise. // to a non-staff identity even if the row were somehow otherwise.
u, err := a.Repo.UserByID(r.Context(), loginReq.UserID) u, err := a.Repo.UserByID(r.Context(), loginReq.UserID)
if err != nil { if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
if u.Role != "admin" { if !staffRole(u.Role) {
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator")) writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
return return
} }
@@ -343,7 +344,7 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the // opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account // in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is role=admin — this check against the API's // and refuses unless that account is staff (admin or owner) — this check against the API's
// authoritative user table is the only gate; velocity's command itself is unprivileged. // authoritative user table is the only gate; velocity's command itself is unprivileged.
type opLoginApproveRequest struct { type opLoginApproveRequest struct {
ApproverUUID string `json:"approver_uuid"` ApproverUUID string `json:"approver_uuid"`
@@ -351,10 +352,10 @@ type opLoginApproveRequest struct {
// handleOpLoginApprove records an in-game admin's vouch for a pending staff login // handleOpLoginApprove records an in-game admin's vouch for a pending staff login
// (internal face), supplying the second factor. It resolves the approver UUID to a // (internal face), supplying the second factor. It resolves the approver UUID to a
// linked role=admin account (else 403), then flips the request approved. A missing or // linked staff account (admin or owner; else 403), then flips the request approved.
// no-longer-pending request is 404. Self-approval is allowed: a staff member online as // A missing or no-longer-pending request is 404. Self-approval is allowed: a staff
// their own admin identity supplies a genuine second factor (in-game session control) // member online as their own admin identity supplies a genuine second factor
// distinct from the mailbox factor. // (in-game session control) distinct from the mailbox factor.
func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
var req opLoginApproveRequest var req opLoginApproveRequest
+38
View File
@@ -170,6 +170,44 @@ func TestOpLoginVertical(t *testing.T) {
} }
} }
// TestOpLoginOwnerAdmitted pins that the staff door admits the Owner (role=owner),
// not just plain admins: the Owner is the primary op.console identity, so a
// role check of "admin only" would strand it outside its own console.
func TestOpLoginOwnerAdmitted(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["owner"] = &StaffUser{
ID: "o1", Username: "owner", Email: "[email protected]",
Role: "owner", EmailVerified: true,
}
repo.links[opUUID] = "o1"
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
eh := api.ExternalHandler()
ih := api.InternalHandler()
w := startOp(eh, "[email protected]")
if w.Code != http.StatusAccepted {
t.Fatalf("owner start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
reqID, _ := acctBody(t, w)["request_id"].(string)
if reqID == "" || mailer.calls != 1 || len(repo.opLogins) != 1 {
t.Fatalf("owner start must mint a request + mail a code: req=%q mails=%d rows=%d",
reqID, mailer.calls, len(repo.opLogins))
}
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
}
w = finishOp(eh, reqID, mailer.code)
if w.Code != http.StatusOK {
t.Fatalf("owner finish: code = %d body %s, want 200", w.Code, w.Body.String())
}
if vb := acctBody(t, w); vb["role"] != "owner" {
t.Fatalf("finish role = %v, want owner", vb["role"])
}
}
// TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is // TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is
// the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying // the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying
// a request_id + expires_at, mint/mail nothing, and still burn the per-recipient // a request_id + expires_at, mint/mail nothing, and still burn the per-recipient
+31
View File
@@ -140,6 +140,18 @@ func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) {
return return
} }
// Owner protection (migration 0011): the owner row is the one identity the
// panel may never demote — only the local break-glass console resets it.
// Username/email edits on it stay allowed. A failed detail read falls through;
// UpdateUser then answers the real 404.
if body.Role != nil && *body.Role != "owner" {
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
writeError(w, r, newError(http.StatusForbidden, "forbidden",
"the owner account's role cannot be changed from the panel"))
return
}
}
if body.Username != nil { if body.Username != nil {
if err := validateUsername(*body.Username); err != nil { if err := validateUsername(*body.Username); err != nil {
writeError(w, r, err) writeError(w, r, err)
@@ -186,6 +198,14 @@ func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
return return
} }
// Same owner protection as the role guard above: only break-glass retires the
// owner identity. A failed detail read falls through to the real 404.
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
writeError(w, r, newError(http.StatusForbidden, "forbidden",
"the owner account cannot be deleted from the panel"))
return
}
if err := a.Repo.DeleteUser(r.Context(), id, p.Email); err != nil { if err := a.Repo.DeleteUser(r.Context(), id, p.Email); err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found")) writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
@@ -222,6 +242,17 @@ func (a *API) handleDisableUser(w http.ResponseWriter, r *http.Request) {
return return
} }
// Owner protection (migration 0011): disabling locks the owner out and revokes
// its sessions — effectively a demotion, so the panel refuses it; only
// break-glass touches the owner identity. Re-enabling stays allowed.
if body.Disabled {
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
writeError(w, r, newError(http.StatusForbidden, "forbidden",
"the owner account cannot be disabled from the panel"))
return
}
}
if err := a.Repo.SetUserDisabled(r.Context(), id, body.Disabled); err != nil { if err := a.Repo.SetUserDisabled(r.Context(), id, body.Disabled); err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found")) writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
+53
View File
@@ -0,0 +1,53 @@
package api
import (
"net/http"
"testing"
)
// The owner row is the one identity the panel may never demote, delete, or
// disable (migration 0011) — only the local break-glass console resets it.
// These guards became load-bearing the moment provisioning started actually
// writing role='owner'; before that the owner tier was simply unreachable, so
// nothing could reach them.
func TestOwnerAccountProtectedFromPanelMutations(t *testing.T) {
owner := &Principal{UserID: "usr-root", Role: "owner", ViaAdminAccess: true}
repo := newFakeRepo()
repo.seedUser(UserView{ID: "usr-root", Username: "root", Role: "owner"})
repo.seedUser(UserView{ID: "usr-owner2", Username: "spare-owner", Role: "owner"})
repo.seedUser(UserView{ID: "u2", Username: "alice", Role: "user"})
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: owner}
eh := api.ExternalHandler()
t.Run("role change refused", func(t *testing.T) {
w := do(eh, "PATCH", "/api/v1/users/usr-owner2", `{"role":"admin"}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("demote owner: code = %d body %s, want 403", w.Code, w.Body.String())
}
})
t.Run("delete refused", func(t *testing.T) {
w := do(eh, "DELETE", "/api/v1/users/usr-owner2", "", nil)
if w.Code != http.StatusForbidden {
t.Fatalf("delete owner: code = %d body %s, want 403", w.Code, w.Body.String())
}
})
t.Run("disable refused", func(t *testing.T) {
w := do(eh, "POST", "/api/v1/users/usr-owner2/disable", `{"disabled":true}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("disable owner: code = %d body %s, want 403", w.Code, w.Body.String())
}
})
t.Run("email edits on an owner stay allowed", func(t *testing.T) {
w := do(eh, "PATCH", "/api/v1/users/usr-owner2", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("edit owner email: code = %d body %s, want 200", w.Code, w.Body.String())
}
})
t.Run("control: a normal user can still be promoted", func(t *testing.T) {
w := do(eh, "PATCH", "/api/v1/users/u2", `{"role":"admin"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("promote user: code = %d body %s, want 200", w.Code, w.Body.String())
}
})
}
+2 -1
View File
@@ -124,7 +124,8 @@ func (a *API) requireExternal(next http.Handler) http.Handler {
// adminOnly gates an external-face handler on the admin Zero-Trust path. The // adminOnly gates an external-face handler on the admin Zero-Trust path. The
// Access middleware has already authenticated; this enforces that admin-tier // Access middleware has already authenticated; this enforces that admin-tier
// operations both carry role=admin AND arrived via admin.* (spec §14). // operations both carry a staff role (admin or owner) AND arrived via admin.*
// (spec §14).
func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc { func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if p := principalFromContext(r.Context()); !p.IsAdmin() { if p := principalFromContext(r.Context()); !p.IsAdmin() {
+31 -26
View File
@@ -159,9 +159,10 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
// together), where both transactions reach the inserts before either commits. // together), where both transactions reach the inserts before either commits.
// Create-or-fetch keyed on the verified UUID. An already-linked role='user' player // Create-or-fetch keyed on the verified UUID. An already-linked role='user' player
// is fetched (idempotent "log in via the game"); a role='admin' STAFF account is // is fetched (idempotent "log in via the game"); any STAFF account (admin or
// refused (op.console only) BEFORE any consume, so the code survives; an unlinked // owner, i.e. role != 'user') is refused (op.console only) BEFORE any consume, so
// UUID births a fresh role='user' player with a uuid-derived unique username. // the code survives; an unlinked UUID births a fresh role='user' player with a
// uuid-derived unique username.
userID := newUserID userID := newUserID
var existingRole string var existingRole string
switch err := tx.QueryRowContext(ctx, switch err := tx.QueryRowContext(ctx,
@@ -209,7 +210,7 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
} }
// CompleteOwnerSetup consumes an in-game link code, creates-or-promotes the bound // CompleteOwnerSetup consumes an in-game link code, creates-or-promotes the bound
// account to the passwordless Owner (role='admin'), enables local auth, and stores // account to the passwordless Owner (role='owner'), enables local auth, and stores
// the one-time first-login token in one transaction. It is the `felis setup` // the one-time first-login token in one transaction. It is the `felis setup`
// MC-bind path: the operator enters limbo, runs /link, and types the code here. // MC-bind path: the operator enters limbo, runs /link, and types the code here.
// Unlike RedeemPlayerBindCode — which refuses an already-staff account so a game // Unlike RedeemPlayerBindCode — which refuses an already-staff account so a game
@@ -240,16 +241,16 @@ func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID, code string,
} }
// Create-or-promote keyed on the verified UUID. An unlinked UUID births a fresh // Create-or-promote keyed on the verified UUID. An unlinked UUID births a fresh
// staff row (role='admin') with a uuid-derived username; an already-linked // staff row (role='owner') with a uuid-derived username; an already-linked
// account is promoted to role='admin' in place (idempotent when it is already // account is promoted to role='owner' in place (idempotent when it already is),
// staff), keeping its id and username. Setup elevates on purpose, so there is no // keeping its id and username. Setup elevates on purpose, so there is no staff
// staff refusal here — that guard belongs to the player path only. // refusal here — that guard belongs to the player path only.
userID := newUserID userID := newUserID
switch err := tx.QueryRowContext(ctx, switch err := tx.QueryRowContext(ctx,
`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); { `SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO users (id, username, role) VALUES ($1, $2, 'admin')`, `INSERT INTO users (id, username, role) VALUES ($1, $2, 'owner')`,
newUserID, mcUUID); err != nil { newUserID, mcUUID); err != nil {
return "", "", "", fmt.Errorf("create owner: %w", err) return "", "", "", fmt.Errorf("create owner: %w", err)
} }
@@ -263,7 +264,7 @@ func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID, code string,
return "", "", "", err return "", "", "", err
default: default:
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE users SET role = 'admin' WHERE id = $1`, userID); err != nil { `UPDATE users SET role = 'owner' WHERE id = $1`, userID); err != nil {
return "", "", "", fmt.Errorf("promote owner: %w", err) return "", "", "", fmt.Errorf("promote owner: %w", err)
} }
} }
@@ -858,17 +859,17 @@ func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool
// who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim // who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim
// exception (spec §B3). The EXISTS joins account_links to users on exactly three // exception (spec §B3). The EXISTS joins account_links to users on exactly three
// conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the // conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the
// linked user is an admin. It intentionally does not test HOW the account signs in: // linked user is staff (admin OR owner — the Owner is the one identity that must never
// an Operator may authenticate via SSO (Cloudflare Access, §14) or any local // be displaced). It intentionally does not test HOW the account signs in: staff may
// passwordless door and must be protected just the same — the sign-in method is // authenticate via SSO (Cloudflare Access, §14) or any local passwordless door and
// orthogonal to "is staff" and "logs in via the Login Server". Keyed by UUID, the // must be protected just the same — the sign-in method is orthogonal to "is staff"
// only identity velocity holds. // and "logs in via the Login Server". Keyed by UUID, the only identity velocity holds.
func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) { func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) {
var ok bool var ok bool
err := p.db.QueryRowContext(ctx, err := p.db.QueryRowContext(ctx,
`SELECT EXISTS( `SELECT EXISTS(
SELECT 1 FROM account_links al JOIN users u ON u.id = al.user_id SELECT 1 FROM account_links al JOIN users u ON u.id = al.user_id
WHERE al.mc_uuid = $1 AND al.auth_source = 'thirdparty' AND u.role = 'admin')`, WHERE al.mc_uuid = $1 AND al.auth_source = 'thirdparty' AND u.role IN ('admin', 'owner'))`,
mcUUID).Scan(&ok) mcUUID).Scan(&ok)
return ok, err return ok, err
} }
@@ -892,13 +893,13 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse
return &u, nil return &u, nil
} }
// AdminExists reports whether any admin account already exists. It is the // AdminExists reports whether any staff account (admin or owner) already exists. It is the
// break-glass console's bootstrap-vs-recovery switch: false means the typed // break-glass console's bootstrap-vs-recovery switch: false means the typed
// credential mints the first Owner (no prior identity to verify against), true // credential mints the first Owner (no prior identity to verify against), true
// means the operator must identify against an existing admin for accountability. // means the operator must identify against an existing staff account for accountability.
// It is not on the Repo interface because only the break-glass CLI consults it. // It is not on the Repo interface because only the break-glass CLI consults it.
func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) { func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) {
const q = `SELECT 1 FROM users WHERE role = 'admin' LIMIT 1` const q = `SELECT 1 FROM users WHERE role IN ('admin', 'owner') LIMIT 1`
var one int var one int
switch err := p.db.QueryRowContext(ctx, q).Scan(&one); { switch err := p.db.QueryRowContext(ctx, q).Scan(&one); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
@@ -926,15 +927,19 @@ func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
} }
// UpsertOwner creates or resets the Owner account direct-to-Postgres (the // UpsertOwner creates or resets the Owner account direct-to-Postgres (the
// break-glass first-run / recovery path). role is forced to 'admin' — the // break-glass first-run / recovery path). role is forced to 'owner' — the
// platform-level identity. On a username conflict the email is overwritten // platform-level identity above admin (migration 0011); every owner-tier route
// while the existing id is preserved, so live sessions referencing it survive // and the panel's owner surfaces gate on exactly this role, so writing a plain
// a reset. The account is passwordless by design. The empty email is stored // 'admin' here would silently strand them. On a username conflict the email is
// as NULL (users.email is nullable). // overwritten while the existing id is preserved, so live sessions referencing
// it survive a reset — and the role is re-asserted, which is also the documented
// promotion path for a pre-0011 install whose Owner row is still 'admin'. The
// account is passwordless by design. The empty email is stored as NULL
// (users.email is nullable).
func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email string) error { func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email string) error {
_, err := p.db.ExecContext(ctx, _, err := p.db.ExecContext(ctx,
`INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'admin') `INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'owner')
ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email`, ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, role = 'owner'`,
id, username, email) id, username, email)
return err return err
} }
+15 -11
View File
@@ -77,10 +77,10 @@ type BackupRecord struct {
} }
// StaffUser is the login-side projection of a users row (spec §B passwordless // StaffUser is the login-side projection of a users row (spec §B passwordless
// auth). Owner/Operator are role=admin rows, minted by `felis setup` (MC link) // auth). The Owner is the role=owner row, minted by `felis setup` (MC link) and
// and recovered by `felis breakGlass` (email OTP); players are role=user rows. // recovered by `felis breakGlass` (email OTP); Operators are role=admin;
// There is no password column — staff authenticate via email-OTP / passkey + // players are role=user. There is no password column — staff authenticate via
// in-game approve, never a password. // email-OTP / passkey + in-game approve, never a password.
type StaffUser struct { type StaffUser struct {
ID string ID string
Username string Username string
@@ -207,9 +207,10 @@ type Repo interface {
// return newUserID; // return newUserID;
// - the uuid is already linked to a role='user' player → return THAT user // - the uuid is already linked to a role='user' player → return THAT user
// (idempotent "log in via the game"), consuming the code; // (idempotent "log in via the game"), consuming the code;
// - the uuid is linked to a role='admin' STAFF account → ErrPlayerBindForbidden // - the uuid is linked to a STAFF account (role != 'user', i.e. admin or owner)
// WITHOUT consuming the code (operators use op.console behind Zero Trust; the // → ErrPlayerBindForbidden WITHOUT consuming the code (staff use op.console
// public bootstrap never mints a session for an admin identity). // behind Zero Trust; the public bootstrap never mints a session for a staff
// identity).
// //
// Safe as an unauthenticated entrypoint because a Bind Code is minted internal-face // Safe as an unauthenticated entrypoint because a Bind Code is minted internal-face
// only (CreateLinkCode), against an online-mode-verified UUID, short-TTL and // only (CreateLinkCode), against an online-mode-verified UUID, short-TTL and
@@ -463,12 +464,13 @@ type Repo interface {
// is staff logging in via the Login Server, not a Mojang squatter, so a // is staff logging in via the Login Server, not a Mojang squatter, so a
// Mojang-priority reclaim must never bar them. The predicate is exactly three // Mojang-priority reclaim must never bar them. The predicate is exactly three
// conjuncts: the UUID is linked (account_links), that link authenticated via // conjuncts: the UUID is linked (account_links), that link authenticated via
// 'thirdparty' (auth_source), and the linked user is an admin (role='admin'). // 'thirdparty' (auth_source), and the linked user is staff — admin or owner
// (migration 0011), the Owner being the identity most in need of the exception.
// It deliberately does NOT ask HOW the staff account signs in: an Operator may // It deliberately does NOT ask HOW the staff account signs in: an Operator may
// authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local // authenticate via SSO (Cloudflare Access, IdP-agnostic per §14) or any local
// passwordless door, and must be protected all the same — the sign-in method is // passwordless door, and must be protected all the same — the sign-in method is
// orthogonal to both "is staff" and "logs in via the Login Server". An unlinked // orthogonal to both "is staff" and "logs in via the Login Server". An unlinked
// UUID, a Mojang-sourced link, or a non-admin link all yield false, so the // UUID, a Mojang-sourced link, or a player link all yield false, so the
// exception never broadens to ordinary thirdparty players (Mojang priority still // exception never broadens to ordinary thirdparty players (Mojang priority still
// displaces them) nor to Mojang-authenticated identities (who have no Login-Server // displaces them) nor to Mojang-authenticated identities (who have no Login-Server
// name to protect). Keyed by UUID — the only identity velocity knows. // name to protect). Keyed by UUID — the only identity velocity knows.
@@ -501,8 +503,10 @@ type Repo interface {
UserByEmail(ctx context.Context, email string) (*StaffUser, error) UserByEmail(ctx context.Context, email string) (*StaffUser, error)
// UpsertOwner creates or resets the single Owner account direct-to-Postgres // UpsertOwner creates or resets the single Owner account direct-to-Postgres
// (the `felis setup` / `felis breakGlass` recovery path). role is forced to // (the `felis setup` / `felis breakGlass` recovery path). role is forced to
// 'admin'; on a username conflict the existing row's email is overwritten so // 'owner' (migration 0011 — the tier every user-admin route gates on); on a
// a reset is idempotent. The account is passwordless by design. // username conflict the existing row's email is overwritten and the role
// re-asserted, so a reset is idempotent and a pre-0011 'admin' Owner row is
// promoted. The account is passwordless by design.
UpsertOwner(ctx context.Context, id, username, email string) error UpsertOwner(ctx context.Context, id, username, email string) error
// CreateSession records a minted session: the sha-256 of the opaque cookie // CreateSession records a minted session: the sha-256 of the opaque cookie
// value, its owner, and its expiry (spec §B sessions). Only the hash is stored, // value, its owner, and its expiry (spec §B sessions). Only the hash is stored,
+113
View File
@@ -357,6 +357,110 @@ func TestUserAdminEmailEditClearsVerification(t *testing.T) {
assertEmailProven(t, u.ID, next, false) assertEmailProven(t, u.ID, next, false)
} }
// The owner tier the panel gates on must actually be WRITTEN: until this
// contract had a test, every provisioning path wrote 'admin', so the whole
// owner surface (user administration) was unreachable in a fresh install.
func TestOwnerProvisioningWritesOwnerRole(t *testing.T) {
ctx := context.Background()
name := "owner-" + suffix(t)
id := "usr-" + suffix(t)
if err := repo.UpsertOwner(ctx, id, name, "owner-"+suffix(t)+"@example.net"); err != nil {
t.Fatalf("UpsertOwner: %v", err)
}
if role := userRole(t, id); role != "owner" {
t.Fatalf("UpsertOwner role = %q, want owner", role)
}
// Re-running the break-glass path resets (email) and PROMOTES (role) in
// place — the documented upgrade for a pre-0011 'admin' Owner row.
if err := repo.UpsertOwner(ctx, "usr-other-"+suffix(t), name, "[email protected]"); err != nil {
t.Fatalf("UpsertOwner (reset): %v", err)
}
var gotID, email, role string
if err := db.QueryRow(`SELECT id, COALESCE(email, ''), role::text FROM users WHERE username = $1`, name).
Scan(&gotID, &email, &role); err != nil {
t.Fatalf("read owner row: %v", err)
}
if gotID != id || email != "[email protected]" || role != "owner" {
t.Fatalf("reset row = (%s, %s, %s), want id preserved + email reset + owner", gotID, email, role)
}
if ok, err := repo.AdminExists(ctx); err != nil || !ok {
t.Fatalf("AdminExists = (%v, %v), want true (the owner counts as staff)", ok, err)
}
// Operators stay plain admins: the owner tier stays singular.
opID := "usr-op-" + suffix(t)
if err := repo.InsertOperator(ctx, opID, "op-"+suffix(t), ""); err != nil {
t.Fatalf("InsertOperator: %v", err)
}
if role := userRole(t, opID); role != "admin" {
t.Fatalf("InsertOperator role = %q, want admin", role)
}
}
// The setup wizard's MC-bind path establishes THE Owner, so it writes the same
// role as break-glass rather than a plain admin.
func TestCompleteOwnerSetupWritesOwnerRole(t *testing.T) {
ctx := context.Background()
now := mustNow()
mc := testUUID(t)
code := "osc-" + suffix(t)
if err := repo.CreateLinkCode(ctx, code, mc, "mojang", now.Add(10*time.Minute)); err != nil {
t.Fatalf("CreateLinkCode: %v", err)
}
newID := "usr-setup-" + suffix(t)
userID, gotUUID, src, err := repo.CompleteOwnerSetup(ctx, newID, code, now,
"tok-"+suffix(t), now.Add(time.Hour))
if err != nil || userID != newID || gotUUID != mc || src != "mojang" {
t.Fatalf("CompleteOwnerSetup = (%s, %s, %s, %v), want (%s, %s, mojang, nil)",
userID, gotUUID, src, err, newID, mc)
}
if role := userRole(t, newID); role != "owner" {
t.Fatalf("CompleteOwnerSetup role = %q, want owner", role)
}
}
// IsProtectedAdminLink is one of the staff predicates the owner role must flow
// through: the Owner logging in via the third-party Yggdrasil must never be
// barred by a Mojang-priority reclaim, exactly like an Operator.
func TestIsProtectedAdminLinkStaffRoles(t *testing.T) {
ctx := context.Background()
now := mustNow()
link := func(userID, source string) string {
t.Helper()
mc := testUUID(t)
code := "pro-" + suffix(t)
if err := repo.CreateLinkCode(ctx, code, mc, source, now.Add(10*time.Minute)); err != nil {
t.Fatalf("CreateLinkCode(%s): %v", source, err)
}
if _, _, err := repo.VerifyLinkCode(ctx, userID, code, now); err != nil {
t.Fatalf("VerifyLinkCode(%s): %v", source, err)
}
return mc
}
ownerID := "usr-" + suffix(t)
if err := repo.UpsertOwner(ctx, ownerID, "prot-owner-"+suffix(t), ""); err != nil {
t.Fatalf("UpsertOwner: %v", err)
}
admin := newUser(t, "admin", "prot-admin")
player := newUser(t, "user", "prot-player")
for _, tc := range []struct {
name string
mc string
want bool
}{
{"owner via thirdparty", link(ownerID, "thirdparty"), true},
{"admin via thirdparty", link(admin.ID, "thirdparty"), true},
{"player via thirdparty", link(player.ID, "thirdparty"), false},
{"admin via mojang", link(admin.ID, "mojang"), false},
} {
got, err := repo.IsProtectedAdminLink(ctx, tc.mc)
if err != nil || got != tc.want {
t.Fatalf("%s = (%v, %v), want %v", tc.name, got, err, tc.want)
}
}
}
// ---- op.console staff login state machine -------------------------------------- // ---- op.console staff login state machine --------------------------------------
func TestOpLoginStateMachine(t *testing.T) { func TestOpLoginStateMachine(t *testing.T) {
@@ -741,6 +845,15 @@ func assertEmailProven(t *testing.T, userID, wantEmail string, wantVerified bool
} }
} }
func userRole(t *testing.T, userID string) string {
t.Helper()
var role string
if err := db.QueryRow(`SELECT role::text FROM users WHERE id = $1`, userID).Scan(&role); err != nil {
t.Fatalf("read user role: %v", err)
}
return role
}
func assertLinkAuthSource(t *testing.T, userID, mcUUID, want string) { func assertLinkAuthSource(t *testing.T, userID, mcUUID, want string) {
t.Helper() t.Helper()
var got string var got string