feat(auth): coordinate canonical projection publication
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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, ®istry); 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 }
|
||||
|
||||
Reference in New Issue
Block a user