access-engine/internal/emission/hardening_linux_test.go
tegwick 0a05383195
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Build and Publish Container Image / build-and-push (push) Failing after 1m1s
Bound durable outbox storage and enforce process writer exclusion
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 18:43:52 +02:00

349 lines
11 KiB
Go

//go:build linux
package emission
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"github.com/netkingdom/access-engine/pkg/api"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
func boundedFixture() api.DecisionEnvelope {
return api.DecisionEnvelope{ID: "decision:bounded", Effect: api.DecisionEffectDeny, Resource: api.ResourceRef{ID: "synthetic", Type: "tenant", System: "tenant-engine"}}
}
func fixedOutbox(t *testing.T, dir string, max int64) *Outbox {
t.Helper()
o, err := OpenBounded(dir, "flex-auth.test", max)
if err != nil {
t.Fatal(err)
}
o.SetClock(func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) })
return o
}
func TestOutboxLockChild(t *testing.T) {
dir := os.Getenv("ACCESS_OUTBOX_LOCK_CHILD")
if dir == "" {
return
}
o, err := Open(dir, "flex-auth.test")
if err != nil {
fmt.Println("REFUSED")
os.Exit(23)
}
fmt.Println("READY")
var one [1]byte
os.Stdin.Read(one[:])
o.Close()
os.Exit(0)
}
func startLockChild(t *testing.T, dir string) (*exec.Cmd, io.WriteCloser, *bufio.Reader) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
child := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestOutboxLockChild$")
child.Env = []string{"ACCESS_OUTBOX_LOCK_CHILD=" + dir}
input, err := child.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := child.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := child.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { child.Process.Kill() })
return child, input, bufio.NewReader(output)
}
func TestActualProcessWriterExclusionAndCrashRelease(t *testing.T) {
dir := t.TempDir()
holder, input, output := startLockChild(t, dir)
ready, err := output.ReadString('\n')
if err != nil || ready != "READY\n" {
t.Fatal("holder failed")
}
before, _ := os.ReadFile(filepath.Join(dir, outboxFile))
if _, err := Open(dir, "flex-auth.test"); err == nil {
t.Fatal("second process writer opened")
}
after, _ := os.ReadFile(filepath.Join(dir, outboxFile))
if !bytes.Equal(before, after) {
t.Fatal("contender mutated evidence")
}
other := fixedOutbox(t, t.TempDir(), MaxOutboxBytes)
other.Close()
contender, _, result := startLockChild(t, dir)
text, _ := result.ReadString('\n')
if contender.Wait() == nil || text != "REFUSED\n" {
t.Fatal("OS contender did not refuse")
}
holder.Process.Kill()
holder.Wait()
input.Close()
reopened := fixedOutbox(t, dir, MaxOutboxBytes)
if err := reopened.Append(boundedFixture()); err != nil {
t.Fatal(err)
}
reopened.Close()
clean, cleanInput, cleanOutput := startLockChild(t, dir)
if line, _ := cleanOutput.ReadString('\n'); line != "READY\n" {
t.Fatal("close did not release lock")
}
cleanInput.Close()
if clean.Wait() != nil {
t.Fatal("clean child close failed")
}
final := fixedOutbox(t, dir, MaxOutboxBytes)
defer final.Close()
if final.Status().Committed[Class(api.DecisionEffectDeny)] != 1 {
t.Fatal("crash/close recovery changed committed prefix")
}
if _, err := os.Stat(filepath.Join(dir, "writer.lock")); err != nil {
t.Fatal("lock file was unlinked")
}
}
func TestExactCapacityBatchEntryAndRestartBoundaries(t *testing.T) {
probe := fixedOutbox(t, t.TempDir(), MaxOutboxBytes)
if err := probe.Append(boundedFixture()); err != nil {
t.Fatal(err)
}
size := probe.Status().CommittedBytes
probe.Close()
dir := t.TempDir()
o := fixedOutbox(t, dir, size)
if err := o.Append(boundedFixture()); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(o.path)
if err := o.Append(boundedFixture()); !errors.Is(err, ErrCapacity) {
t.Fatal("over-cap append accepted")
}
after, _ := os.ReadFile(o.path)
if !bytes.Equal(before, after) || o.seq != 1 || o.cursor.Seq != 0 {
t.Fatal("capacity refusal changed prefix/sequence/cursor")
}
status := o.Status()
if status.RemainingBytes != 0 || !status.CapacityExhausted || status.MaxBytes != size {
t.Fatal("capacity status inaccurate")
}
o.Close()
o = fixedOutbox(t, dir, size)
if o.Status().CommittedBytes != size || o.seq != 1 {
t.Fatal("full outbox restart lost evidence")
}
o.Close()
for _, max := range []int64{size - 1, size*2 - 1} {
batch := fixedOutbox(t, t.TempDir(), max)
count := 1
if max > size {
count = 2
}
items := make([]api.DecisionEnvelope, count)
for i := range items {
items[i] = boundedFixture()
}
if err := batch.AppendBatch(items); !errors.Is(err, ErrCapacity) {
t.Fatal("over-cap batch accepted")
}
info, _ := os.Stat(batch.path)
if info.Size() != 0 || batch.seq != 0 {
t.Fatal("batch partially written")
}
batch.Close()
}
huge := fixedOutbox(t, t.TempDir(), MaxOutboxBytes)
defer huge.Close()
entry := boundedFixture()
entry.Diagnostics = map[string]any{"payload": strings.Repeat("x", MaxEntryBytes)}
if !errors.Is(huge.Append(entry), ErrCapacity) {
t.Fatal("oversized entry accepted")
}
info, _ := os.Stat(huge.path)
if info.Size() != 0 {
t.Fatal("oversized entry written")
}
batchMemory := fixedOutbox(t, t.TempDir(), MaxOutboxBytes)
defer batchMemory.Close()
medium := boundedFixture()
medium.Diagnostics = map[string]any{"payload": strings.Repeat("m", MaxBatchBytes/2)}
if !errors.Is(batchMemory.AppendBatch([]api.DecisionEnvelope{medium, medium}), ErrCapacity) {
t.Fatal("aggregate memory bound exceeded")
}
if info, _ := os.Stat(batchMemory.path); info.Size() != 0 {
t.Fatal("oversized batch partially written")
}
for _, limit := range []int64{0, -1, MaxOutboxBytes + 1} {
if _, err := OpenBounded(t.TempDir(), "flex-auth.test", limit); err == nil {
t.Fatal("invalid configured limit")
}
}
}
func TestBoundedStartupPreservesOversizedCommittedEvidence(t *testing.T) {
for _, sample := range []struct {
name string
raw []byte
max int64
}{{"file", bytes.Repeat([]byte("x"), 1025), 1024}, {"line", append(bytes.Repeat([]byte("x"), MaxEntryBytes+1), '\n'), MaxOutboxBytes}, {"oversized-torn", bytes.Repeat([]byte("x"), MaxEntryBytes+1), MaxOutboxBytes}, {"terminated-corrupt", []byte("{bad}\n"), MaxOutboxBytes}} {
t.Run(sample.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, outboxFile)
os.WriteFile(path, sample.raw, 0600)
if _, err := OpenBounded(dir, "flex-auth.test", sample.max); err == nil {
t.Fatal("corrupt/oversized evidence accepted")
}
after, _ := os.ReadFile(path)
if !bytes.Equal(after, sample.raw) {
t.Fatal("committed evidence discarded")
}
})
}
dir := t.TempDir()
cursor := bytes.Repeat([]byte(" "), 4097)
os.WriteFile(filepath.Join(dir, "drain.json"), cursor, 0600)
if _, err := Open(dir, "flex-auth.test"); err == nil {
t.Fatal("unbounded cursor accepted")
}
after, _ := os.ReadFile(filepath.Join(dir, "drain.json"))
if !bytes.Equal(after, cursor) {
t.Fatal("invalid cursor changed")
}
}
func TestActualDiskFullAndFsyncFailureFenceUnconfirmedRollback(t *testing.T) {
for _, device := range []string{"/dev/full", "/dev/null"} {
t.Run(device, func(t *testing.T) {
o := fixedOutbox(t, t.TempDir(), MaxOutboxBytes)
defer o.Close()
if err := o.Append(boundedFixture()); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(o.path)
original := o.file
fault, err := os.OpenFile(device, os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
o.file = fault
failure := o.Append(boundedFixture())
if failure == nil {
t.Fatal("device write/sync failure accepted")
}
if device == "/dev/full" && !errors.Is(failure, syscall.ENOSPC) {
t.Fatal("did not exercise actual ENOSPC")
}
if device == "/dev/null" && !strings.Contains(failure.Error(), "sync outbox") {
t.Fatal("did not exercise actual fsync failure")
}
if !o.writeFault || !o.Status().WriterFenced {
t.Fatal("unconfirmed rollback did not fence writer")
}
fault.Close()
o.file = original
if err := o.Append(boundedFixture()); err == nil {
t.Fatal("fenced writer resumed")
}
after, _ := os.ReadFile(o.path)
if !bytes.Equal(before, after) || o.seq != 1 {
t.Fatal("disk failure changed committed prefix")
}
})
}
}
func TestWriterLockRefusesUnsafeFilesAndStartupFailureReleases(t *testing.T) {
for _, kind := range []string{"symlink", "world-readable", "hardlink"} {
t.Run(kind, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "writer.lock")
switch kind {
case "symlink":
target := filepath.Join(t.TempDir(), "target")
os.WriteFile(target, []byte("private unchanged"), 0600)
os.Symlink(target, path)
case "world-readable":
os.WriteFile(path, nil, 0644)
case "hardlink":
target := filepath.Join(t.TempDir(), "target")
os.WriteFile(target, nil, 0600)
os.Link(target, path)
}
if _, err := Open(dir, "flex-auth.test"); err == nil {
t.Fatal("unsafe lock accepted")
}
})
}
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, outboxFile), []byte("bad\n"), 0600)
if _, err := Open(dir, "flex-auth.test"); err == nil {
t.Fatal("corrupt state accepted")
}
// The failed Open has no live writer and cannot strand an OS lock. Manual
// repair here is a disposable fixture action, never an automatic recovery.
os.WriteFile(filepath.Join(dir, outboxFile), nil, 0600)
o := fixedOutbox(t, dir, MaxOutboxBytes)
o.Close()
}
func TestPVCFSGroupPermissionWideningRetainsWriterExclusion(t *testing.T) {
dir := t.TempDir()
o := fixedOutbox(t, dir, MaxOutboxBytes)
o.Close()
// Kubernetes writable-volume fsGroup reconciliation may OR group0660 on
// remount. This nonsecret lock must still work for the same admitted group.
if err := os.Chmod(filepath.Join(dir, "writer.lock"), 0660); err != nil {
t.Fatal(err)
}
o = fixedOutbox(t, dir, MaxOutboxBytes)
defer o.Close()
if _, err := Open(dir, "flex-auth.test"); err == nil {
t.Fatal("fsGroup-widened file lost exclusive lock")
}
}
func TestJournalAndCursorRefuseAliasesAndFIFOs(t *testing.T) {
for _, name := range []string{outboxFile, "drain.json"} {
for _, kind := range []string{"symlink", "hardlink", "fifo"} {
t.Run(name+"/"+kind, func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(t.TempDir(), "evidence")
original := []byte("retained evidence\n")
if err := os.WriteFile(target, original, 0600); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, name)
var err error
switch kind {
case "symlink":
err = os.Symlink(target, path)
case "hardlink":
err = os.Link(target, path)
case "fifo":
err = syscall.Mkfifo(path, 0600)
}
if err != nil {
t.Fatal(err)
}
if o, err := Open(dir, "flex-auth.test"); err == nil {
o.Close()
t.Fatal("unsafe evidence path accepted")
}
after, err := os.ReadFile(target)
if err != nil || !bytes.Equal(original, after) {
t.Fatal("refusal changed aliased evidence")
}
})
}
}
}