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