a73x

internal/server/api/isolation_test.go

Ref:   Size: 12.4 KiB   History

package api

import (
	"bufio"
	"context"
	"crypto/rand"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/server/api/types"
	"github.com/a73x/eitri/internal/server/sshca"
	"github.com/a73x/eitri/internal/server/store"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
)

// twoTenant is a two-tenant world: the seeded `default` tenant (auth'd by the
// package-global testPAT) and a second `beta` tenant, each with one enrolled
// host carrying one VM. It is the fixture every isolation test shares.
type twoTenant struct {
	ts       *httptest.Server
	st       *store.Store
	a        *API
	betaID   string
	betaPAT  string
	defHost  string
	defVM    string
	betaHost string
	betaVM   string
}

// enrollWith mints an enroll token with pat and redeems it, returning the new
// host id. Mirrors enroll() but for an arbitrary credential + host name.
func enrollWith(t *testing.T, ts *httptest.Server, pat, name string) string {
	t.Helper()
	resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", pat, nil)
	require.Equal(t, 201, resp.StatusCode)
	var tok map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&tok))
	resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
		"token": tok["token"], "name": name, "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
	require.Equal(t, 201, resp.StatusCode)
	var out map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
	agentJoins(out["host_id"])
	return out["host_id"]
}

// createVMWith creates a VM on hostID with pat, returning the VM id.
func createVMWith(t *testing.T, ts *httptest.Server, pat, hostID, name string) string {
	t.Helper()
	resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID, "name": name})
	require.Equal(t, 201, resp.StatusCode)
	var out map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
	return out["id"]
}

func newTwoTenant(t *testing.T) twoTenant {
	t.Helper()
	ts, st, _, _, a := newServer(t)

	// Second tenant with its own PAT and BYO user CA (the VM-create precondition).
	beta, err := st.CreateTenantForIdentity("https://issuer.example", "sub-beta", "beta@example.com")
	require.NoError(t, err)
	betaPAT, _, err := st.CreateAPIToken(beta.ID, "beta", 0)
	require.NoError(t, err)
	require.NoError(t, st.AddTenantUserCA(beta.ID,
		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBETASEEDCA beta-seed", "tenant", "beta-seed", "test"))

	w := twoTenant{ts: ts, st: st, a: a, betaID: beta.ID, betaPAT: betaPAT}
	w.defHost = enrollWith(t, ts, testPAT, "default-host")
	w.defVM = createVMWith(t, ts, testPAT, w.defHost, "default-vm")
	w.betaHost = enrollWith(t, ts, betaPAT, "beta-host")
	w.betaVM = createVMWith(t, ts, betaPAT, w.betaHost, "beta-vm")
	return w
}

// readSSESnapshot opens the events stream at url and returns the first
// `event: state` frame parsed as a StateSnapshot.
func readSSESnapshot(t *testing.T, url string) types.StateSnapshot {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	require.Equal(t, 200, resp.StatusCode)

	sc := bufio.NewScanner(resp.Body)
	sawState := false
	for sc.Scan() {
		line := sc.Text()
		if strings.HasPrefix(line, "event: state") {
			sawState = true
			continue
		}
		if sawState && strings.HasPrefix(line, "data: ") {
			var snap types.StateSnapshot
			require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &snap))
			return snap
		}
	}
	t.Fatal("no state frame received")
	return types.StateSnapshot{}
}

// TestSSEStreamIsTenantScoped proves the SSE fan-out is filtered per connection:
// a stream opened with a ticket minted by one tenant's credential carries ONLY
// that tenant's hosts/VMs — the other tenant's resources never appear.
func TestSSEStreamIsTenantScoped(t *testing.T) {
	w := newTwoTenant(t)

	// default's stream: only default's host/VM.
	defSnap := readSSESnapshot(t, w.ts.URL+"/api/v1/events?ticket="+mintTicket(t, w.ts.URL, testPAT))
	defHostIDs := hostIDSet(defSnap)
	defVMIDs := vmIDSet(defSnap)
	assert.Contains(t, defHostIDs, w.defHost)
	assert.Contains(t, defVMIDs, w.defVM)
	assert.NotContains(t, defHostIDs, w.betaHost, "default's stream must not carry beta's host")
	assert.NotContains(t, defVMIDs, w.betaVM, "default's stream must not carry beta's VM")

	// beta's stream: only beta's host/VM.
	betaSnap := readSSESnapshot(t, w.ts.URL+"/api/v1/events?ticket="+mintTicket(t, w.ts.URL, w.betaPAT))
	betaHostIDs := hostIDSet(betaSnap)
	betaVMIDs := vmIDSet(betaSnap)
	assert.Contains(t, betaHostIDs, w.betaHost)
	assert.Contains(t, betaVMIDs, w.betaVM)
	assert.NotContains(t, betaHostIDs, w.defHost, "beta's stream must not carry default's host")
	assert.NotContains(t, betaVMIDs, w.defVM, "beta's stream must not carry default's VM")
}

func hostIDSet(s types.StateSnapshot) map[string]bool {
	out := map[string]bool{}
	for _, h := range s.Hosts {
		out[h.ID] = true
	}
	return out
}

func vmIDSet(s types.StateSnapshot) map[string]bool {
	out := map[string]bool{}
	for _, v := range s.VMs {
		out[v.ID] = true
	}
	return out
}

// TestConsoleWSRejectsForeignTenantVM proves the console attach ownership gate: a
// ticket minted by default cannot open a console to beta's VM — it answers 404
// (no existence leak), identical to a missing VM.
func TestConsoleWSRejectsForeignTenantVM(t *testing.T) {
	w := newTwoTenant(t)
	w.a.SetConsoleDialer(&fakeConsole{}) // wired, so we reach the ownership check (not 503)

	// default's ticket, beta's VM → 404 before any upgrade/attach.
	ticket := mintTicket(t, w.ts.URL, testPAT)
	resp, err := w.ts.Client().Get(w.ts.URL + "/api/v1/vms/" + w.betaVM + "/console/ws?ticket=" + ticket)
	require.NoError(t, err)
	defer resp.Body.Close()
	assert.Equal(t, 404, resp.StatusCode, "cross-tenant console must 404, not leak existence")

	// Sanity: beta's own ticket reaches beta's VM (the handshake upgrades).
	betaTicket := mintTicket(t, w.ts.URL, w.betaPAT)
	r2, err := w.ts.Client().Get(w.ts.URL + "/api/v1/vms/" + w.betaVM + "/console/ws?ticket=" + betaTicket)
	require.NoError(t, err)
	defer r2.Body.Close()
	assert.NotEqual(t, 404, r2.StatusCode, "beta reaching its own VM must not 404")
}

// TestAuditIsTenantScoped proves GET /api/v1/audit returns only the caller
// tenant's rows: beta's enroll/mint/create actions never surface for default and
// vice versa.
func TestAuditIsTenantScoped(t *testing.T) {
	w := newTwoTenant(t)

	defRows := listAudit(t, w.ts, testPAT)
	betaRows := listAudit(t, w.ts, w.betaPAT)

	// Each tenant's own VM name shows in its audit; the other's never does.
	assert.True(t, auditMentions(defRows, "default-vm"), "default must see its own vm.create")
	assert.False(t, auditMentions(defRows, "beta-vm"), "default must not see beta's audit rows")
	assert.True(t, auditMentions(betaRows, "beta-vm"), "beta must see its own vm.create")
	assert.False(t, auditMentions(betaRows, "default-vm"), "beta must not see default's audit rows")
}

func listAudit(t *testing.T, ts *httptest.Server, pat string) []map[string]any {
	t.Helper()
	resp := do(t, "GET", ts.URL+"/api/v1/audit", pat, nil)
	require.Equal(t, 200, resp.StatusCode)
	var rows []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
	return rows
}

func auditMentions(rows []map[string]any, needle string) bool {
	for _, r := range rows {
		b, _ := json.Marshal(r)
		if strings.Contains(string(b), needle) {
			return true
		}
	}
	return false
}

// TestListEndpointsAreTenantScoped proves the real HTTP list paths (not just the
// in-package filter) never cross tenants: beta's PAT sees only beta's host/VM.
func TestListEndpointsAreTenantScoped(t *testing.T) {
	w := newTwoTenant(t)

	betaHosts := decodeJSONKeys(t, do(t, "GET", w.ts.URL+"/api/v1/hosts", w.betaPAT, nil))
	require.Len(t, betaHosts, 1)
	assert.Equal(t, w.betaHost, betaHosts[0]["id"])

	betaVMs := decodeJSONKeys(t, do(t, "GET", w.ts.URL+"/api/v1/vms", w.betaPAT, nil))
	require.Len(t, betaVMs, 1)
	assert.Equal(t, w.betaVM, betaVMs[0]["id"])

	// default sees only its own, too.
	defHosts := decodeJSONKeys(t, do(t, "GET", w.ts.URL+"/api/v1/hosts", testPAT, nil))
	require.Len(t, defHosts, 1)
	assert.Equal(t, w.defHost, defHosts[0]["id"])
}

// TestHostOperationsRejectForeignTenant proves every ex-fleet host operation
// (decommission, revoke-credential, upgrade-agent) answers 404 for a host in
// another tenant — same not-found response as a missing host, no existence leak.
func TestHostOperationsRejectForeignTenant(t *testing.T) {
	w := newTwoTenant(t)
	// upgrade-agent needs release + upgrader wired to reach the ownership gate
	// (they are checked before the host lookup).
	w.a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
	w.a.SetAgentUpgrader(&fakeUpgrader{})

	// default acting on beta's host → 404 on every operation.
	assert.Equal(t, 404, do(t, "DELETE", w.ts.URL+"/api/v1/hosts/"+w.betaHost, testPAT, nil).StatusCode,
		"cross-tenant decommission must 404")
	assert.Equal(t, 404, do(t, "POST", w.ts.URL+"/api/v1/hosts/"+w.betaHost+"/revoke-credential", testPAT, nil).StatusCode,
		"cross-tenant revoke-credential must 404")
	assert.Equal(t, 404, do(t, "POST", w.ts.URL+"/api/v1/hosts/"+w.betaHost+"/upgrade-agent", testPAT, nil).StatusCode,
		"cross-tenant upgrade-agent must 404")

	// beta's host must still be intact (the ownership gate ran before mutate).
	h, err := w.st.GetHost(w.betaHost)
	require.NoError(t, err)
	assert.Equal(t, int64(1), h.CredGeneration, "revoke-credential must not have bumped a foreign host")
	assert.NotEqual(t, "decommissioning", h.Status, "decommission must not have touched a foreign host")
}

// TestEnrollTokenMintBindsTenantNoFleet proves enroll-token mint works for an
// ordinary tenant credential (no fleet bit exists) and binds the token — and so
// the joining host — to the minting principal's tenant.
func TestEnrollTokenMintBindsTenantNoFleet(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	beta, err := st.CreateTenantForIdentity("https://issuer.example", "sub-b", "b@example.com")
	require.NoError(t, err)
	betaPAT, _, err := st.CreateAPIToken(beta.ID, "beta", 0)
	require.NoError(t, err)

	hostID := enrollWith(t, ts, betaPAT, "b-host")
	h, err := st.GetHost(hostID)
	require.NoError(t, err)
	assert.Equal(t, beta.ID, h.Tenant, "a host joined with beta's token belongs to beta")
}

// TestSSHCertRevokeRejectsForeignTenantCert proves the revoke ownership gate: a
// cert whose signing CA belongs to another tenant answers 404 and is NOT revoked;
// the owning tenant revokes the same cert line successfully, and the revocation
// LIST stays tenant-scoped.
func TestSSHCertRevokeRejectsForeignTenantCert(t *testing.T) {
	w := newTwoTenant(t)

	// beta registers a REAL user CA and mints a user cert signed by it.
	betaCA := newCASigner(t)
	require.NoError(t, w.st.AddTenantUserCA(w.betaID,
		sshca.AuthorizedKeyLine(betaCA.PublicKey()), "tenant", "beta-real-ca", "test"))

	pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(genUserPubKey(t)))
	require.NoError(t, err)
	cert := &ssh.Certificate{
		Key: pk, Serial: 0xBEEF, CertType: ssh.UserCert, ValidBefore: ssh.CertTimeInfinity,
	}
	require.NoError(t, cert.SignCert(rand.Reader, betaCA))
	line := string(ssh.MarshalAuthorizedKey(cert))

	// default tries to revoke beta's cert → 404, and the serial stays live.
	resp := do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": line})
	assert.Equal(t, 404, resp.StatusCode, "revoking another tenant's cert must 404")
	revoked, err := w.st.IsSSHCertRevoked("beta", cert.Serial)
	require.NoError(t, err)
	assert.False(t, revoked, "a cross-tenant revoke must not have taken effect")

	// beta revokes its own cert → 204, and it shows only in beta's list.
	resp = do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", w.betaPAT, map[string]any{"certificate": line})
	require.Equal(t, 204, resp.StatusCode)
	revoked, err = w.st.IsSSHCertRevoked("beta", cert.Serial)
	require.NoError(t, err)
	assert.True(t, revoked)

	var betaList, defList []map[string]any
	require.NoError(t, json.NewDecoder(do(t, "GET", w.ts.URL+"/api/v1/ssh-certs/revoked", w.betaPAT, nil).Body).Decode(&betaList))
	require.NoError(t, json.NewDecoder(do(t, "GET", w.ts.URL+"/api/v1/ssh-certs/revoked", testPAT, nil).Body).Decode(&defList))
	assert.Len(t, betaList, 1, "beta sees its own revocation")
	assert.Empty(t, defList, "default must not see beta's revocation")
}