feat(auth): coordinate canonical projection publication

This commit is contained in:
User
2026-08-21 23:43:37 +02:00
parent 1e2c4e65c5
commit 903c0b4de5
10 changed files with 1272 additions and 8 deletions
@@ -0,0 +1,177 @@
//go:build linux
package authconfig
import (
"context"
"errors"
"os"
"sync"
"time"
"golang.org/x/sys/unix"
)
type transactionLock struct {
directoryFD int
fileFD int
closed bool
}
type projectionLockHooks struct {
beforeCreate func()
afterOpen func()
}
var projectionLockHookState struct {
sync.Mutex
hooks projectionLockHooks
}
func setProjectionLockHooksForTest(hooks projectionLockHooks) func() {
projectionLockHookState.Lock()
previous := projectionLockHookState.hooks
projectionLockHookState.hooks = hooks
projectionLockHookState.Unlock()
return func() {
projectionLockHookState.Lock()
projectionLockHookState.hooks = previous
projectionLockHookState.Unlock()
}
}
func projectionLockHooksForTest() projectionLockHooks {
projectionLockHookState.Lock()
defer projectionLockHookState.Unlock()
return projectionLockHookState.hooks
}
func acquireTransactionLock(ctx context.Context, directory string) (*transactionLock, error) {
if ctx == nil || ctx.Err() != nil || requirePrivateDirectory(directory) != nil {
return nil, errProjectionIntegrity
}
directoryFD, err := unix.Open(directory, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
if err != nil {
return nil, errProjectionIntegrity
}
lock := &transactionLock{directoryFD: directoryFD, fileFD: -1}
fail := func() (*transactionLock, error) {
_ = lock.Close()
return nil, errProjectionIntegrity
}
if !validTransactionDirectory(directoryFD) {
return fail()
}
fileFD, err := openTransactionLockFile(directoryFD)
if err != nil {
return fail()
}
lock.fileFD = fileFD
if hook := projectionLockHooksForTest().afterOpen; hook != nil {
hook()
}
var opened unix.Stat_t
if unix.Fstat(fileFD, &opened) != nil || !validTransactionLockFile(opened) || !sameTransactionLockName(directoryFD, opened) {
return fail()
}
if err := flockTransactionFile(ctx, fileFD); err != nil {
_ = lock.Close()
return nil, err
}
if !sameTransactionLockName(directoryFD, opened) {
return fail()
}
return lock, nil
}
func openTransactionLockFile(directoryFD int) (int, error) {
for attempt := 0; attempt < 2; attempt++ {
fd, err := unix.Openat(directoryFD, transactionLockFileName, unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if err == nil {
return fd, nil
}
if !errors.Is(err, unix.ENOENT) {
return -1, errProjectionIntegrity
}
if hook := projectionLockHooksForTest().beforeCreate; hook != nil {
hook()
}
fd, err = unix.Openat(directoryFD, transactionLockFileName, unix.O_RDWR|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if err == nil {
if unix.Fchmod(fd, 0o600) != nil || unix.Fsync(fd) != nil || unix.Fsync(directoryFD) != nil {
unix.Close(fd)
return -1, errProjectionIntegrity
}
return fd, nil
}
if !errors.Is(err, unix.EEXIST) {
return -1, errProjectionIntegrity
}
}
return -1, errProjectionIntegrity
}
func validTransactionDirectory(fd int) bool {
var stat unix.Stat_t
return unix.Fstat(fd, &stat) == nil && stat.Mode&unix.S_IFMT == unix.S_IFDIR && stat.Uid == uint32(os.Geteuid()) && stat.Mode&0o077 == 0
}
func validTransactionLockFile(stat unix.Stat_t) bool {
return stat.Mode&unix.S_IFMT == unix.S_IFREG && stat.Nlink == 1 && stat.Uid == uint32(os.Geteuid()) && stat.Mode&0o7777 == 0o600
}
func sameTransactionLockName(directoryFD int, opened unix.Stat_t) bool {
var named unix.Stat_t
return unix.Fstatat(directoryFD, transactionLockFileName, &named, unix.AT_SYMLINK_NOFOLLOW) == nil && validTransactionLockFile(named) && named.Dev == opened.Dev && named.Ino == opened.Ino
}
func flockTransactionFile(ctx context.Context, fd int) error {
for {
if err := ctx.Err(); err != nil {
return err
}
err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
return nil
}
if !errors.Is(err, unix.EWOULDBLOCK) {
return errProjectionIntegrity
}
if hook := coordinatorHooks().onOuterLockContention; hook != nil {
hook()
}
timer := time.NewTimer(10 * time.Millisecond)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ctx.Err()
case <-timer.C:
}
}
}
func (lock *transactionLock) Close() error {
if lock == nil || lock.closed {
return nil
}
lock.closed = true
var err error
if lock.fileFD >= 0 {
if unlockErr := unix.Flock(lock.fileFD, unix.LOCK_UN); unlockErr != nil {
err = errors.Join(err, errProjectionCleanup)
}
if closeErr := unix.Close(lock.fileFD); closeErr != nil {
err = errors.Join(err, errProjectionCleanup)
}
lock.fileFD = -1
}
if lock.directoryFD >= 0 {
if closeErr := unix.Close(lock.directoryFD); closeErr != nil {
err = errors.Join(err, errProjectionCleanup)
}
lock.directoryFD = -1
}
return err
}
@@ -0,0 +1,13 @@
//go:build !linux
package authconfig
import "context"
type transactionLock struct{}
func acquireTransactionLock(context.Context, string) (*transactionLock, error) {
return nil, errProjectionIntegrity
}
func (*transactionLock) Close() error { return nil }
@@ -0,0 +1,349 @@
package authconfig
import (
"context"
"errors"
"sync"
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
)
const transactionLockFileName = ".auth-transaction.lock"
var (
errProjectionMutation = errors.New("authentication projection mutation failed")
errProjectionPublish = errors.New("authentication projection publication failed")
errProjectionCleanup = errors.New("authentication projection cleanup failed")
errProjectionIntegrity = errors.New("authentication projection coordination failed")
)
var runtimeProjectionBegin = authprojection.BeginContext
func setRuntimeProjectionBeginForTest(begin func(context.Context, authprojection.Spec, *authprojection.Snapshot, bool) (*authprojection.Transaction, error)) func() {
previous := runtimeProjectionBegin
runtimeProjectionBegin = begin
return func() { runtimeProjectionBegin = previous }
}
type ProjectionSpec struct {
RuntimeRoot string
UID uint32
GID uint32
}
type ProjectionStatus struct {
State string
Generation string
CanonicalRevision string
Equal bool
}
type ExternalProjectionTransaction struct {
ctx context.Context
canonicalRoot string
spec authprojection.Spec
outerLock *transactionLock
projection *authprojection.Transaction
before authprojection.Snapshot
hasBefore bool
published bool
closed bool
}
type projectionCoordinatorHooks struct {
onOuterLockContention func()
onCanonicalLockContention func()
beforeProjectionBegin func()
verifyCommittedProjection func(authprojection.Status, authprojection.Snapshot) error
}
var projectionCoordinatorHookState struct {
sync.Mutex
hooks projectionCoordinatorHooks
}
func setProjectionCoordinatorHooksForTest(hooks projectionCoordinatorHooks) func() {
projectionCoordinatorHookState.Lock()
previous := projectionCoordinatorHookState.hooks
projectionCoordinatorHookState.hooks = hooks
projectionCoordinatorHookState.Unlock()
return func() {
projectionCoordinatorHookState.Lock()
projectionCoordinatorHookState.hooks = previous
projectionCoordinatorHookState.Unlock()
}
}
func coordinatorHooks() projectionCoordinatorHooks {
projectionCoordinatorHookState.Lock()
defer projectionCoordinatorHookState.Unlock()
return projectionCoordinatorHookState.hooks
}
func RunProjectedMutation(ctx context.Context, canonicalRoot string, spec ProjectionSpec, mutate func() error) (err error) {
if mutate == nil {
return errProjectionMutation
}
transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec)
if err != nil {
return errProjectionIntegrity
}
defer func() {
if closeErr := transaction.Close(); closeErr != nil {
if err == nil {
err = errProjectionCleanup
} else {
err = errors.Join(err, errProjectionCleanup)
}
}
}()
if mutateErr := mutate(); mutateErr != nil {
if restoreErr := transaction.restorePriorIfCanonicalUnchanged(ctx); restoreErr != nil {
return errors.Join(errProjectionMutation, errProjectionCleanup)
}
return errProjectionMutation
}
if _, publishErr := transaction.publishCanonicalContext(ctx); publishErr != nil {
return errProjectionPublish
}
return nil
}
func PublishProjectedCanonical(ctx context.Context, canonicalRoot string, spec ProjectionSpec) (status ProjectionStatus, err error) {
transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec)
if err != nil {
return ProjectionStatus{}, errProjectionIntegrity
}
defer func() {
if closeErr := transaction.Close(); closeErr != nil {
if err == nil {
err = errProjectionCleanup
} else {
err = errors.Join(err, errProjectionCleanup)
}
}
}()
return transaction.publishCanonicalContext(ctx)
}
func BeginExternalProjectionTransaction(ctx context.Context, canonicalRoot string, spec ProjectionSpec) (transaction *ExternalProjectionTransaction, err error) {
if ctx == nil {
return nil, errProjectionIntegrity
}
if err := requirePrivateDirectory(canonicalRoot); err != nil {
return nil, errProjectionIntegrity
}
outerLock, err := acquireTransactionLock(ctx, canonicalRoot)
if err != nil {
return nil, errProjectionIntegrity
}
transaction = &ExternalProjectionTransaction{
ctx: ctx,
canonicalRoot: canonicalRoot,
spec: authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID},
outerLock: outerLock,
}
created := transaction
defer func() {
if err != nil {
if closeErr := created.closeProjectionAndOuterLock(); closeErr != nil {
err = errors.Join(errProjectionIntegrity, errProjectionCleanup)
} else {
err = errProjectionIntegrity
}
}
}()
if err = transaction.beginProjection(ctx); err != nil {
return nil, err
}
return transaction, nil
}
func (transaction *ExternalProjectionTransaction) PublishCanonical() (ProjectionStatus, error) {
if transaction == nil {
return ProjectionStatus{}, errProjectionIntegrity
}
return transaction.publishCanonicalContext(transaction.ctx)
}
func (transaction *ExternalProjectionTransaction) publishCanonicalContext(ctx context.Context) (ProjectionStatus, error) {
if transaction == nil || transaction.closed {
return ProjectionStatus{}, errProjectionIntegrity
}
if transaction.projection == nil {
if err := transaction.beginProjection(ctx); err != nil {
return ProjectionStatus{}, errProjectionIntegrity
}
}
var published authprojection.Status
var expected authprojection.Snapshot
committed := false
err := transaction.withCanonicalLock(ctx, func() error {
snapshot, err := loadSnapshotBytes(transaction.canonicalRoot)
if err != nil {
return err
}
expected = snapshot
published, err = transaction.projection.Commit(snapshot)
if err != nil {
return err
}
committed = true
if !equalProjection(published, snapshot) {
return errProjectionIntegrity
}
if verify := coordinatorHooks().verifyCommittedProjection; verify != nil {
return verify(published, snapshot)
}
return nil
})
if err != nil {
if committed {
if blockErr := transaction.ensureBlocked(); blockErr != nil {
return ProjectionStatus{}, errors.Join(errProjectionPublish, errProjectionCleanup)
}
}
return ProjectionStatus{}, errProjectionPublish
}
transaction.published = true
if err := transaction.releaseProjection(); err != nil {
if blockErr := transaction.ensureBlocked(); blockErr != nil {
return ProjectionStatus{}, errors.Join(errProjectionCleanup, errProjectionIntegrity)
}
return ProjectionStatus{}, errProjectionCleanup
}
return projectionStatus(published, expected), nil
}
func (transaction *ExternalProjectionTransaction) RestorePriorIfCanonicalUnchanged() error {
if transaction == nil {
return errProjectionIntegrity
}
return transaction.restorePriorIfCanonicalUnchanged(transaction.ctx)
}
func (transaction *ExternalProjectionTransaction) restorePriorIfCanonicalUnchanged(ctx context.Context) error {
if transaction == nil || transaction.closed || transaction.projection == nil || !transaction.hasBefore {
return errProjectionIntegrity
}
err := transaction.withCanonicalLock(ctx, func() error {
current, err := loadSnapshotBytes(transaction.canonicalRoot)
if err != nil {
return err
}
if current.Generation != transaction.before.Generation {
return errProjectionIntegrity
}
return transaction.projection.RestoreIfUnchanged(current)
})
if err != nil {
return errProjectionIntegrity
}
return transaction.releaseProjection()
}
func (transaction *ExternalProjectionTransaction) Close() (err error) {
if transaction == nil || transaction.closed {
return nil
}
defer func() {
transaction.closed = true
err = errors.Join(err, transaction.closeProjectionAndOuterLock())
}()
if transaction.projection == nil && transaction.published {
if beginErr := transaction.beginProjection(context.Background()); beginErr != nil {
return errProjectionIntegrity
}
if restoreErr := transaction.restorePriorIfCanonicalUnchanged(context.Background()); restoreErr != nil {
return errProjectionIntegrity
}
}
return nil
}
func (transaction *ExternalProjectionTransaction) beginProjection(ctx context.Context) error {
status, err := authprojection.Inspect(transaction.spec)
var before *authprojection.Snapshot
requireReadyMatch := false
if err == nil {
captured := status.Snapshot
before = &captured
requireReadyMatch = true
}
if hook := coordinatorHooks().beforeProjectionBegin; hook != nil {
hook()
}
projection, err := runtimeProjectionBegin(ctx, transaction.spec, before, requireReadyMatch)
if err != nil {
return errProjectionIntegrity
}
transaction.projection = projection
transaction.hasBefore = before != nil
if before != nil {
transaction.before = *before
} else {
transaction.before = authprojection.Snapshot{}
}
return nil
}
func (transaction *ExternalProjectionTransaction) ensureBlocked() error {
if transaction.projection != nil {
if err := transaction.releaseProjection(); err != nil {
return err
}
}
return transaction.beginProjection(context.Background())
}
func (transaction *ExternalProjectionTransaction) releaseProjection() error {
if transaction.projection == nil {
return nil
}
projection := transaction.projection
transaction.projection = nil
if err := projection.Close(); err != nil {
return errProjectionIntegrity
}
return nil
}
func (transaction *ExternalProjectionTransaction) closeProjectionAndOuterLock() error {
var err error
if closeErr := transaction.releaseProjection(); closeErr != nil {
err = errors.Join(err, closeErr)
}
if transaction.outerLock != nil {
if unlockErr := transaction.outerLock.Close(); unlockErr != nil {
err = errors.Join(err, errProjectionCleanup)
}
transaction.outerLock = nil
}
return err
}
func (transaction *ExternalProjectionTransaction) withCanonicalLock(ctx context.Context, callback func() error) (err error) {
lock, err := acquireLockContext(ctx, transaction.canonicalRoot)
if err != nil {
return errProjectionIntegrity
}
defer func() {
if unlockErr := lock.Unlock(); unlockErr != nil {
err = errors.Join(err, errProjectionCleanup)
}
}()
return callback()
}
func equalProjection(status authprojection.Status, snapshot authprojection.Snapshot) bool {
return status.Selector.State == "ready" && status.Snapshot.Generation == snapshot.Generation && status.Snapshot.CanonicalRevision == snapshot.CanonicalRevision
}
func projectionStatus(status authprojection.Status, expected authprojection.Snapshot) ProjectionStatus {
return ProjectionStatus{
State: status.Selector.State,
Generation: status.Snapshot.Generation,
CanonicalRevision: status.Snapshot.CanonicalRevision,
Equal: equalProjection(status, expected),
}
}
@@ -0,0 +1,300 @@
//go:build linux
package authconfig
import (
"context"
"errors"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
)
func TestBeginExternalTransactionReleasesOuterLockAfterRuntimeCancellation(t *testing.T) {
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
spec := testProjectionSpec(t)
raw, err := authprojection.Begin(toRuntimeSpec(spec), nil, false)
if err != nil {
t.Fatal(err)
}
defer func() {
if raw != nil {
_ = raw.Close()
}
}()
admitted := make(chan struct{})
restore := setRuntimeProjectionBeginForTest(func(ctx context.Context, runtimeSpec authprojection.Spec, before *authprojection.Snapshot, requireReadyMatch bool) (*authprojection.Transaction, error) {
close(admitted)
return authprojection.BeginContext(ctx, runtimeSpec, before, requireReadyMatch)
})
t.Cleanup(restore)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() {
transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec)
if err == nil {
err = transaction.Close()
}
result <- err
}()
<-admitted
cancel()
select {
case err := <-result:
if err == nil {
t.Fatal("BeginExternalProjectionTransaction() unexpectedly succeeded after cancellation")
}
case <-time.After(2 * time.Second):
t.Fatal("BeginExternalProjectionTransaction() did not return after runtime cancellation")
}
restore()
if err := raw.Close(); err != nil {
t.Fatal(err)
}
raw = nil
next, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec)
if err != nil {
t.Fatalf("outer lock remained held after runtime cancellation: %v", err)
}
if err := next.Close(); err != nil {
t.Fatal(err)
}
}
func TestExternalPublishContextReturnsWhenCanonicalLockIsHeld(t *testing.T) {
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
ctx, cancel := context.WithCancel(context.Background())
transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, testProjectionSpec(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = transaction.Close() })
canonicalLock, err := acquireLock(canonicalRoot)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = canonicalLock.Unlock() })
contended := make(chan struct{}, 1)
reblocked := make(chan struct{}, 1)
restore := setProjectionCoordinatorHooksForTest(projectionCoordinatorHooks{
onCanonicalLockContention: func() { contended <- struct{}{} },
beforeProjectionBegin: func() { reblocked <- struct{}{} },
})
t.Cleanup(restore)
result := make(chan error, 1)
go func() {
_, err := transaction.PublishCanonical()
result <- err
}()
<-contended
if _, err := authprojection.Inspect(toRuntimeSpec(testProjectionSpecFromTransaction(transaction))); !errors.Is(err, authprojection.ErrBlocked) {
t.Fatalf("Inspect() error = %v, want blocked while waiting for canonical lock", err)
}
cancel()
select {
case <-reblocked:
t.Fatal("publishCanonicalContext() re-blocked even though Commit was never reached")
case err := <-result:
if !errors.Is(err, errProjectionPublish) {
t.Fatalf("publishCanonicalContext() error = %v, want sanitized publish failure", err)
}
case <-time.After(2 * time.Second):
t.Fatal("publishCanonicalContext() did not return after cancellation")
}
}
func TestExternalRestoreContextReturnsWhenCanonicalLockIsHeld(t *testing.T) {
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
spec := testProjectionSpec(t)
publishCanonical(t, canonicalRoot, spec)
ctx, cancel := context.WithCancel(context.Background())
transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = transaction.Close() })
canonicalLock, err := acquireLock(canonicalRoot)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = canonicalLock.Unlock() })
contended := make(chan struct{}, 1)
restoreHooks := setProjectionCoordinatorHooksForTest(projectionCoordinatorHooks{
onCanonicalLockContention: func() { contended <- struct{}{} },
})
t.Cleanup(restoreHooks)
result := make(chan error, 1)
go func() { result <- transaction.RestorePriorIfCanonicalUnchanged() }()
<-contended
if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) {
t.Fatalf("Inspect() error = %v, want blocked while waiting for canonical lock", err)
}
cancel()
select {
case err := <-result:
if !errors.Is(err, errProjectionIntegrity) {
t.Fatalf("RestorePriorIfCanonicalUnchanged() error = %v, want sanitized integrity failure", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RestorePriorIfCanonicalUnchanged() did not return after cancellation")
}
}
func TestAcquireTransactionLockRejectsSwapBetweenOpenAndRevalidation(t *testing.T) {
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
opened := make(chan struct{})
release := make(chan struct{})
var firstOpen atomic.Bool
restore := setProjectionLockHooksForTest(projectionLockHooks{afterOpen: func() {
if firstOpen.CompareAndSwap(false, true) {
close(opened)
<-release
}
}})
t.Cleanup(restore)
first := make(chan struct {
lock *transactionLock
err error
}, 1)
go func() {
lock, err := acquireTransactionLock(context.Background(), canonicalRoot)
first <- struct {
lock *transactionLock
err error
}{lock, err}
}()
<-opened
replacement := filepath.Join(canonicalRoot, ".replacement-lock")
if err := safeio.WriteCanonicalNewFile(replacement, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Rename(replacement, filepath.Join(canonicalRoot, transactionLockFileName)); err != nil {
t.Fatal(err)
}
second, err := acquireTransactionLock(context.Background(), canonicalRoot)
if err != nil {
t.Fatalf("replacement-domain acquisition error = %v", err)
}
t.Cleanup(func() { _ = second.Close() })
close(release)
firstResult := <-first
if firstResult.lock != nil || !errors.Is(firstResult.err, errProjectionIntegrity) {
t.Fatalf("swapped original acquisition = %#v, %v; want no old-domain lock", firstResult.lock, firstResult.err)
}
}
func TestAcquireTransactionLockRejectsUnsafeMetadata(t *testing.T) {
for _, fixture := range []struct {
name string
arrange func(*testing.T, string)
}{
{
name: "symlink",
arrange: func(t *testing.T, path string) {
target := path + ".target"
if err := safeio.WriteCanonicalNewFile(target, nil, 0o600); err != nil {
t.Fatal(err)
}
testsupport.SymlinkOrSkip(t, target, path)
},
},
{
name: "hardlink",
arrange: func(t *testing.T, path string) {
if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Link(path, path+".linked"); err != nil {
t.Fatal(err)
}
},
},
{
name: "non-0600 mode",
arrange: func(t *testing.T, path string) {
if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0o640); err != nil {
t.Fatal(err)
}
},
},
{
name: "wrong owner",
arrange: func(t *testing.T, path string) {
if os.Geteuid() != 0 {
t.Skip("requires root to create a wrong numeric owner")
}
if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chown(path, 10001, 10001); err != nil {
t.Fatal(err)
}
},
},
} {
t.Run(fixture.name, func(t *testing.T) {
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
fixture.arrange(t, filepath.Join(canonicalRoot, transactionLockFileName))
lock, err := acquireTransactionLock(context.Background(), canonicalRoot)
if lock != nil || !errors.Is(err, errProjectionIntegrity) {
t.Fatalf("acquireTransactionLock() = %#v, %v; want unsafe metadata rejection", lock, err)
}
})
}
}
func TestAcquireTransactionLockAcceptsSafeCreatorRace(t *testing.T) {
canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin")))
paused := make(chan struct{})
release := make(chan struct{})
var firstCreate atomic.Bool
restore := setProjectionLockHooksForTest(projectionLockHooks{beforeCreate: func() {
if firstCreate.CompareAndSwap(false, true) {
close(paused)
<-release
}
}})
t.Cleanup(restore)
first := make(chan struct {
lock *transactionLock
err error
}, 1)
go func() {
lock, err := acquireTransactionLock(context.Background(), canonicalRoot)
first <- struct {
lock *transactionLock
err error
}{lock, err}
}()
<-paused
second, err := acquireTransactionLock(context.Background(), canonicalRoot)
if err != nil {
t.Fatalf("creator-race winner error = %v", err)
}
if err := second.Close(); err != nil {
t.Fatal(err)
}
close(release)
result := <-first
if result.err != nil || result.lock == nil {
t.Fatalf("creator-race loser = %#v, %v; want same validated lock domain", result.lock, result.err)
}
if err := result.lock.Close(); err != nil {
t.Fatal(err)
}
}
func testProjectionSpecFromTransaction(transaction *ExternalProjectionTransaction) ProjectionSpec {
return ProjectionSpec{RuntimeRoot: transaction.spec.RuntimeRoot, UID: transaction.spec.UID, GID: transaction.spec.GID}
}
@@ -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
}
+49 -1
View File
@@ -2,11 +2,14 @@ package authconfig
import (
"bytes"
"context"
"errors"
"io"
"os"
"path/filepath"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/gofrs/flock"
"gopkg.in/yaml.v3"
@@ -53,6 +56,34 @@ func load(directory string) (Config, Registry, error) {
return config, registry, nil
}
// loadSnapshotBytes reads only canonical, validated authentication files and returns a detached,
// bounded projection snapshot. Callers that coordinate a mutation hold .auth.lock around it.
func loadSnapshotBytes(directory string) (authprojection.Snapshot, error) {
if err := requirePrivateDirectory(directory); err != nil {
return authprojection.Snapshot{}, err
}
authContents, err := readPrivateFile(filepath.Join(directory, authFileName))
if err != nil {
return authprojection.Snapshot{}, err
}
var config Config
if err := decodeStrictYAML(authContents, &config); err != nil || !validConfig(config) {
return authprojection.Snapshot{}, errInvalidAuthenticationConfig
}
var usersContents []byte
if config.Mode == "local" {
usersContents, err = readPrivateFile(filepath.Join(directory, config.Local.UsersFile))
if err != nil {
return authprojection.Snapshot{}, err
}
var registry Registry
if err := decodeStrictYAML(usersContents, &registry); err != nil || validateRegistry(registry) != nil {
return authprojection.Snapshot{}, errInvalidAuthenticationConfig
}
}
return authprojection.NewSnapshot(config.Mode, append([]byte(nil), authContents...), append([]byte(nil), usersContents...))
}
// MutateUsers serializes the entire read-check-write transaction under the configuration lock.
// The resulting registry is revalidated and atomically replaced only after all invariants hold.
func MutateUsers(directory string, mutate func(*Registry) error) error {
@@ -137,6 +168,13 @@ func readPrivateFile(path string) ([]byte, error) {
}
func acquireLock(directory string) (*flock.Flock, error) {
return acquireLockContext(context.Background(), directory)
}
func acquireLockContext(ctx context.Context, directory string) (*flock.Flock, error) {
if ctx == nil || ctx.Err() != nil {
return nil, errInvalidAuthenticationConfig
}
path := filepath.Join(directory, lockFileName)
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil {
@@ -153,9 +191,19 @@ func acquireLock(directory string) (*flock.Flock, error) {
return nil, err
}
lock := flock.New(path, flock.SetPermissions(0o600))
if err := lock.Lock(); err != nil {
locked, err := lock.TryLock()
if err != nil {
return nil, errInvalidAuthenticationConfig
}
if !locked {
if hook := coordinatorHooks().onCanonicalLockContention; hook != nil {
hook()
}
locked, err = lock.TryLockContext(ctx, 10*time.Millisecond)
if err != nil || !locked {
return nil, errInvalidAuthenticationConfig
}
}
if _, err := readPrivateFile(path); err != nil {
_ = lock.Unlock()
return nil, err
@@ -1,16 +1,44 @@
package authconfig
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
)
func TestLoadSnapshotBytesReturnsValidatedCopiedCanonicalBytes(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) {
directory := writeAuthFiles(t, fixture.auth, fixture.users)
snapshot, err := loadSnapshotBytes(directory)
if err != nil {
t.Fatal(err)
}
if snapshot.Mode != fixture.name || !bytes.Equal(snapshot.Auth, []byte(fixture.auth)) || !bytes.Equal(snapshot.Users, []byte(fixture.users)) {
t.Fatal("loadSnapshotBytes() did not preserve the validated canonical bytes")
}
if _, err := authprojection.NewSnapshot(snapshot.Mode, snapshot.Auth, snapshot.Users); err != nil {
t.Fatalf("loadSnapshotBytes() returned invalid projection data: %v", err)
}
snapshot.Auth[0] ^= 1
onDisk, err := os.ReadFile(filepath.Join(directory, authFileName))
if err != nil || !bytes.Equal(onDisk, []byte(fixture.auth)) {
t.Fatal("loadSnapshotBytes() returned aliasing canonical storage")
}
})
}
}
const publicFixedHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"
func TestLoadReadsTheStrictLocalConfigurationAndRegistry(t *testing.T) {
@@ -3,12 +3,14 @@
package authprojection
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"path/filepath"
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
)
@@ -71,14 +73,24 @@ func Inspect(spec Spec) (Status, error) {
}
func Begin(spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, error) {
return BeginContext(context.Background(), spec, before, requireReadyMatch)
}
func BeginContext(ctx context.Context, spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, error) {
if ctx == nil {
return nil, ErrIntegrity
}
if err := ctx.Err(); err != nil {
return nil, err
}
root, err := openRoot(spec)
if err != nil {
return nil, err
}
rendezvousBeforeFlock(root)
if unix.Flock(root, unix.LOCK_EX) != nil {
if err := flockContext(ctx, root); err != nil {
unix.Close(root)
return nil, ErrIntegrity
return nil, err
}
transaction := &Transaction{spec: spec, before: before, rootFD: root, lockFD: root}
selector, selectorErr := readSelector(root, spec)
@@ -132,6 +144,30 @@ func Begin(spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, e
return transaction, nil
}
func flockContext(ctx context.Context, descriptor int) error {
for {
if err := ctx.Err(); err != nil {
return err
}
err := unix.Flock(descriptor, unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
return nil
}
if !errors.Is(err, unix.EWOULDBLOCK) {
return ErrIntegrity
}
timer := time.NewTimer(10 * time.Millisecond)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return ctx.Err()
case <-timer.C:
}
}
}
func (transaction *Transaction) Commit(after Snapshot) (Status, error) {
if transaction == nil || transaction.closed || !transaction.blocked || validateSnapshot(after) != nil {
return Status{}, ErrIntegrity
@@ -3,6 +3,7 @@
package authprojection
import (
"context"
"errors"
"fmt"
"os"
@@ -11,10 +12,50 @@ import (
"sync"
"syscall"
"testing"
"time"
"golang.org/x/sys/unix"
)
func TestBeginContextReturnsWhenRuntimeFlockIsHeld(t *testing.T) {
spec := testSpec(t)
first, err := Begin(spec, nil, false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = first.Close() })
contended := make(chan error, 1)
restore := setTestHooksForTest(testHooks{beforeFlock: func(fd int) {
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); !errors.Is(err, unix.EWOULDBLOCK) {
contended <- fmt.Errorf("runtime lock contention = %v", err)
return
}
contended <- nil
}})
t.Cleanup(restore)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() {
transaction, err := BeginContext(ctx, spec, nil, false)
if err == nil {
err = transaction.Close()
}
result <- err
}()
if err := <-contended; err != nil {
t.Fatal(err)
}
cancel()
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("BeginContext() error = %v, want context cancellation", err)
}
case <-time.After(2 * time.Second):
t.Fatal("BeginContext() did not return after cancellation")
}
}
func testSpec(t *testing.T) Spec {
t.Helper()
root := t.TempDir()
@@ -2,8 +2,15 @@
package authprojection
func Inspect(Spec) (Status, error) { return Status{}, ErrUnsupported }
func Begin(Spec, *Snapshot, bool) (*Transaction, error) { return nil, ErrUnsupported }
func (*Transaction) Commit(Snapshot) (Status, error) { return Status{}, ErrUnsupported }
func (*Transaction) RestoreIfUnchanged(Snapshot) error { return ErrUnsupported }
func (*Transaction) Close() error { return ErrUnsupported }
import "context"
func Inspect(Spec) (Status, error) { return Status{}, ErrUnsupported }
func Begin(Spec, *Snapshot, bool) (*Transaction, error) {
return nil, ErrUnsupported
}
func BeginContext(context.Context, Spec, *Snapshot, bool) (*Transaction, error) {
return nil, ErrUnsupported
}
func (*Transaction) Commit(Snapshot) (Status, error) { return Status{}, ErrUnsupported }
func (*Transaction) RestoreIfUnchanged(Snapshot) error { return ErrUnsupported }
func (*Transaction) Close() error { return ErrUnsupported }