Unverified Commit e0d23780 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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.
parent d1ec40f7
Loading
Loading
Loading
Loading
+15 −12
Changes for cmd/felis/breakglass.go: 15 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -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,
+10 −0
Changes for cmd/felis/breakglass_test.go: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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")
+3 −3
Changes for internal/api/api_test.go: 3 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -630,15 +630,15 @@ func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool
}

// IsProtectedAdminLink mirrors PGRepo's JOIN of account_links to users: linked,
// auth_source 'thirdparty', and the linked user an admin — no password-hash test, so
// an SSO Operator (role='admin', with no password) is protected like any other.
// auth_source 'thirdparty', and the linked user staff (admin OR owner) — no
// password-hash test, so an SSO Operator or the Owner is protected like any other.
func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) {
	userID, ok := f.links[mcUUID]
	if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty {
		return false, nil
	}
	for _, u := range f.staff {
		if u.ID == userID && u.Role == "admin" {
		if u.ID == userID && staffRole(u.Role) {
			return true, nil
		}
	}
+3 −3
Changes for internal/api/auth.go: 3 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -17,13 +17,13 @@ type Principal struct {
	UserID string
	// Email is the audited actor identity (spec §14: audit actor = Access email).
	Email string
	// Role is "admin" or "user" (mirrors users.role).
	// Role is "owner", "admin", or "user" (mirrors users.role).
	Role string
	// ViaAdminAccess is true only when the request arrived through an admin-graded
	// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
	// a local session presented on the op.console host (SessionAuth, the
	// passwordless face). Admin-tier operations require it in addition to
	// Role=="admin" (spec §14: ZT is graded by operation). A role=admin session
	// a staff role (spec §14: ZT is graded by operation). A staff session
	// arriving on the player console (console.*) never sets it.
	ViaAdminAccess bool
	// EmailVerified mirrors users.email_verified. The lockdown middleware gates
@@ -49,7 +49,7 @@ func staffRole(role string) bool {
}

// IsAdmin reports whether the principal may perform admin-tier operations.
// Both the role claim and the admin Access path are required: a role=admin
// Both the role claim and the admin Access path are required: a staff
// session arriving on panel.* must not bypass the Zero-Trust boundary.
// An owner implicitly passes this check (the owner role is a superset of admin).
func (p *Principal) IsAdmin() bool {
+5 −5
Changes for internal/api/errors.go: 5 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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
Loading