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:
16 files changed
+349
-80
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user