Files

373 lines
18 KiB
Go

//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
}