diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 985eeca..c801aa2 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -80,8 +80,9 @@ type ownerStore interface { // InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is // 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 - // Operator. The row is role=admin, identical in shape to the Owner — Felis has no - // separate operator DB role (migration 0003: staff = role=admin). + // Operator. The row is role=admin — an Operator is staff BELOW the single + // role=owner identity (migration 0011 adds that role); the two are the only + // staff roles. InsertOperator(ctx context.Context, id, username, email string) error // CompleteOwnerSetup atomically consumes the in-game link code, creates or // 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 { 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 u.Username, true, nil } // 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`. func provisionOwner(ctx context.Context, s ownerStore, username, email string) error { username = strings.TrimSpace(username) @@ -290,13 +293,13 @@ func provisionOwner(ctx context.Context, s ownerStore, username, email string) e return nil } -// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the -// Owner it is role=admin and passwordless — Felis has no separate operator DB role, -// so an Operator is simply an additional staff admin (migration 0003). UNLIKE -// provisionOwner, which upserts the single Owner and resets it on a username -// conflict, this is insert-only: a username already taken returns api.ErrConflict -// rather than overwriting a live account, so adding an Operator can never silently -// clobber the Owner's or another Operator's account. +// provisionOperator mints a NEW Operator staff account direct-to-Postgres. It is +// role=admin and passwordless — an additional staff admin below the single +// role=owner identity (migrations 0003 + 0011). UNLIKE provisionOwner, which +// upserts the single Owner and resets it on a username conflict, this is +// insert-only: a username already taken returns api.ErrConflict rather than +// overwriting a live account, so adding an Operator can never silently clobber +// the Owner's or another Operator's account. func provisionOperator(ctx context.Context, s ownerStore, username, email string) error { username = strings.TrimSpace(username) if username == "" { @@ -387,7 +390,7 @@ func newSetupToken() (raw, hash string, err error) { // performSetupMCBind is the `felis setup` Owner-establishment path: the operator // 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 // login where the Owner verifies email / enrolls a passkey. adminHostname is the // operator-console host the URL points at (op.console.): the Owner is staff, diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index de55db0..5efda14 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -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) { f := &fakeOwnerStore{} _, ok, err := authenticateAdmin(ctx, f, "nobody") diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ca25011..bfe1917 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -630,15 +630,15 @@ func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool } // 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 -// an SSO Operator (role='admin', with no password) is protected like any other. +// auth_source 'thirdparty', and the linked user staff (admin OR owner) — no +// 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) { userID, ok := f.links[mcUUID] if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty { return false, nil } for _, u := range f.staff { - if u.ID == userID && u.Role == "admin" { + if u.ID == userID && staffRole(u.Role) { return true, nil } } diff --git a/internal/api/auth.go b/internal/api/auth.go index 4346f3b..62912bc 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -17,13 +17,13 @@ type Principal struct { UserID string // Email is the audited actor identity (spec §14: audit actor = Access email). Email string - // Role is "admin" or "user" (mirrors users.role). + // Role is "owner", "admin", or "user" (mirrors users.role). Role string // ViaAdminAccess is true only when the request arrived through an admin-graded // path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR // a local session presented on the op.console host (SessionAuth, the // 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. ViaAdminAccess bool // 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. -// 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. // An owner implicitly passes this check (the owner role is a superset of admin). func (p *Principal) IsAdmin() bool { diff --git a/internal/api/errors.go b/internal/api/errors.go index 20b81ce..99ad5cf 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -44,11 +44,11 @@ var ( // consumed, or expired) — so handlers map it to 400, not 404. ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired") // ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF - // account (role=admin), which the player-console bootstrap refuses (console-tier - // access model). Operators authenticate at op.console behind Zero Trust, never via - // the account-less console. door, so the public bootstrap provably - // never mints a session for an admin identity. It is distinct from ErrConflict so - // the handler answers 403 (wrong door) rather than 409 (already linked). + // account (admin or owner), which the player-console bootstrap refuses + // (console-tier access model). Staff authenticate at op.console behind Zero Trust, + // never via the account-less console. door, so the public bootstrap + // provably never mints a session for a staff identity. It is distinct from + // ErrConflict so the handler answers 403 (wrong door) rather than 409. ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account") // ErrEmailTaken means a verified email would collide with another account's // already-verified address (spec §B email-first login foundation; the diff --git a/internal/api/handlers_auth_email.go b/internal/api/handlers_auth_email.go index c1c59dc..ae6339a 100644 --- a/internal/api/handlers_auth_email.go +++ b/internal/api/handlers_auth_email.go @@ -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 // 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). - 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", "that account is staff; sign in at the operator console")) return diff --git a/internal/api/handlers_auth_email_test.go b/internal/api/handlers_auth_email_test.go index 8bf0930..9c5e4e4 100644 --- a/internal/api/handlers_auth_email_test.go +++ b/internal/api/handlers_auth_email_test.go @@ -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 // the 403 costs the valid code (verify-then-refuse), so it cannot be farmed as an // 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) { + 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.settings[LocalAuthEnabledKey] = []byte("true") repo.staff["owner"] = &StaffUser{ ID: "a1", Username: "owner", Email: "boss@example.net", - Role: "admin", EmailVerified: true, + Role: role, EmailVerified: true, } mailer := &captureMailer{} api := newTestAPI(repo, newFakeCluster()) diff --git a/internal/api/handlers_onboard.go b/internal/api/handlers_onboard.go index a9f6d3f..280e6a9 100644 --- a/internal/api/handlers_onboard.go +++ b/internal/api/handlers_onboard.go @@ -38,11 +38,11 @@ import ( // in-game identity is the root of trust, so re-minting a code always re-grants a // session even after email/passkey are bound. "登录并非强制,但没登录什么都干不了". // -// op.console stays behind Zero Trust. A code whose UUID belongs to STAFF (role=admin) -// is refused here (ErrPlayerBindForbidden → 403), so the public bootstrap provably -// never mints a session for an admin identity — the sole tier it yields is a role=user -// player session, host-only to console. (never sent to op.console) and -// carrying ViaAdminAccess=false. "op.console 必须得 Auth". +// op.console stays behind Zero Trust. A code whose UUID belongs to STAFF (admin or +// owner) is refused here (ErrPlayerBindForbidden → 403), so the public bootstrap +// provably never mints a session for a staff identity — the sole tier it yields is a +// role=user player session, host-only to console. (never sent to +// op.console) and carrying ViaAdminAccess=false. "op.console 必须得 Auth". // newUserID returns an opaque random user id (128 bits, hex), matching the shape of // the ids break-glass mints for staff rows. diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go index 08be3e4..dc872e0 100644 --- a/internal/api/handlers_op_login.go +++ b/internal/api/handlers_op_login.go @@ -28,7 +28,7 @@ import ( // 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 // 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). // // 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) 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.) 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() return } @@ -290,15 +291,15 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - // Load the staff account for the session + response. Re-assert admin as defence in - // depth: only admins ever get a request minted, but the session must never be issued - // to a non-admin identity even if the row were somehow otherwise. + // Load the staff account for the session + response. Re-assert staff as defence in + // depth: only staff ever get a request minted, but the session must never be issued + // to a non-staff identity even if the row were somehow otherwise. u, err := a.Repo.UserByID(r.Context(), loginReq.UserID) if err != nil { writeError(w, r, err) return } - if u.Role != "admin" { + if !staffRole(u.Role) { writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator")) 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 // 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. type opLoginApproveRequest struct { 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 // (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 -// no-longer-pending request is 404. Self-approval is allowed: a staff member online as -// their own admin identity supplies a genuine second factor (in-game session control) -// distinct from the mailbox factor. +// linked staff account (admin or owner; else 403), then flips the request approved. +// A missing or no-longer-pending request is 404. Self-approval is allowed: a staff +// member online as their own admin identity supplies a genuine second factor +// (in-game session control) distinct from the mailbox factor. func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req opLoginApproveRequest diff --git a/internal/api/handlers_op_login_test.go b/internal/api/handlers_op_login_test.go index d009ca9..cde1a36 100644 --- a/internal/api/handlers_op_login_test.go +++ b/internal/api/handlers_op_login_test.go @@ -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: "owner@example.net", + 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, "owner@example.net") + 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 // 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 diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 6fc7a85..c01cf45 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -140,6 +140,18 @@ func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) { 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 err := validateUsername(*body.Username); err != nil { writeError(w, r, err) @@ -186,6 +198,14 @@ func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) { 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 errors.Is(err, ErrNotFound) { 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 } + // 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 errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found")) diff --git a/internal/api/handlers_users_test.go b/internal/api/handlers_users_test.go new file mode 100644 index 0000000..9fc2745 --- /dev/null +++ b/internal/api/handlers_users_test.go @@ -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":"root2@example.net"}`, 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()) + } + }) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 763d849..080927f 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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 // 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 { return func(w http.ResponseWriter, r *http.Request) { if p := principalFromContext(r.Context()); !p.IsAdmin() { diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 684ca55..be0f9d4 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -159,9 +159,10 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin // together), where both transactions reach the inserts before either commits. // 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 - // refused (op.console only) BEFORE any consume, so the code survives; an unlinked - // UUID births a fresh role='user' player with a uuid-derived unique username. + // is fetched (idempotent "log in via the game"); any STAFF account (admin or + // owner, i.e. role != 'user') is refused (op.console only) BEFORE any consume, so + // the code survives; an unlinked UUID births a fresh role='user' player with a + // uuid-derived unique username. userID := newUserID var existingRole string 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 -// 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` // 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 @@ -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 - // staff row (role='admin') with a uuid-derived username; an already-linked - // account is promoted to role='admin' in place (idempotent when it is already - // staff), keeping its id and username. Setup elevates on purpose, so there is no - // staff refusal here — that guard belongs to the player path only. + // staff row (role='owner') with a uuid-derived username; an already-linked + // account is promoted to role='owner' in place (idempotent when it already is), + // keeping its id and username. Setup elevates on purpose, so there is no staff + // refusal here — that guard belongs to the player path only. userID := newUserID switch err := tx.QueryRowContext(ctx, `SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); { case errors.Is(err, sql.ErrNoRows): 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 { return "", "", "", fmt.Errorf("create owner: %w", err) } @@ -263,7 +264,7 @@ func (p *PGRepo) CompleteOwnerSetup(ctx context.Context, newUserID, code string, return "", "", "", err default: 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) } } @@ -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 // 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 -// linked user is an admin. It intentionally does not test HOW the account signs in: -// an Operator may authenticate via SSO (Cloudflare Access, §14) or any local -// passwordless door and must be protected just the same — the sign-in method is -// orthogonal to "is staff" and "logs in via the Login Server". Keyed by UUID, the -// only identity velocity holds. +// linked user is staff (admin OR owner — the Owner is the one identity that must never +// be displaced). It intentionally does not test HOW the account signs in: staff may +// authenticate via SSO (Cloudflare Access, §14) or any local passwordless door and +// must be protected just the same — the sign-in method is orthogonal to "is staff" +// 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) { var ok bool err := p.db.QueryRowContext(ctx, `SELECT EXISTS( 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) return ok, err } @@ -892,13 +893,13 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse 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 // 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. 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 switch err := p.db.QueryRowContext(ctx, q).Scan(&one); { 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 -// break-glass first-run / recovery path). role is forced to 'admin' — the -// platform-level identity. On a username conflict the email is overwritten -// while the existing id is preserved, so live sessions referencing it survive -// a reset. The account is passwordless by design. The empty email is stored -// as NULL (users.email is nullable). +// break-glass first-run / recovery path). role is forced to 'owner' — the +// platform-level identity above admin (migration 0011); every owner-tier route +// and the panel's owner surfaces gate on exactly this role, so writing a plain +// 'admin' here would silently strand them. On a username conflict the email is +// 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 { _, err := p.db.ExecContext(ctx, - `INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'admin') - ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email`, + `INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'owner') + ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, role = 'owner'`, id, username, email) return err } diff --git a/internal/api/repo.go b/internal/api/repo.go index bd45c08..fc02765 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -77,10 +77,10 @@ type BackupRecord struct { } // 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) -// and recovered by `felis breakGlass` (email OTP); players are role=user rows. -// There is no password column — staff authenticate via email-OTP / passkey + -// in-game approve, never a password. +// auth). The Owner is the role=owner row, minted by `felis setup` (MC link) and +// recovered by `felis breakGlass` (email OTP); Operators are role=admin; +// players are role=user. There is no password column — staff authenticate via +// email-OTP / passkey + in-game approve, never a password. type StaffUser struct { ID string Username string @@ -207,9 +207,10 @@ type Repo interface { // return newUserID; // - the uuid is already linked to a role='user' player → return THAT user // (idempotent "log in via the game"), consuming the code; - // - the uuid is linked to a role='admin' STAFF account → ErrPlayerBindForbidden - // WITHOUT consuming the code (operators use op.console behind Zero Trust; the - // public bootstrap never mints a session for an admin identity). + // - the uuid is linked to a STAFF account (role != 'user', i.e. admin or owner) + // → ErrPlayerBindForbidden WITHOUT consuming the code (staff use op.console + // 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 // 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 // Mojang-priority reclaim must never bar them. The predicate is exactly three // 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 // 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 // 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 // displaces them) nor to Mojang-authenticated identities (who have no Login-Server // 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) // UpsertOwner creates or resets the single Owner account direct-to-Postgres // (the `felis setup` / `felis breakGlass` recovery path). role is forced to - // 'admin'; on a username conflict the existing row's email is overwritten so - // a reset is idempotent. The account is passwordless by design. + // 'owner' (migration 0011 — the tier every user-admin route gates on); on a + // 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 // 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, diff --git a/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index da31437..6c2b496 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -357,6 +357,110 @@ func TestUserAdminEmailEditClearsVerification(t *testing.T) { 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, "reset@example.net"); 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 != "reset@example.net" || 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 -------------------------------------- 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) { t.Helper() var got string