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
|
||||
// insert-only: a username already taken is a conflict (api.ErrConflict), never a
|
||||
// silent reset, so adding an Operator can never clobber the Owner or an existing
|
||||
// Operator. The row is role=admin, identical in shape to the Owner — Felis has no
|
||||
// separate operator DB role (migration 0003: staff = role=admin).
|
||||
// Operator. The row is role=admin — an Operator is staff BELOW the single
|
||||
// role=owner identity (migration 0011 adds that role); the two are the only
|
||||
// staff roles.
|
||||
InsertOperator(ctx context.Context, id, username, email string) error
|
||||
// CompleteOwnerSetup atomically consumes the in-game link code, creates or
|
||||
// promotes the bound Owner, enables local auth, and stores the one-time setup
|
||||
@@ -266,14 +267,16 @@ func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matc
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if u.Role != "admin" {
|
||||
// Staff means admin OR owner: recovery attribution must accept the Owner (the
|
||||
// primary break-glass identity), not just plain admins.
|
||||
if u.Role != "admin" && u.Role != "owner" {
|
||||
return "", false, nil
|
||||
}
|
||||
return u.Username, true, nil
|
||||
}
|
||||
|
||||
// provisionOwner mints or resets the single Owner account direct-to-Postgres,
|
||||
// passwordless. The account is role=admin with no password — the Owner completes
|
||||
// passwordless. The account is role=owner with no password — the Owner completes
|
||||
// passwordless login setup via the web setup-token flow after `felis setup`.
|
||||
func provisionOwner(ctx context.Context, s ownerStore, username, email string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
@@ -290,13 +293,13 @@ func provisionOwner(ctx context.Context, s ownerStore, username, email string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the
|
||||
// Owner it is role=admin and passwordless — Felis has no separate operator DB role,
|
||||
// so an Operator is simply an additional staff admin (migration 0003). UNLIKE
|
||||
// provisionOwner, which upserts the single Owner and resets it on a username
|
||||
// conflict, this is insert-only: a username already taken returns api.ErrConflict
|
||||
// rather than overwriting a live account, so adding an Operator can never silently
|
||||
// clobber the Owner's or another Operator's account.
|
||||
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. It is
|
||||
// role=admin and passwordless — an additional staff admin below the single
|
||||
// role=owner identity (migrations 0003 + 0011). UNLIKE provisionOwner, which
|
||||
// upserts the single Owner and resets it on a username conflict, this is
|
||||
// insert-only: a username already taken returns api.ErrConflict rather than
|
||||
// overwriting a live account, so adding an Operator can never silently clobber
|
||||
// the Owner's or another Operator's account.
|
||||
func provisionOperator(ctx context.Context, s ownerStore, username, email string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
@@ -387,7 +390,7 @@ func newSetupToken() (raw, hash string, err error) {
|
||||
|
||||
// performSetupMCBind is the `felis setup` Owner-establishment path: the operator
|
||||
// binds their Minecraft account via a one-time link code the login gate handed
|
||||
// them in-game, the bound user is promoted to role='admin' (passwordless Owner),
|
||||
// them in-game, the bound user is promoted to role='owner' (passwordless Owner),
|
||||
// local auth is enabled, and a one-time setup URL is minted for the first web
|
||||
// login where the Owner verifies email / enrolls a passkey. adminHostname is the
|
||||
// operator-console host the URL points at (op.console.<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) {
|
||||
f := &fakeOwnerStore{}
|
||||
_, ok, err := authenticateAdmin(ctx, f, "nobody")
|
||||
|
||||
Reference in new issue
Block a user