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")
}