fix(auth): hand off privileges before state creation; fail closed on store errors

Fresh boots opened the owner/token/session stores before EnsureDataRoot
dropped to the host user, leaving a root-owned maestro/ state dir whose
EACCES surfaced as a bogus ERR_INVALID_CREDENTIALS on /api/auth/setup.
main() now runs the handoff first; EnsureDataRoot heals pre-existing
root-owned state dirs while still root (healOwnerState).

Storage failures from the owner store now answer 500 ERR_INTERNAL
everywhere (status/setup/login/change-password/RequireSession/
GateWebSocket) instead of blaming credentials or re-advertising setup
mode; SetupRequired returns the store error instead of swallowing it,
and FileOwnerStore.Verify propagates read errors rather than masking
them as unknown-user.
This commit is contained in:
2026-08-23 12:10:15 +01:00
parent 707fe619f7
commit c014b2abfb
7 changed files with 228 additions and 23 deletions
+30
View File
@@ -202,6 +202,9 @@ func EnsureDataRoot() error {
if err != nil {
return err
}
if err := healOwnerState(root, identity); err != nil {
return fmt.Errorf("failed to hand owner state dir to uid %d: %w", identity.uid, err)
}
if err := os.Chown(root, identity.uid, identity.gid); err != nil {
return fmt.Errorf("failed to chown data root %s to %d:%d: %w", root, identity.uid, identity.gid, err)
}
@@ -212,6 +215,33 @@ func EnsureDataRoot() error {
return nil
}
// healOwnerState gives the instance-owner gate's state directory to the
// runtime user. Boots before the privilege drop opened the gate's stores as
// root first, leaving a root-owned <root>/maestro behind; after the handoff
// every write then failed with EACCES (surfacing as a bogus
// ERR_INVALID_CREDENTIALS on /api/auth/setup). Repairs such installs here,
// while the process can still chown. Missing dirs are fine — nothing to heal.
func healOwnerState(root string, id runtimeIdentity) error {
dir := filepath.Join(root, "maestro")
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read owner state dir: %w", err)
}
if err := os.Chown(dir, id.uid, id.gid); err != nil {
return fmt.Errorf("chown %s: %w", dir, err)
}
for _, e := range entries {
p := filepath.Join(dir, e.Name())
if err := os.Chown(p, id.uid, id.gid); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("chown %s: %w", p, err)
}
}
return nil
}
// runtimeIdentity is the user the container should run as, plus any
// supplementary groups needed to keep working (e.g. the docker socket group).
type runtimeIdentity struct {
+11 -5
View File
@@ -27,6 +27,7 @@ import (
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -249,13 +250,18 @@ func (s *FileOwnerStore) Create(_ context.Context, username, password string, is
func (s *FileOwnerStore) Verify(_ context.Context, username, password string) (*Owner, error) {
o, err := s.Get(context.Background(), username)
if err != nil || o == nil {
if err != nil {
// Unknown user is a credentials miss; keep storage failures
// distinguishable so callers can answer 500 instead of 401.
if errors.Is(err, os.ErrNotExist) {
return nil, os.ErrNotExist
}
return nil, err
}
if !VerifyPassword(o.PasswordHash, password) {
return nil, os.ErrNotExist
}
if VerifyPassword(o.PasswordHash, password) {
return o, nil
}
return nil, os.ErrNotExist
return o, nil
}
func (s *FileOwnerStore) Get(_ context.Context, username string) (*Owner, error) {
+26
View File
@@ -166,3 +166,29 @@ func TestOwnerFilePermissions(t *testing.T) {
t.Fatalf("owners.json mode = %o, want 600", perm)
}
}
// TestVerifyDistinguishesStorageFailureFromUnknownUser pins the contract the
// auth handlers rely on: an unknown user or wrong password surfaces as
// os.ErrNotExist (→ 401), while a broken store keeps its own error (→ 500).
func TestVerifyDistinguishesStorageFailureFromUnknownUser(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
s, err := NewFileOwnerStore(dir)
if err != nil {
t.Fatal(err)
}
if _, err := s.Create(ctx, "admin", "verify-check-pass", true); err != nil {
t.Fatal(err)
}
if _, err := s.Verify(ctx, "ghost", "verify-check-pass"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unknown user: %v", err)
}
broken := []byte("{not json")
if err := os.WriteFile(filepath.Join(dir, "owners.json"), broken, 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.Verify(ctx, "admin", "verify-check-pass"); err == nil || errors.Is(err, os.ErrNotExist) {
t.Fatalf("storage failure collapsed into ErrNotExist: %v", err)
}
}