feat(auth): coordinate canonical projection publication
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
package authconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
)
|
||||
|
||||
func TestRunProjectedMutationHoldsOuterLockAcrossCanonicalAndProjection(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
spec := testProjectionSpec(t)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
contended := make(chan struct{}, 8)
|
||||
restoreHooks := setProjectionCoordinatorHooksForTest(projectionCoordinatorHooks{
|
||||
onOuterLockContention: func() {
|
||||
select {
|
||||
case contended <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(restoreHooks)
|
||||
first := make(chan error, 1)
|
||||
go func() {
|
||||
first <- RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error {
|
||||
close(entered)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
<-entered
|
||||
if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) {
|
||||
t.Fatalf("Inspect() error = %v, want blocked while mutation is inside the coordinator", err)
|
||||
}
|
||||
second := make(chan error, 1)
|
||||
go func() {
|
||||
second <- RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return nil })
|
||||
}()
|
||||
external := make(chan error, 1)
|
||||
go func() {
|
||||
transaction, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec)
|
||||
if err == nil {
|
||||
err = transaction.Close()
|
||||
}
|
||||
external <- err
|
||||
}()
|
||||
<-contended
|
||||
<-contended
|
||||
close(release)
|
||||
if err := <-first; err != nil {
|
||||
t.Fatalf("first RunProjectedMutation() error = %v", err)
|
||||
}
|
||||
if err := <-second; err != nil {
|
||||
t.Fatalf("second RunProjectedMutation() error = %v", err)
|
||||
}
|
||||
if err := <-external; err != nil {
|
||||
t.Fatalf("BeginExternalProjectionTransaction() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProjectedMutationPublishesExactLocalAndOIDCSnapshots(t *testing.T) {
|
||||
for _, fixture := range []struct{ name, auth, users string }{
|
||||
{"local", defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))},
|
||||
{"oidc", "version: 1\nmode: oidc\npublicUrl: https://example.invalid\n", ""},
|
||||
} {
|
||||
t.Run(fixture.name, func(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, fixture.auth, fixture.users)
|
||||
spec := testProjectionSpec(t)
|
||||
if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return nil }); err != nil {
|
||||
t.Fatalf("RunProjectedMutation() error = %v", err)
|
||||
}
|
||||
status, err := authprojection.Inspect(toRuntimeSpec(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() error = %v", err)
|
||||
}
|
||||
if status.Snapshot.Mode != fixture.name || !bytes.Equal(status.Snapshot.Auth, []byte(fixture.auth)) {
|
||||
t.Fatal("published snapshot does not match canonical auth.yaml")
|
||||
}
|
||||
if fixture.name == "local" && !bytes.Equal(status.Snapshot.Users, []byte(fixture.users)) {
|
||||
t.Fatal("published local snapshot does not match canonical users.yaml")
|
||||
}
|
||||
if fixture.name == "oidc" && status.Snapshot.Users != nil {
|
||||
t.Fatal("published OIDC snapshot unexpectedly includes users.yaml")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProjectedMutationRestoresPriorReadyWhenMutationFailsWithoutChangingCanonical(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
spec := testProjectionSpec(t)
|
||||
before := publishCanonical(t, canonicalRoot, spec)
|
||||
if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return errors.New("mutation failed") }); err == nil {
|
||||
t.Fatal("RunProjectedMutation() succeeded after a failed mutation")
|
||||
}
|
||||
after := inspectCanonicalProjection(t, spec)
|
||||
if after.Generation != before.Generation {
|
||||
t.Fatalf("generation = %s, want restored %s", after.Generation, before.Generation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProjectedMutationLeavesBlockedWhenMutationChangesCanonicalThenFails(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
spec := testProjectionSpec(t)
|
||||
publishCanonical(t, canonicalRoot, spec)
|
||||
if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error {
|
||||
return writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), errors.New("mutation failed"))
|
||||
}); err == nil {
|
||||
t.Fatal("RunProjectedMutation() succeeded after changing canonical authentication then failing")
|
||||
}
|
||||
if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) {
|
||||
t.Fatalf("Inspect() error = %v, want blocked after divergent mutation failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProjectedMutationLeavesBlockedWhenPublicationOrVerificationFails(t *testing.T) {
|
||||
for _, fixture := range []struct {
|
||||
name string
|
||||
mutate func(string) error
|
||||
hooks projectionCoordinatorHooks
|
||||
}{
|
||||
{"invalid canonical after mutation", func(directory string) error {
|
||||
return safeio.ReplaceCanonicalRegular(filepath.Join(directory, authFileName), []byte("not: [valid\n"), 0o600)
|
||||
}, projectionCoordinatorHooks{}},
|
||||
{"post-commit equality verification", func(string) error { return nil }, projectionCoordinatorHooks{
|
||||
verifyCommittedProjection: func(authprojection.Status, authprojection.Snapshot) error {
|
||||
return errors.New("synthetic verification failure")
|
||||
},
|
||||
}},
|
||||
} {
|
||||
t.Run(fixture.name, func(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
spec := testProjectionSpec(t)
|
||||
publishCanonical(t, canonicalRoot, spec)
|
||||
restoreHooks := setProjectionCoordinatorHooksForTest(fixture.hooks)
|
||||
t.Cleanup(restoreHooks)
|
||||
if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return fixture.mutate(canonicalRoot) }); err == nil {
|
||||
t.Fatal("RunProjectedMutation() unexpectedly succeeded")
|
||||
}
|
||||
if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) {
|
||||
t.Fatalf("Inspect() error = %v, want blocked after failed publication", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishProjectedCanonicalRepairsBlockedStateFromCanonicalOnly(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
spec := testProjectionSpec(t)
|
||||
publishCanonical(t, canonicalRoot, spec)
|
||||
if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error {
|
||||
return writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), errors.New("fail after changing canonical bytes"))
|
||||
}); err == nil {
|
||||
t.Fatal("RunProjectedMutation() succeeded after a divergent failed mutation")
|
||||
}
|
||||
if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) {
|
||||
t.Fatalf("Inspect() error = %v, want blocked before canonical repair", err)
|
||||
}
|
||||
status, err := PublishProjectedCanonical(context.Background(), canonicalRoot, spec)
|
||||
if err != nil || !status.Equal || status.State != "ready" {
|
||||
t.Fatalf("PublishProjectedCanonical() = %#v, %v; want an equal ready projection", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalProjectionTransactionRepublishesRecoveredCanonicalUnderOneOuterLock(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
spec := testProjectionSpec(t)
|
||||
initial := publishCanonical(t, canonicalRoot, spec)
|
||||
transaction, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = transaction.Close() })
|
||||
if err := writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate, err := transaction.PublishCanonical()
|
||||
if err != nil || !candidate.Equal || candidate.Generation == initial.Generation {
|
||||
t.Fatalf("candidate PublishCanonical() = %#v, %v", candidate, err)
|
||||
}
|
||||
if err := writeCanonicalAuth(canonicalRoot, defaultAuthYAML, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovered, err := transaction.PublishCanonical()
|
||||
if err != nil || !recovered.Equal || recovered.Generation != initial.Generation {
|
||||
t.Fatalf("recovery PublishCanonical() = %#v, %v; want original generation", recovered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalProjectionTransactionCloseNeverMakesChangedCanonicalReady(t *testing.T) {
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
spec := testProjectionSpec(t)
|
||||
publishCanonical(t, canonicalRoot, spec)
|
||||
transaction, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := transaction.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) {
|
||||
t.Fatalf("Inspect() error = %v, want blocked after closing with changed canonical bytes", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectionCoordinatorErrorsAndLogsNeverContainSyntheticPasswordsOrHashes(t *testing.T) {
|
||||
const secret = "synthetic-password-or-hash-must-not-leak"
|
||||
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
|
||||
err := RunProjectedMutation(context.Background(), canonicalRoot, testProjectionSpec(t), func() error {
|
||||
return fmt.Errorf("mutation failed: %s", secret)
|
||||
})
|
||||
if err == nil || strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("RunProjectedMutation() error = %q, must be sanitized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testProjectionSpec(t *testing.T) ProjectionSpec {
|
||||
t.Helper()
|
||||
runtimeRoot := t.TempDir()
|
||||
if err := os.Chmod(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ProjectionSpec{RuntimeRoot: runtimeRoot, UID: uint32(os.Getuid()), GID: uint32(os.Getgid())}
|
||||
}
|
||||
|
||||
func toRuntimeSpec(spec ProjectionSpec) authprojection.Spec {
|
||||
return authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}
|
||||
}
|
||||
|
||||
func publishCanonical(t *testing.T, canonicalRoot string, spec ProjectionSpec) ProjectionStatus {
|
||||
t.Helper()
|
||||
status, err := PublishProjectedCanonical(context.Background(), canonicalRoot, spec)
|
||||
if err != nil || !status.Equal || status.State != "ready" {
|
||||
t.Fatalf("PublishProjectedCanonical() = %#v, %v", status, err)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func inspectCanonicalProjection(t *testing.T, spec ProjectionSpec) ProjectionStatus {
|
||||
t.Helper()
|
||||
status, err := authprojection.Inspect(toRuntimeSpec(spec))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ProjectionStatus{State: status.Selector.State, Generation: status.Snapshot.Generation, CanonicalRevision: status.Snapshot.CanonicalRevision, Equal: true}
|
||||
}
|
||||
|
||||
func writeCanonicalAuth(directory, contents string, after error) error {
|
||||
if err := safeio.ReplaceCanonicalRegular(filepath.Join(directory, authFileName), []byte(contents), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return after
|
||||
}
|
||||
Reference in New Issue
Block a user