a73x

internal/server/store/trustedcas_test.go

Ref:   Size: 9.1 KiB   History

package store

import (
	"crypto/ed25519"
	"crypto/rand"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
)

// TestTrustedCAsRoundtrip pins that a VM's frozen CA set survives the store
// whole — label, fingerprint and key line, in the order it was given, since
// that is the order the guest's TrustedUserCAKeys file ends up in.
func TestTrustedCAsRoundtrip(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	want := []TrustedCA{
		{Label: "laptop", Fingerprint: "SHA256:aaa", AuthorizedKey: "ssh-ed25519 AAAALAPTOP"},
		{Label: "", Fingerprint: "SHA256:bbb", AuthorizedKey: "ssh-ed25519 AAAACI"},
	}
	require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
		ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
		TrustedCAs: want}))

	got, err := s.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, want, got.TrustedCAs)
}

// TestTrustedCAsUnrecordedIsNilNotEmpty pins the distinction the whole feature
// turns on. A VM created with no record reads back as nil — "we did not write
// this down" — and NOT as an empty set, which would claim the guest trusts no
// CA at all. Every reader (the console's empty state, the snapshot's fallback)
// branches on exactly this.
func TestTrustedCAsUnrecordedIsNilNotEmpty(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
		ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))

	got, err := s.GetVM("vm1")
	require.NoError(t, err)
	assert.Nil(t, got.TrustedCAs)
}

// legacyDB builds the database a pre-freeze server leaves behind: one VM whose
// trusted_cas is NULL — nothing written down about what it trusts — plus
// whatever the tenant has registered by then, which register may add. Reopening
// the returned path runs the real migration.
func legacyDB(t *testing.T, register func(*Store)) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), "eitri.db")
	s, err := Open(path, "10.77.0.0/16")
	require.NoError(t, err)
	_, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
	require.NoError(t, err)
	h := enrollHost(t, s)
	if register != nil {
		register(s)
	}
	// No TrustedCAs: CreateVM writes NULL, which is exactly the row a server
	// from before the column existed wrote.
	require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
		ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
	require.NoError(t, s.Close())
	return path
}

func reopen(t *testing.T, path string) *Store {
	t.Helper()
	s, err := Open(path, "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { s.Close() })
	return s
}

// testCA mints a real ed25519 CA line, because the record the backfill writes
// carries a fingerprint derived from the key — an invented string cannot pin it.
func testCA(t *testing.T) (line, fingerprint string) {
	t.Helper()
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	require.NoError(t, err)
	signer, err := ssh.NewSignerFromKey(priv)
	require.NoError(t, err)
	pub := signer.PublicKey()
	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))), ssh.FingerprintSHA256(pub)
}

// TestOpenFreezesAnUnrecordedRowOntoTheTenantsCurrentSet is the upgrade half of
// the freeze. A row that recorded nothing was served the tenant's LIVE set on
// every push, forever — so "a guest trusts the set it was created against" was
// simply false for it, and the console could never call its trust stale, having
// nothing to compare against. The upgrade is the last moment that VM's trust can
// still be written down, so it is written down here: the set it was already
// being served, now a record.
func TestOpenFreezesAnUnrecordedRowOntoTheTenantsCurrentSet(t *testing.T) {
	caLine, caFP := testCA(t)
	path := legacyDB(t, func(s *Store) {
		require.NoError(t, s.AddTenantUserCA(testTenant, caLine, "tenant", "laptop", "admin"))
	})

	s := reopen(t, path)
	got, err := s.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: caFP, AuthorizedKey: caLine}},
		got.TrustedCAs, "the row must record the set it was being served")
	assert.Equal(t, "web-1", got.Name, "the rest of the row survives the migration")

	// The column itself, not just the round-trip: the backfill writes the JSON
	// the create path writes, so no reader can tell the two apart.
	var raw string
	require.NoError(t, s.db.QueryRow(`SELECT trusted_cas FROM vms WHERE id='vm1'`).Scan(&raw))
	assert.JSONEq(t,
		`[{"label":"laptop","fingerprint":"`+caFP+`","authorized_key":"`+caLine+`"}]`, raw)
}

// TestOpenFreezesARowThatPredatesTheColumn runs the same freeze through the
// other legacy shape: a database whose vms table has no trusted_cas column at
// all, so ensureColumn adds it back as NULL first and the backfill fills it in
// the same Open.
func TestOpenFreezesARowThatPredatesTheColumn(t *testing.T) {
	caLine, caFP := testCA(t)
	path := legacyDB(t, func(s *Store) {
		require.NoError(t, s.AddTenantUserCA(testTenant, caLine, "tenant", "laptop", "admin"))
	})
	pre := reopen(t, path)
	_, err := pre.db.Exec(`ALTER TABLE vms DROP COLUMN trusted_cas`)
	require.NoError(t, err)
	require.NoError(t, pre.Close())

	got, err := reopen(t, path).GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: caFP, AuthorizedKey: caLine}},
		got.TrustedCAs)
}

// TestOpenFreezesAnEmptySetForATenantWithNoCAs pins the honest answer for the
// rare tenant that has registered nothing: an empty set — "this guest trusts no
// CA" — and not NULL. It is what the live fallback was already serving such a
// VM, and create refuses a tenant with no CA, so only a tenant that removed its
// last CA afterwards can be here.
func TestOpenFreezesAnEmptySetForATenantWithNoCAs(t *testing.T) {
	s := reopen(t, legacyDB(t, nil))

	got, err := s.GetVM("vm1")
	require.NoError(t, err)
	assert.NotNil(t, got.TrustedCAs, "an unrecorded row must not stay unrecorded")
	assert.Empty(t, got.TrustedCAs, "a tenant with no CAs freezes an empty set")

	var raw string
	require.NoError(t, s.db.QueryRow(`SELECT trusted_cas FROM vms WHERE id='vm1'`).Scan(&raw))
	assert.Equal(t, "[]", raw)
}

// TestOpenLeavesAnAlreadyFrozenRowAlone is what keeps the migration from
// becoming the live-set read it replaces: a row that has a set never gets a
// newer one, however often the server restarts and whatever the tenant has
// registered since.
func TestOpenLeavesAnAlreadyFrozenRowAlone(t *testing.T) {
	first, fp := testCA(t)
	path := legacyDB(t, func(s *Store) {
		require.NoError(t, s.AddTenantUserCA(testTenant, first, "tenant", "laptop", "admin"))
	})
	require.NoError(t, reopen(t, path).Close()) // freezes

	// A second CA arrives, and the server restarts again.
	again := reopen(t, path)
	second, _ := testCA(t)
	require.NoError(t, again.AddTenantUserCA(testTenant, second, "tenant", "ci", "admin"))
	require.NoError(t, again.Close())

	got, err := reopen(t, path).GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: fp, AuthorizedKey: first}},
		got.TrustedCAs, "a frozen set must not be refreshed by a later Open")
}

// TestOpenFreezesARowLeftByARolledBackServer is why the backfill runs on every
// Open rather than once ever. The previous release writes no trusted_cas at all,
// so a plane that rolls back and forward again mints fresh unrecorded rows; a
// one-shot marker would leave exactly those on the live set forever, which is
// the bug this migration closes.
func TestOpenFreezesARowLeftByARolledBackServer(t *testing.T) {
	caLine, caFP := testCA(t)
	path := legacyDB(t, func(s *Store) {
		require.NoError(t, s.AddTenantUserCA(testTenant, caLine, "tenant", "laptop", "admin"))
	})
	s := reopen(t, path)
	// What the older server's create leaves behind, after this one has run.
	_, err := s.db.Exec(`UPDATE vms SET trusted_cas=NULL WHERE id='vm1'`)
	require.NoError(t, err)
	require.NoError(t, s.Close())

	got, err := reopen(t, path).GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: caFP, AuthorizedKey: caLine}},
		got.TrustedCAs, "a row minted by a rolled-back server must still be frozen")
}

// TestOpenFreezesEachTenantsOwnSet: the backfill reads and caches per tenant,
// which is where a fleet-wide read would hand one tenant's CA to another
// tenant's guest — the failure the create-side refusal is tested against for
// the same reason.
func TestOpenFreezesEachTenantsOwnSet(t *testing.T) {
	mine, mineFP := testCA(t)
	theirs, _ := testCA(t)
	path := legacyDB(t, func(s *Store) {
		other, err := s.CreateTenantForIdentity("https://test-issuer", "other-subject", "other@test.local")
		require.NoError(t, err)
		require.NoError(t, s.AddTenantUserCA(testTenant, mine, "tenant", "laptop", "admin"))
		require.NoError(t, s.AddTenantUserCA(other.ID, theirs, "tenant", "someone-elses", "admin"))
	})

	got, err := reopen(t, path).GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: mineFP, AuthorizedKey: mine}},
		got.TrustedCAs, "another tenant's CA must not be frozen onto this tenant's guest")
}