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

+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,
// 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
}
}
+3 -3
View File
@@ -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 {
+5 -5
View File
@@ -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.<root_domain> 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.<root_domain> 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
+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
// 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
+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
// 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: "[email protected]",
Role: "admin", EmailVerified: true,
Role: role, EmailVerified: true,
}
mailer := &captureMailer{}
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
// 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.<root_domain> (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.<root_domain> (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.
+13 -12
View File
@@ -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.<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()
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
+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
// 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
+31
View File
@@ -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"))
+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
// 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() {
+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.
// 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
}
+15 -11
View File
@@ -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,