//go:build linux package authconfig import ( "bytes" "context" "encoding/json" "errors" "io" "os" "path/filepath" "strings" "testing" "github.com/aritmolab/thothii/tools/tht/internal/authprojection" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/safeio" ) func TestProjectedAuthMutatorsBlockBeforeCanonicalWriteAndPublishOnlyEqualSnapshots(t *testing.T) { installation, spec := projectedAuthInstallation(t) adminPassword := writePasswordFile(t, "initial projected administrator password\n") userPassword := writePasswordFile(t, "projected ordinary user password\n") previous := runProjectedAuthMutation blockedObservations := 0 runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { return previous(ctx, canonicalRoot, projection, func() error { status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: projection.RuntimeRoot, UID: projection.UID, GID: projection.GID}) if !errors.Is(err, authprojection.ErrBlocked) || status.Selector.State != "blocked" { t.Fatalf("projection before canonical mutation = %#v, %v; want blocked", status, err) } blockedObservations++ return mutate() }) } t.Cleanup(func() { runProjectedAuthMutation = previous }) for _, args := range [][]string{ {"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}, {"user", "add", "operator", "--role", "user", "--password-file", userPassword}, {"user", "set-password", "operator", "--password-file", adminPassword}, {"user", "disable", "operator"}, {"user", "enable", "operator"}, {"user", "grant", "operator", "--role", "admin"}, {"user", "revoke", "operator", "--role", "admin"}, {"user", "logout-all", "operator", "--yes"}, } { var stdout, stderr bytes.Buffer if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code != 0 { t.Fatalf("%v = %d, stdout=%q stderr=%q", args, code, stdout.String(), stderr.String()) } assertProjectedCanonicalReadyAndEqual(t, installation.AuthenticationDirectory(), spec) if strings.Contains(stdout.String()+stderr.String(), "projected administrator password") || strings.Contains(stdout.String()+stderr.String(), "$argon2id$") { t.Fatalf("%v leaked secret material", args) } } if blockedObservations != 8 { t.Fatalf("blocked observations = %d, want 8", blockedObservations) } } func TestProjectedAuthMutationLeavesBlockedAfterChangedCanonicalFailure(t *testing.T) { installation, spec := projectedAuthInstallation(t) adminPassword := writePasswordFile(t, "initial projected administrator password\n") if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { t.Fatalf("configure = %d", code) } previous := runProjectedAuthMutation runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { return previous(ctx, canonicalRoot, projection, func() error { if err := mutate(); err != nil { return err } return errors.New("synthetic-password-sentinel") }) } t.Cleanup(func() { runProjectedAuthMutation = previous }) var stdout, stderr bytes.Buffer code := Run(context.Background(), installation, []string{"user", "logout-all", "admin", "--yes"}, strings.NewReader(""), &stdout, &stderr) if code == 0 || strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") { t.Fatalf("changed mutation failure was accepted or leaked: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } _, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}) if !errors.Is(err, authprojection.ErrBlocked) { t.Fatalf("Inspect() error = %v, want blocked runtime projection", err) } } func TestProjectedAuthMutatorFailuresRestoreOnlyUnchangedCanonicalState(t *testing.T) { mutators := []string{"configure", "user add", "user set-password", "user enable", "user disable", "user grant", "user revoke", "user logout-all"} for _, mutator := range mutators { t.Run(mutator+" restores ready before callback", func(t *testing.T) { installation, spec, args := preparedProjectedMutation(t, mutator) previous := runProjectedAuthMutation runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { return previous(ctx, canonicalRoot, projection, func() error { return errors.New("synthetic-password-sentinel") }) } t.Cleanup(func() { runProjectedAuthMutation = previous }) var stdout, stderr bytes.Buffer if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code == 0 { t.Fatalf("%s accepted injected pre-mutation failure", mutator) } if strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") { t.Fatalf("%s leaked injected failure: stdout=%q stderr=%q", mutator, stdout.String(), stderr.String()) } assertProjectedCanonicalReadyAndEqual(t, installation.AuthenticationDirectory(), spec) }) t.Run(mutator+" leaves blocked after changed canonical error", func(t *testing.T) { var installation config.Installation var spec ProjectionSpec var args []string if mutator == "configure" { installation, spec = projectedAuthInstallation(t) passwordFile := writePasswordFile(t, "fresh projected bootstrap password\n") args = []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile} } else { installation, spec, args = preparedProjectedMutation(t, mutator) } previous := runProjectedAuthMutation runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { return previous(ctx, canonicalRoot, projection, func() error { if err := mutate(); err != nil { return err } return errors.New("synthetic-password-sentinel") }) } t.Cleanup(func() { runProjectedAuthMutation = previous }) var stdout, stderr bytes.Buffer if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code == 0 { t.Fatalf("%s accepted changed-canonical injected failure", mutator) } if strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") { t.Fatalf("%s leaked injected failure: stdout=%q stderr=%q", mutator, stdout.String(), stderr.String()) } _, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}) if !errors.Is(err, authprojection.ErrBlocked) { t.Fatalf("%s Inspect() error = %v, want blocked projection", mutator, err) } }) } } func TestProjectedAuthPublishStatusAndCheckFailClosed(t *testing.T) { installation, spec := projectedAuthInstallation(t) passwordFile := writePasswordFile(t, "projected authentication password\n") if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { t.Fatalf("configure = %d", code) } var stdout, stderr bytes.Buffer if code := Run(context.Background(), installation, []string{"publish"}, strings.NewReader(""), &stdout, &stderr); code != 0 { t.Fatalf("publish = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } if code := Run(context.Background(), installation, []string{"publish", "secret"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 { t.Fatal("publish accepted content arguments") } stdout.Reset() if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 { t.Fatalf("status = %d, stderr=%q", code, stderr.String()) } var raw map[string]json.RawMessage if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil { t.Fatalf("status JSON = %q, %v", stdout.String(), err) } if len(raw) != 4 { t.Fatalf("status keys = %#v, want exactly projection public keys", raw) } for _, key := range []string{"state", "generation", "canonicalRevision", "equal"} { if _, ok := raw[key]; !ok { t.Fatalf("status keys = %#v, missing %q", raw, key) } } var status struct { State string `json:"state"` Generation string `json:"generation"` CanonicalRevision string `json:"canonicalRevision"` Equal bool `json:"equal"` } if err := json.Unmarshal(stdout.Bytes(), &status); err != nil || status.State != "ready" || !status.Equal || status.Generation == "" || status.CanonicalRevision == "" { t.Fatalf("status = %q, %#v, %v", stdout.String(), status, err) } if strings.Contains(stdout.String(), "password") || strings.Contains(stdout.String(), "$argon2id$") { t.Fatalf("status exposed secret material: %q", stdout.String()) } transaction, err := BeginExternalProjectionTransaction(context.Background(), installation.AuthenticationDirectory(), spec) if err != nil { t.Fatal(err) } defer transaction.Close() canonical, err := loadSnapshotBytes(installation.AuthenticationDirectory()) if err != nil { t.Fatal(err) } stdout.Reset() stderr.Reset() if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 { t.Fatalf("blocked status JSON = %d, stderr=%q", code, stderr.String()) } raw = nil if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil { t.Fatalf("blocked status JSON = %q, %v", stdout.String(), err) } if len(raw) != 4 { t.Fatalf("blocked status keys = %#v, want exactly projection public keys", raw) } for _, key := range []string{"state", "generation", "canonicalRevision", "equal"} { if _, ok := raw[key]; !ok { t.Fatalf("blocked status keys = %#v, missing %q", raw, key) } } status = struct { State string `json:"state"` Generation string `json:"generation"` CanonicalRevision string `json:"canonicalRevision"` Equal bool `json:"equal"` }{} if err := json.Unmarshal(stdout.Bytes(), &status); err != nil || status.State != "blocked" || status.Generation != "" || status.CanonicalRevision != canonical.CanonicalRevision || status.Equal { t.Fatalf("blocked status = %q, %#v, %v", stdout.String(), status, err) } stdout.Reset() stderr.Reset() if code := Run(context.Background(), installation, []string{"status"}, strings.NewReader(""), &stdout, &stderr); code != 0 { t.Fatalf("blocked status text = %d, stderr=%q", code, stderr.String()) } wantText := "State: blocked\nGeneration: \nCanonical revision: " + canonical.CanonicalRevision + "\nEqual: false\n" if stdout.String() != wantText { t.Fatalf("blocked status text = %q, want %q", stdout.String(), wantText) } calls := 0 runner := runnerFunc(func(_ context.Context, _ []string, _ io.Reader) (compose.Result, error) { calls++ return compose.Result{}, errors.New("backend diagnostic must not run") }) stdout.Reset() stderr.Reset() if code := RunWithRunner(context.Background(), installation, []string{"check", "--json"}, strings.NewReader(""), &stdout, &stderr, runner); code == 0 || calls != 0 { t.Fatalf("check admitted blocked projection: code=%d calls=%d stdout=%q stderr=%q", code, calls, stdout.String(), stderr.String()) } } func TestRequireRuntimeAuthProjectionReadyRejectsMissingBlockedAndDivergentStates(t *testing.T) { for _, state := range []string{"missing", "blocked", "divergent"} { t.Run(state, func(t *testing.T) { installation, spec := projectedAuthInstallation(t) passwordFile := writePasswordFile(t, "projected readiness password\n") if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { t.Fatalf("configure = %d", code) } if err := RequireRuntimeAuthProjectionReady(installation); err != nil { t.Fatalf("ready projection rejected: %v", err) } switch state { case "missing": if err := os.RemoveAll(spec.RuntimeRoot); err != nil { t.Fatal(err) } case "blocked": transaction, err := BeginExternalProjectionTransaction(context.Background(), installation.AuthenticationDirectory(), spec) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = transaction.Close() }) case "divergent": if err := MutateUsers(installation.AuthenticationDirectory(), func(registry *Registry) error { registry.Users[0].AuthRevision++ return nil }); err != nil { t.Fatal(err) } } if err := RequireRuntimeAuthProjectionReady(installation); err == nil { t.Fatalf("RequireRuntimeAuthProjectionReady accepted %s projection", state) } }) } } func TestProjectedAuthCommandsRefuseBeforeMutationWhenNotRoot(t *testing.T) { installation, _ := projectedAuthInstallation(t) passwordFile := writePasswordFile(t, "projected authentication password\n") previous := authProjectionEffectiveUID authProjectionEffectiveUID = func() int { return 1000 } t.Cleanup(func() { authProjectionEffectiveUID = previous }) if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 { t.Fatal("non-root projected configure succeeded") } if _, err := os.Lstat(filepath.Join(installation.AuthenticationDirectory(), authFileName)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("canonical auth was written after non-root refusal: %v", err) } } func projectedAuthInstallation(t *testing.T) (config.Installation, ProjectionSpec) { t.Helper() root := t.TempDir() canonicalRoot, runtimeRoot := filepath.Join(root, "canonical-auth"), filepath.Join(root, "runtime-auth") for _, directory := range []string{canonicalRoot, runtimeRoot} { if err := safeio.EnsurePrivateDirectory(directory); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(root, "operator.env"), []byte("SAFE_VALUE=1\n"), 0o600); err != nil { t.Fatal(err) } uid, gid := uint32(os.Geteuid()), uint32(os.Getegid()) installation := config.Installation{Profile: "server", EnvFile: filepath.Join(root, "operator.env"), Authentication: config.Authentication{ ConfigDirectory: canonicalRoot, RuntimeProjection: &config.RuntimeProjection{Directory: runtimeRoot, UID: uid, GID: gid}, }} return installation, ProjectionSpec{RuntimeRoot: runtimeRoot, UID: uid, GID: gid} } func assertProjectedCanonicalReadyAndEqual(t *testing.T, canonicalRoot string, spec ProjectionSpec) { t.Helper() status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}) if err != nil || status.Selector.State != "ready" { t.Fatalf("Inspect() = %#v, %v; want ready", status, err) } snapshot, err := loadSnapshotBytes(canonicalRoot) if err != nil || status.Snapshot.Generation != snapshot.Generation || status.Snapshot.CanonicalRevision != snapshot.CanonicalRevision { t.Fatalf("projection = %#v, canonical = %#v, %v; want equality", status.Snapshot, snapshot, err) } } func preparedProjectedMutation(t *testing.T, mutator string) (config.Installation, ProjectionSpec, []string) { t.Helper() installation, spec := projectedAuthInstallation(t) adminPassword := writePasswordFile(t, "prepared projected administrator password\n") userPassword := writePasswordFile(t, "prepared projected user password\n") configure := []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword} if code := Run(context.Background(), installation, configure, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { t.Fatalf("prepare %s configure = %d", mutator, code) } if mutator == "configure" { return installation, spec, configure } if code := Run(context.Background(), installation, []string{"user", "add", "operator", "--role", "user", "--password-file", userPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { t.Fatalf("prepare %s add = %d", mutator, code) } if mutator == "user enable" { if code := Run(context.Background(), installation, []string{"user", "disable", "operator"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { t.Fatalf("prepare %s disable = %d", mutator, code) } } if mutator == "user revoke" { if code := Run(context.Background(), installation, []string{"user", "grant", "operator", "--role", "admin"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { t.Fatalf("prepare %s grant = %d", mutator, code) } } args := map[string][]string{ "user add": {"user", "add", "second", "--role", "user", "--password-file", userPassword}, "user set-password": {"user", "set-password", "operator", "--password-file", adminPassword}, "user enable": {"user", "enable", "operator"}, "user disable": {"user", "disable", "operator"}, "user grant": {"user", "grant", "operator", "--role", "admin"}, "user revoke": {"user", "revoke", "operator", "--role", "admin"}, "user logout-all": {"user", "logout-all", "operator", "--yes"}, }[mutator] if args == nil { t.Fatalf("unknown projected mutator %q", mutator) } return installation, spec, args }