a73x

d0b7e5e1

feat(tenant): multi-tenancy — data model, principal, and gate authz

a73x   2026-07-25 13:19

Commit message
feat(tenant): multi-tenancy — data model, principal, and gate authz

A fleet of hosts partitions into tenants: a tenants table with an FK
rebuild migration, VM tenancy derived in-transaction from the owning
host, and tenant-scoped lookups replacing the global ones. Every API
principal is Principal{Tenant, Fleet} attached by adminAuth — fleet-only
endpoints gate on Fleet, and there is deliberately no superuser that
crosses tenants. Enrollment tokens carry the tenant, so an enrolling
host inherits it: the token is the tenant credential. The SSH gate
authorizes per connection against the tenant derived from the signing
CA, and guests get <tenant>.<name> connect names with host certs
namespaced the same way, so names never collide across tenants.

cmd/eitri-server/sshgate.go
Old New
@@ -101,16 +101,23 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) {
101 gateDomain = "localhost" 101 gateDomain = "localhost"
102 } 102 }
103 slog.Info("ssh gate host cert", "principal", gateDomain) 103 slog.Info("ssh gate host cert", "principal", gateDomain)
104 // resolve maps a VM name to its host/VM IDs; unknown or tombstoned ⇒ ok=false. 104 // resolve is tenant-scoped: the bare name is looked up WITHIN the
105 resolve := func(name string) (hostID, vmID string, ok bool) { 105 // connection's tenant only.
106 vm, err := st.VMByName(name) 106 resolve := func(tenant, name string) (hostID, vmID string, ok bool) {
107 vm, err := st.VMByTenantName(tenant, name)
107 if err != nil { 108 if err != nil {
108 return "", "", false 109 return "", "", false
109 } 110 }
110 return vm.HostID, vm.ID, true 111 return vm.HostID, vm.ID, true
111 } 112 }
112 // v1 single-admin: any CA-signed cert reaches any VM; per-user ownership is Task/Slice per §5/§9. 113 // authorize re-reads the VM row and requires tenant equality. With one CA
113 authorize := func(principal, vmID string) bool { return true } 114 // every connection is DefaultTenant so this always passes TODAY — but it
115 // is a live per-connection comparison, not a stub: tenant #2's CA maps to
116 // its own tenant here with no code-shape change.
117 authorize := func(tenant, vmID string) bool {
118 vm, err := st.GetVM(vmID)
119 return err == nil && vm.DeletedAt == nil && vm.Tenant == tenant
120 }
114 // Sign a long-lived HOST cert for the gate's own host key and present THAT 121 // Sign a long-lived HOST cert for the gate's own host key and present THAT
115 // (via a cert signer) instead of the bare key, so a client verifying with 122 // (via a cert signer) instead of the bare key, so a client verifying with
116 // `@cert-authority` accepts the gate on first connect — no TOFU window. 123 // `@cert-authority` accepts the gate on first connect — no TOFU window.
@@ -138,7 +145,7 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) {
138 } 145 }
139 return revoked 146 return revoked
140 } 147 }
141 gate := sshgate.New(gateHostSigner, g.ca.UserCA().PublicKey(), resolve, authorize, svc.OpenTCP, isRevoked) 148 gate := sshgate.New(gateHostSigner, g.ca.UserCA().PublicKey(), store.DefaultTenant, resolve, authorize, svc.OpenTCP, isRevoked)
142 ln, err := net.Listen("tcp", g.listen) 149 ln, err := net.Listen("tcp", g.listen)
143 if err != nil { 150 if err != nil {
144 slog.Error("ssh gate listen", "err", err) 151 slog.Error("ssh gate listen", "err", err)
docs/openapi.json
Old New
@@ -296,10 +296,14 @@
296 "properties": { 296 "properties": {
297 "certificate": { 297 "certificate": {
298 "type": "string" 298 "type": "string"
299 },
300 "tenant": {
301 "type": "string"
299 } 302 }
300 }, 303 },
301 "required": [ 304 "required": [
302 "certificate" 305 "certificate",
306 "tenant"
303 ], 307 ],
304 "type": "object" 308 "type": "object"
305 }, 309 },
docs/shape.html
Old New
@@ -320,7 +320,7 @@
320 { 320 {
321 "importPath": "internal/server/sshgate", 321 "importPath": "internal/server/sshgate",
322 "plane": "control", 322 "plane": "control",
323 "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003cvm\u003e`.", 323 "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003ctenant\u003e.\u003cvm\u003e`.",
324 "imports": [] 324 "imports": []
325 }, 325 },
326 { 326 {
docs/shape.json
Old New
@@ -269,7 +269,7 @@
269 { 269 {
270 "importPath": "internal/server/sshgate", 270 "importPath": "internal/server/sshgate",
271 "plane": "control", 271 "plane": "control",
272 "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003cvm\u003e`.", 272 "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003ctenant\u003e.\u003cvm\u003e`.",
273 "imports": [] 273 "imports": []
274 }, 274 },
275 { 275 {
docs/ssh-access.md
Old New
@@ -24,6 +24,10 @@ hack/eitri-ssh <vm-name> # opens a shell on the VM
24 hack/eitri-ssh <vm-name> uptime # runs a command and exits 24 hack/eitri-ssh <vm-name> uptime # runs a command and exits
25 ``` 25 ```
26 26
27 You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect
28 name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `hack/eitri-ssh`
29 builds it automatically from the tenant the mint response returns.
30
27 Environment variables: 31 Environment variables:
28 32
29 | Var | Meaning | 33 | Var | Meaning |
@@ -56,18 +60,23 @@ The helper is a thin wrapper over three steps you can run by hand:
56 "$EITRI_URL/api/v1/ssh-certs" | jq -r .certificate > ~/.ssh/id_ed25519-cert.pub 60 "$EITRI_URL/api/v1/ssh-certs" | jq -r .certificate > ~/.ssh/id_ed25519-cert.pub
57 ``` 61 ```
58 62
63 The same response also carries a `.tenant` field — the prefix of the
64 `<tenant>.<vm-name>` connect name you dial in step 3.
65
59 2. **Place the cert beside the key.** OpenSSH auto-offers a cert named 66 2. **Place the cert beside the key.** OpenSSH auto-offers a cert named
60 `<key>-cert.pub` next to `<key>`, so the write above is all that's needed — 67 `<key>-cert.pub` next to `<key>`, so the write above is all that's needed —
61 no `ssh-add` required. 68 no `ssh-add` required.
62 69
63 3. **Hop through the gate** to `ubuntu@<vm>`: 70 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`:
64 71
65 ```sh 72 ```sh
66 ssh -J "$EITRI_GATE" ubuntu@<vm-name> 73 ssh -J "$EITRI_GATE" ubuntu@<tenant>.<vm-name>
67 ``` 74 ```
68 75
69 The inner user must be `ubuntu` (the cert principal). The outer gate hop 76 The inner user must be `ubuntu` (the cert principal). The outer gate hop
70 accepts any username. 77 accepts any username. The host part is the `<tenant>.<vm-name>` connect name
78 (the tenant is the `.tenant` from the mint response); the gate resolves names
79 within your tenant and rejects a bare or foreign-prefixed name.
71 80
72 ## Certs are short-lived 81 ## Certs are short-lived
73 82
@@ -102,11 +111,12 @@ ssh \
102 -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KH -p $GATE_PORT ubuntu@$GATE_HOST" \ 111 -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KH -p $GATE_PORT ubuntu@$GATE_HOST" \
103 -o StrictHostKeyChecking=yes \ 112 -o StrictHostKeyChecking=yes \
104 -o "UserKnownHostsFile=$KH" \ 113 -o "UserKnownHostsFile=$KH" \
105 ubuntu@<vm-name> 114 ubuntu@<tenant>.<vm-name>
106 ``` 115 ```
107 116
108 The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), 117 The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it),
109 and each VM's cert principal is its VM name (so the inner `ubuntu@<vm-name>` host 118 and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the
110 must match). Because verification is by CA, recycling a VM name or IP never 119 inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by
111 produces a host-key-changed warning — the new VM simply presents a fresh 120 CA, recycling a VM name or IP never produces a host-key-changed warning — the
112 CA-signed cert for that name. `hack/eitri-ssh` does all of this for you. 121 new VM simply presents a fresh CA-signed cert for that name. `hack/eitri-ssh`
122 does all of this for you.
hack/eitri-ssh
Old New
@@ -20,6 +20,10 @@
20 # outer gate hop accepts any username. Certs are short-lived — just re-run to 20 # outer gate hop accepts any username. Certs are short-lived — just re-run to
21 # refresh. 21 # refresh.
22 # 22 #
23 # VMs are dialed by their gate connect name <tenant>.<vm-name> (which is also the
24 # VM's host-cert principal); eitri-ssh builds it automatically from the tenant
25 # the mint response returns, so you pass just the bare <vm-name>.
26 #
23 # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's CA 27 # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's CA
24 # public key and pins it as a `@cert-authority *` entry in a DEDICATED 28 # public key and pins it as a `@cert-authority *` entry in a DEDICATED
25 # known_hosts file (never your main ~/.ssh/known_hosts — a wildcard cert 29 # known_hosts file (never your main ~/.ssh/known_hosts — a wildcard cert
@@ -49,6 +53,21 @@ KEY=${EITRI_KEY:-$HOME/.ssh/id_ed25519}
49 # user's main known_hosts, where a wildcard CA would apply to every ssh target. 53 # user's main known_hosts, where a wildcard CA would apply to every ssh target.
50 KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts} 54 KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts}
51 55
56 # json_field <name>: extract a top-level JSON string field from stdin. Uses jq
57 # when available, else a sed fallback. The capture is NON-GREEDY (`[^"]*`, not
58 # `.*`) so a multi-field body doesn't swallow through to the last quote — the
59 # cause of a corrupted cert on jq-less machines now that the mint response
60 # carries both `certificate` and `tenant`. `[^"]*` is safe because the server's
61 # JSON string values contain no raw double quotes; `\n` escapes are unescaped so
62 # the multi-line cert survives (a no-op for single-token fields like tenant).
63 json_field() {
64 if command -v jq >/dev/null 2>&1; then
65 jq -r ".$1"
66 else
67 sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | sed 's/\\n/\n/g'
68 fi
69 }
70
52 # 1. Ensure a keypair exists. 71 # 1. Ensure a keypair exists.
53 if [ ! -f "$KEY" ]; then 72 if [ ! -f "$KEY" ]; then
54 echo "eitri-ssh: generating SSH key at $KEY" >&2 73 echo "eitri-ssh: generating SSH key at $KEY" >&2
@@ -71,17 +90,23 @@ if [ "$code" != "200" ]; then
71 exit 1 90 exit 1
72 fi 91 fi
73 92
74 # 3. Extract .certificate (jq preferred; sed fallback for the flat string field). 93 # 3. Extract .certificate (jq preferred; non-greedy sed fallback via json_field).
75 if command -v jq >/dev/null 2>&1; then 94 cert=$(printf '%s' "$body" | json_field certificate)
76 cert=$(printf '%s' "$body" | jq -r '.certificate')
77 else
78 cert=$(printf '%s' "$body" | sed -n 's/.*"certificate"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' | sed 's/\\n/\n/g')
79 fi
80 if [ -z "$cert" ] || [ "$cert" = "null" ]; then 95 if [ -z "$cert" ] || [ "$cert" = "null" ]; then
81 echo "eitri-ssh: could not extract certificate from response: $body" >&2 96 echo "eitri-ssh: could not extract certificate from response: $body" >&2
82 exit 1 97 exit 1
83 fi 98 fi
84 99
100 # 3b. Extract the tenant and build the gate connect name <tenant>.<vm>. The
101 # gate resolves names WITHIN a tenant and each VM's host-cert principal is
102 # the namespaced name, so the connect name must carry the tenant prefix.
103 tenant=$(printf '%s' "$body" | json_field tenant)
104 if [ -z "$tenant" ] || [ "$tenant" = "null" ]; then
105 echo "eitri-ssh: server did not return a tenant (pre-tenancy server?); cannot build connect name" >&2
106 exit 1
107 fi
108 TARGET="$tenant.$VM"
109
85 # 4. Write it beside the key so ssh auto-offers it. 110 # 4. Write it beside the key so ssh auto-offers it.
86 printf '%s\n' "$cert" >"$KEY-cert.pub" 111 printf '%s\n' "$cert" >"$KEY-cert.pub"
87 112
@@ -96,11 +121,7 @@ if [ "$ca_code" != "200" ]; then
96 echo "eitri-ssh: fetch CA failed (HTTP $ca_code): $ca_body" >&2 121 echo "eitri-ssh: fetch CA failed (HTTP $ca_code): $ca_body" >&2
97 exit 1 122 exit 1
98 fi 123 fi
99 if command -v jq >/dev/null 2>&1; then 124 ca=$(printf '%s' "$ca_body" | json_field ca)
100 ca=$(printf '%s' "$ca_body" | jq -r '.ca')
101 else
102 ca=$(printf '%s' "$ca_body" | sed -n 's/.*"ca"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' | sed 's/\\n/\n/g')
103 fi
104 if [ -z "$ca" ] || [ "$ca" = "null" ]; then 125 if [ -z "$ca" ] || [ "$ca" = "null" ]; then
105 echo "eitri-ssh: could not extract CA key from response: $ca_body" >&2 126 echo "eitri-ssh: could not extract CA key from response: $ca_body" >&2
106 exit 1 127 exit 1
@@ -119,8 +140,9 @@ printf '@cert-authority * %s\n' "$(printf '%s' "$ca" | tr -d '\r\n')" >"$KNOWN_H
119 # with StrictHostKeyChecking=yes. 140 # with StrictHostKeyChecking=yes.
120 # 141 #
121 # The gate host cert's principal must match $GATE_HOST (eitri's 142 # The gate host cert's principal must match $GATE_HOST (eitri's
122 # ssh_gate_domain); each VM's host cert principal is the VM name. A mismatch 143 # ssh_gate_domain); each VM's host cert principal is its <tenant>.<vm-name>
123 # is a hard failure, not a prompt — that is the point. 144 # connect name, which eitri-ssh builds automatically from the mint response.
145 # A mismatch is a hard failure, not a prompt — that is the point.
124 GATE_HOST=${EITRI_GATE%%:*} 146 GATE_HOST=${EITRI_GATE%%:*}
125 GATE_PORT=${EITRI_GATE##*:} 147 GATE_PORT=${EITRI_GATE##*:}
126 [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 148 [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22
@@ -135,4 +157,4 @@ exec ssh \
135 -o StrictHostKeyChecking=yes \ 157 -o StrictHostKeyChecking=yes \
136 -o "UserKnownHostsFile=$KNOWN_HOSTS" \ 158 -o "UserKnownHostsFile=$KNOWN_HOSTS" \
137 -i "$KEY" \ 159 -i "$KEY" \
138 "ubuntu@$VM" "$@" 160 "ubuntu@$TARGET" "$@"
internal/agent/syncclient/client_test.go
Old New
@@ -182,7 +182,7 @@ func (h *serverHarness) stop() {
182 182
183 // enroll creates a host and returns a valid credential for it. 183 // enroll creates a host and returns a valid credential for it.
184 func (h *serverHarness) enroll() (hostID, cred string) { 184 func (h *serverHarness) enroll() (hostID, cred string) {
185 tok, _ := h.st.CreateEnrollmentToken() 185 tok, _ := h.st.CreateEnrollmentToken(store.DefaultTenant)
186 host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 186 host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
187 require.NoError(h.t, err) 187 require.NoError(h.t, err)
188 return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now()) 188 return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now())
internal/mcpserver/api_test.go
Old New
@@ -152,22 +152,24 @@ func TestMintUserCert(t *testing.T) {
152 require.NoError(t, cert.SignCert(rand.Reader, caSigner)) 152 require.NoError(t, cert.SignCert(rand.Reader, caSigner))
153 json.NewEncoder(w).Encode(map[string]string{ 153 json.NewEncoder(w).Encode(map[string]string{
154 "certificate": string(ssh.MarshalAuthorizedKey(cert)), 154 "certificate": string(ssh.MarshalAuthorizedKey(cert)),
155 "tenant": "default",
155 }) 156 })
156 }) 157 })
157 158
158 got, err := c.MintUserCert(t.Context(), userPub) 159 got, tenant, err := c.MintUserCert(t.Context(), userPub)
159 require.NoError(t, err) 160 require.NoError(t, err)
160 require.NotNil(t, got) 161 require.NotNil(t, got)
161 assert.Equal(t, uint64(1), got.Serial) 162 assert.Equal(t, uint64(1), got.Serial)
162 assert.Equal(t, []string{"ubuntu"}, got.ValidPrincipals) 163 assert.Equal(t, []string{"ubuntu"}, got.ValidPrincipals)
163 assert.Equal(t, userPub.Marshal(), got.Key.Marshal()) 164 assert.Equal(t, userPub.Marshal(), got.Key.Marshal())
165 assert.Equal(t, "default", tenant, "mint must return the response tenant")
164 } 166 }
165 167
166 func TestMintUserCertErrorDoesNotLeakToken(t *testing.T) { 168 func TestMintUserCertErrorDoesNotLeakToken(t *testing.T) {
167 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { 169 c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
168 http.Error(w, "internal error", http.StatusInternalServerError) 170 http.Error(w, "internal error", http.StatusInternalServerError)
169 }) 171 })
170 _, err := c.MintUserCert(t.Context(), genTestKey(t)) 172 _, _, err := c.MintUserCert(t.Context(), genTestKey(t))
171 require.Error(t, err) 173 require.Error(t, err)
172 assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors") 174 assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors")
173 } 175 }
@@ -179,6 +181,6 @@ func TestMintUserCertRejectsNonCertResponse(t *testing.T) {
179 "certificate": string(ssh.MarshalAuthorizedKey(notACert)), 181 "certificate": string(ssh.MarshalAuthorizedKey(notACert)),
180 }) 182 })
181 }) 183 })
182 _, err := c.MintUserCert(t.Context(), genTestKey(t)) 184 _, _, err := c.MintUserCert(t.Context(), genTestKey(t))
183 require.Error(t, err) 185 require.Error(t, err)
184 } 186 }
internal/mcpserver/gateauth.go
Old New
@@ -5,6 +5,7 @@ import (
5 "context" 5 "context"
6 "crypto/ed25519" 6 "crypto/ed25519"
7 "crypto/rand" 7 "crypto/rand"
8 "errors"
8 "fmt" 9 "fmt"
9 "sync" 10 "sync"
10 "time" 11 "time"
@@ -19,7 +20,7 @@ import (
19 // server. 20 // server.
20 type CertAuthority interface { 21 type CertAuthority interface {
21 FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) 22 FetchSSHCA(ctx context.Context) (ssh.PublicKey, error)
22 MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) 23 MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error)
23 } 24 }
24 25
25 // GateAuth is a concurrency-safe credential cache for authenticating to the 26 // GateAuth is a concurrency-safe credential cache for authenticating to the
@@ -36,6 +37,7 @@ type GateAuth struct {
36 ca ssh.PublicKey // eitri SSH CA; fetched lazily, once 37 ca ssh.PublicKey // eitri SSH CA; fetched lazily, once
37 cert *ssh.Certificate 38 cert *ssh.Certificate
38 certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert 39 certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert
40 tenant string // minting principal's tenant; set alongside cert
39 } 41 }
40 42
41 // NewGateAuth constructs a GateAuth backed by api. If now is nil, time.Now 43 // NewGateAuth constructs a GateAuth backed by api. If now is nil, time.Now
@@ -68,7 +70,7 @@ func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) {
68 // minting so concurrent Signer callers reuse one in-flight request 70 // minting so concurrent Signer callers reuse one in-flight request
69 // rather than stampeding the CA with duplicate mints. Do not "fix" this 71 // rather than stampeding the CA with duplicate mints. Do not "fix" this
70 // into a per-call unlock — that reintroduces a thundering herd. 72 // into a per-call unlock — that reintroduces a thundering herd.
71 cert, err := g.api.MintUserCert(ctx, g.ephemeral.PublicKey()) 73 cert, tenant, err := g.api.MintUserCert(ctx, g.ephemeral.PublicKey())
72 if err != nil { 74 if err != nil {
73 return nil, fmt.Errorf("minting user certificate: %w", err) 75 return nil, fmt.Errorf("minting user certificate: %w", err)
74 } 76 }
@@ -78,11 +80,27 @@ func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) {
78 } 80 }
79 g.cert = cert 81 g.cert = cert
80 g.certSigner = certSigner 82 g.certSigner = certSigner
83 g.tenant = tenant
81 } 84 }
82 85
83 return g.certSigner, nil 86 return g.certSigner, nil
84 } 87 }
85 88
89 // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>",
90 // the form the gate resolves and the VM's host-cert principal matches. Minting
91 // is ensured first (the tenant rides the mint response).
92 func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, error) {
93 if _, err := g.Signer(ctx); err != nil {
94 return "", err
95 }
96 g.mu.Lock()
97 defer g.mu.Unlock()
98 if g.tenant == "" {
99 return "", errors.New("eitri server did not return a tenant at cert mint (pre-tenancy server?)")
100 }
101 return g.tenant + "." + vmName, nil
102 }
103
86 // needsMintLocked reports whether the cached cert is absent or expires 104 // needsMintLocked reports whether the cached cert is absent or expires
87 // within a minute of now(). Callers must hold g.mu. 105 // within a minute of now(). Callers must hold g.mu.
88 func (g *GateAuth) needsMintLocked() bool { 106 func (g *GateAuth) needsMintLocked() bool {
internal/mcpserver/gateauth_test.go
Old New
@@ -48,7 +48,7 @@ func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, erro
48 return f.caSigner.PublicKey(), nil 48 return f.caSigner.PublicKey(), nil
49 } 49 }
50 50
51 func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) { 51 func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) {
52 f.mu.Lock() 52 f.mu.Lock()
53 validBefore := f.nextValidBefore 53 validBefore := f.nextValidBefore
54 f.mintCertCalls++ 54 f.mintCertCalls++
@@ -56,7 +56,7 @@ func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey)
56 f.mu.Unlock() 56 f.mu.Unlock()
57 57
58 if mintErr != nil { 58 if mintErr != nil {
59 return nil, mintErr 59 return nil, "", mintErr
60 } 60 }
61 61
62 cert := &ssh.Certificate{ 62 cert := &ssh.Certificate{
@@ -67,9 +67,9 @@ func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey)
67 ValidBefore: validBefore, 67 ValidBefore: validBefore,
68 } 68 }
69 if err := cert.SignCert(rand.Reader, f.caSigner); err != nil { 69 if err := cert.SignCert(rand.Reader, f.caSigner); err != nil {
70 return nil, err 70 return nil, "", err
71 } 71 }
72 return cert, nil 72 return cert, "default", nil
73 } 73 }
74 74
75 func (f *fakeCertAuthority) setNextValidBefore(v uint64) { 75 func (f *fakeCertAuthority) setNextValidBefore(v uint64) {
internal/mcpserver/sshrun.go
Old New
@@ -19,11 +19,14 @@ import (
19 // outputCap bounds captured exec/file bytes returned to the model. 19 // outputCap bounds captured exec/file bytes returned to the model.
20 const outputCap = 1 << 20 // 1 MiB 20 const outputCap = 1 << 20 // 1 MiB
21 21
22 // GateCredentials provides the cert-backed client signer and the CA host-key 22 // GateCredentials provides the cert-backed client signer, the CA host-key
23 // verifier the Runner authenticates with. *GateAuth satisfies it. 23 // verifier, and the gate connect name the Runner authenticates with. *GateAuth
24 // satisfies it.
24 type GateCredentials interface { 25 type GateCredentials interface {
25 Signer(ctx context.Context) (ssh.Signer, error) 26 Signer(ctx context.Context) (ssh.Signer, error)
26 HostKeyCallback() ssh.HostKeyCallback 27 HostKeyCallback() ssh.HostKeyCallback
28 // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name.
29 ConnectName(ctx context.Context, vmName string) (string, error)
27 } 30 }
28 31
29 // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate. 32 // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate.
@@ -44,14 +47,23 @@ type ExecResult struct {
44 // Runner executes commands and transfers files on VMs over SSH, reaching each 47 // Runner executes commands and transfers files on VMs over SSH, reaching each
45 // VM by NAME through eitri's SSH-CA jump gate. There is no TOFU/known_hosts: 48 // VM by NAME through eitri's SSH-CA jump gate. There is no TOFU/known_hosts:
46 // both hops are verified against the eitri CA — the gate presents a CA-signed 49 // both hops are verified against the eitri CA — the gate presents a CA-signed
47 // host cert for its own domain, the VM a CA-signed host cert for its name — and 50 // host cert for its own domain, the VM a CA-signed host cert for its
48 // the client authenticates with a short-lived CA-signed user cert. 51 // <tenant>.<name> connect name — and the client authenticates with a
52 // short-lived CA-signed user cert.
49 type Runner struct { 53 type Runner struct {
50 cfg RunnerConfig 54 cfg RunnerConfig
51 } 55 }
52 56
53 func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} } 57 func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} }
54 58
59 // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>",
60 // the form the gate resolves and the VM's host-cert principal matches. It
61 // delegates to the gate credentials so the Tools layer can build a correct
62 // `ssh -J` hint without dialing.
63 func (r *Runner) ConnectName(ctx context.Context, vmName string) (string, error) {
64 return r.cfg.Auth.ConnectName(ctx, vmName)
65 }
66
55 // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero 67 // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero
56 // remote exit is NOT an error — it's in ExitCode. 68 // remote exit is NOT an error — it's in ExitCode.
57 func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) { 69 func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) {
@@ -94,16 +106,23 @@ func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Dura
94 106
95 // dial reaches the VM named vmName through the eitri SSH-CA gate: a client 107 // dial reaches the VM named vmName through the eitri SSH-CA gate: a client
96 // handshake with the gate (CA-verified host cert, CA-signed user cert), a 108 // handshake with the gate (CA-verified host cert, CA-signed user cert), a
97 // direct-tcpip tunnel to <vmName>:22 (the only port the gate permits), then a 109 // direct-tcpip tunnel to <tenant>.<vmName>:22 (the only port the gate permits),
98 // second handshake directly with the VM's sshd over that tunnel. Error messages 110 // then a second handshake directly with the VM's sshd over that tunnel. Error
99 // distinguish gate-unreachable/gate-handshake from VM-unreachable/VM-handshake. 111 // messages distinguish gate-unreachable/gate-handshake from
100 // The caller must Close the returned *ssh.Client. 112 // VM-unreachable/VM-handshake. The caller must Close the returned *ssh.Client.
101 func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { 113 func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) {
102 signer, err := r.cfg.Auth.Signer(ctx) 114 signer, err := r.cfg.Auth.Signer(ctx)
103 if err != nil { 115 if err != nil {
104 return nil, fmt.Errorf("minting gate credentials: %w", err) 116 return nil, fmt.Errorf("minting gate credentials: %w", err)
105 } 117 }
106 hostCB := r.cfg.Auth.HostKeyCallback() 118 hostCB := r.cfg.Auth.HostKeyCallback()
119 // The gate resolves <tenant>.<name> and each VM's host-cert principal is that
120 // same namespaced name, so both the tunnel target and the VM host-cert
121 // verification address use it.
122 target, err := r.cfg.Auth.ConnectName(ctx, vmName)
123 if err != nil {
124 return nil, fmt.Errorf("building gate connect name: %w", err)
125 }
107 126
108 // Both hops share the same client config: the same CA-signed user cert 127 // Both hops share the same client config: the same CA-signed user cert
109 // authenticates to the gate and to the VM, and the same callback verifies 128 // authenticates to the gate and to the VM, and the same callback verifies
@@ -131,17 +150,18 @@ func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) {
131 } 150 }
132 gateClient := ssh.NewClient(gnc, gchans, greqs) 151 gateClient := ssh.NewClient(gnc, gchans, greqs)
133 152
134 // VM hop. Open the direct-tcpip tunnel to <vmName>:22 through the gate. A 153 // VM hop. Open the direct-tcpip tunnel to <tenant>.<vmName>:22 through the
135 // connection failure here is expected during the vm_create pre-sshd boot 154 // gate. A connection failure here is expected during the vm_create pre-sshd
136 // window (the guest hasn't started sshd yet), and the caller retries. 155 // boot window (the guest hasn't started sshd yet), and the caller retries.
137 vmAddr := vmName + ":22" 156 vmAddr := target + ":22"
138 vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr) 157 vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr)
139 if err != nil { 158 if err != nil {
140 gateClient.Close() 159 gateClient.Close()
141 return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err) 160 return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err)
142 } 161 }
143 // Verify the VM's host cert under <vmName>:22: its host-cert principal is the 162 // Verify the VM's host cert under <tenant>.<vmName>:22: its host-cert
144 // VM name, so ssh.CertChecker matches on the host portion of this address. 163 // principal is that namespaced name, so ssh.CertChecker matches on the host
164 // portion of this address.
145 nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf) 165 nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf)
146 if err != nil { 166 if err != nil {
147 gateClient.Close() 167 gateClient.Close()
internal/mcpserver/sshrun_test.go
Old New
@@ -214,6 +214,9 @@ type fakeGateCreds struct {
214 214
215 func (f fakeGateCreds) Signer(context.Context) (ssh.Signer, error) { return f.signer, nil } 215 func (f fakeGateCreds) Signer(context.Context) (ssh.Signer, error) { return f.signer, nil }
216 func (f fakeGateCreds) HostKeyCallback() ssh.HostKeyCallback { return f.hostCB } 216 func (f fakeGateCreds) HostKeyCallback() ssh.HostKeyCallback { return f.hostCB }
217 func (f fakeGateCreds) ConnectName(_ context.Context, vmName string) (string, error) {
218 return "default." + vmName, nil
219 }
217 220
218 // ── tests ──────────────────────────────────────────────────────────────────── 221 // ── tests ────────────────────────────────────────────────────────────────────
219 222
@@ -222,7 +225,7 @@ func TestExecThroughGate(t *testing.T) {
222 ga := NewGateAuth(fake, nil) 225 ga := NewGateAuth(fake, nil)
223 ca := fake.caSigner 226 ca := fake.caSigner
224 227
225 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "testvm"), ca.PublicKey(), "hi\n", 0) 228 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0)
226 // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>, 229 // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>,
227 // verifies it under that host. 230 // verifies it under that host.
228 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) 231 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
@@ -243,7 +246,7 @@ func TestExecVMForeignCAHostCertRejected(t *testing.T) {
243 // still trusts the real CA, so the gate hop and client auth both succeed and 246 // still trusts the real CA, so the gate hop and client auth both succeed and
244 // this isolates the VM host-cert rejection. 247 // this isolates the VM host-cert rejection.
245 foreignCA := newSigner(t) 248 foreignCA := newSigner(t)
246 vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "testvm"), ca.PublicKey(), "hi\n", 0) 249 vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "default.testvm"), ca.PublicKey(), "hi\n", 0)
247 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) 250 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
248 251
249 r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"}) 252 r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"})
@@ -257,7 +260,7 @@ func TestExecGateRejectsNonCAUserKey(t *testing.T) {
257 ga := NewGateAuth(fake, nil) 260 ga := NewGateAuth(fake, nil)
258 ca := fake.caSigner 261 ca := fake.caSigner
259 262
260 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "testvm"), ca.PublicKey(), "hi\n", 0) 263 vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0)
261 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) 264 gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr)
262 265
263 // A plain (non-cert) client key: the gate only accepts CA-signed user certs, 266 // A plain (non-cert) client key: the gate only accepts CA-signed user certs,
internal/mcpserver/tools.go
Old New
@@ -24,6 +24,9 @@ type runner interface {
24 Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) 24 Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error)
25 WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error 25 WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error
26 ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error) 26 ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error)
27 // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name,
28 // so the ssh_command hint dials the form the gate actually accepts.
29 ConnectName(ctx context.Context, vmName string) (string, error)
27 } 30 }
28 31
29 // API is the shared eitri API client plus the one piece of MCP placement 32 // API is the shared eitri API client plus the one piece of MCP placement
@@ -153,7 +156,7 @@ func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error
153 return out, err 156 return out, err
154 } 157 }
155 out.IP = ip 158 out.IP = ip
156 out.SSHCommand = t.sshCommand(created.Name) 159 out.SSHCommand = t.sshCommand(ctx, created.Name)
157 // "ready" means cloud-hypervisor is up and the IP is ALLOCATED — NOT that the 160 // "ready" means cloud-hypervisor is up and the IP is ALLOCATED — NOT that the
158 // guest has booted Linux, brought up its NIC, and started sshd. The first SSH 161 // guest has booted Linux, brought up its NIC, and started sshd. The first SSH
159 // dials can therefore hit "no route to host"/"connection refused" while the 162 // dials can therefore hit "no route to host"/"connection refused" while the
@@ -254,11 +257,21 @@ func (t *Tools) resolveHost(ctx context.Context, name string) (string, error) {
254 return h.ID, nil 257 return h.ID, nil
255 } 258 }
256 259
257 func (t *Tools) sshCommand(name string) string { 260 func (t *Tools) sshCommand(ctx context.Context, name string) string {
258 if t.Gate != "" { 261 if t.Gate == "" {
259 return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, name) 262 // No gate: reach the VM directly by name/IP (a different deployment
263 // shape). Unchanged.
264 return fmt.Sprintf("ssh %s@%s", t.VMUser, name)
265 }
266 // The gate rejects a bare VM name — VMs are dialed by their <tenant>.<name>
267 // connect name (also the VM's host-cert principal), resolved via the same
268 // gate auth the Runner uses. If it can't be resolved (e.g. a mint failure)
269 // omit the hint rather than emit the bare form the gate would reject.
270 target, err := t.Runner.ConnectName(ctx, name)
271 if err != nil {
272 return ""
260 } 273 }
261 return fmt.Sprintf("ssh %s@%s", t.VMUser, name) 274 return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, target)
262 } 275 }
263 276
264 // ── vm_list / vm_info ──────────────────────────────────────────────────────── 277 // ── vm_list / vm_info ────────────────────────────────────────────────────────
@@ -293,7 +306,7 @@ func (t *Tools) VMInfo(ctx context.Context, in VMInfoIn) (VMInfoOut, error) {
293 } 306 }
294 out := VMInfoOut{VM: vm} 307 out := VMInfoOut{VM: vm}
295 if vm.Lifecycle == "ready" { 308 if vm.Lifecycle == "ready" {
296 out.SSHCommand = t.sshCommand(vm.Name) 309 out.SSHCommand = t.sshCommand(ctx, vm.Name)
297 } 310 }
298 return out, nil 311 return out, nil
299 } 312 }
internal/mcpserver/tools_test.go
Old New
@@ -93,6 +93,12 @@ func (f *fakeRunner) ReadFile(ctx context.Context, vmName, p string) ([]byte, bo
93 return d, false, nil 93 return d, false, nil
94 } 94 }
95 95
96 // ConnectName mirrors GateAuth: the gate connect name is <tenant>.<vmName>, and
97 // the fake's tenant is "default" (as the fake CertAuthority returns).
98 func (f *fakeRunner) ConnectName(ctx context.Context, vmName string) (string, error) {
99 return "default." + vmName, nil
100 }
101
96 func newTestTools(api *fakeToolsAPI, r *fakeRunner) *Tools { 102 func newTestTools(api *fakeToolsAPI, r *fakeRunner) *Tools {
97 return &Tools{ 103 return &Tools{
98 API: api, Runner: r, 104 API: api, Runner: r,
@@ -112,7 +118,8 @@ func TestCreateWaitsForReadyAndCloudInit(t *testing.T) {
112 assert.Equal(t, "new1", out.ID) 118 assert.Equal(t, "new1", out.ID)
113 assert.Equal(t, "10.77.1.9", out.IP) 119 assert.Equal(t, "10.77.1.9", out.IP)
114 assert.Contains(t, out.SSHCommand, "-J localhost:2223") 120 assert.Contains(t, out.SSHCommand, "-J localhost:2223")
115 assert.Contains(t, out.SSHCommand, "ubuntu@claude-abc") 121 assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc",
122 "hint must dial the namespaced <tenant>.<name>, not the bare name the gate rejects")
116 123
117 require.Len(t, api.created, 1) 124 require.Len(t, api.created, 1)
118 req := api.created[0] 125 req := api.created[0]
@@ -283,6 +290,29 @@ func TestReadFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
283 assert.Empty(t, run.reads, "runner must not be called on a non-ready VM") 290 assert.Empty(t, run.reads, "runner must not be called on a non-ready VM")
284 } 291 }
285 292
293 // TestVMInfoHintNamespacesConnectName pins that vm_info's ssh_command hint dials
294 // the gate by the <tenant>.<name> connect name (the gate rejects a bare name),
295 // and that the gateless deployment still emits the plain `ssh <user>@<name>`.
296 func TestVMInfoHintNamespacesConnectName(t *testing.T) {
297 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
298
299 // Gate configured: hint must be the namespaced -J form.
300 tl := newTestTools(api, &fakeRunner{})
301 out, err := tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
302 require.NoError(t, err)
303 assert.Contains(t, out.SSHCommand, "-J localhost:2223")
304 assert.Contains(t, out.SSHCommand, "ubuntu@default.web-1",
305 "gate hint must dial <tenant>.<name>, not the bare name")
306 assert.NotContains(t, out.SSHCommand, "ubuntu@web-1",
307 "hint must not contain the bare name the gate rejects")
308
309 // Gateless deployment: the direct `ssh <user>@<name>` form is unchanged.
310 tl.Gate = ""
311 out, err = tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
312 require.NoError(t, err)
313 assert.Equal(t, "ssh ubuntu@web-1", out.SSHCommand)
314 }
315
286 func TestDestroyRequiresExactMatch(t *testing.T) { 316 func TestDestroyRequiresExactMatch(t *testing.T) {
287 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1"}}} 317 api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1"}}}
288 tl := newTestTools(api, &fakeRunner{}) 318 tl := newTestTools(api, &fakeRunner{})
internal/server/api/api.go
Old New
@@ -155,7 +155,11 @@ func (a *API) adminAuth(next http.Handler) http.Handler {
155 http.Error(w, "unauthorized", http.StatusUnauthorized) 155 http.Error(w, "unauthorized", http.StatusUnauthorized)
156 return 156 return
157 } 157 }
158 next.ServeHTTP(w, r) 158 // The token maps to the bootstrap principal: the default tenant's
159 // operator, with the fleet bit. Multi-user auth replaces this constant
160 // with a real token→principal lookup; nothing downstream changes.
161 p := Principal{Tenant: store.DefaultTenant, Fleet: true}
162 next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p)))
159 }) 163 })
160 } 164 }
161 165
@@ -248,7 +252,10 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) {
248 } 252 }
249 253
250 func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) { 254 func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) {
251 tok, err := a.st.CreateEnrollmentToken() 255 if !a.requireFleet(w, r) {
256 return
257 }
258 tok, err := a.st.CreateEnrollmentToken(principalFromContext(r).Tenant)
252 if err != nil { 259 if err != nil {
253 http.Error(w, "internal error", http.StatusInternalServerError) 260 http.Error(w, "internal error", http.StatusInternalServerError)
254 return 261 return
@@ -328,7 +335,7 @@ func (a *API) fetchStates(ids ...[]string) map[string]regState {
328 335
329 // snapshotHosts builds the wire host list (durable host rows merged with live 336 // snapshotHosts builds the wire host list (durable host rows merged with live
330 // registry state and server-computed allocation) for GET /hosts. 337 // registry state and server-computed allocation) for GET /hosts.
331 func (a *API) snapshotHosts() ([]types.Host, error) { 338 func (a *API) snapshotHosts(p Principal) ([]types.Host, error) {
332 // Single-tx read: hosts and alloc must not mix two epochs (same property 339 // Single-tx read: hosts and alloc must not mix two epochs (same property
333 // the SSE stream needs; the unused vms read is cheap on these small 340 // the SSE stream needs; the unused vms read is cheap on these small
334 // control-plane tables). 341 // control-plane tables).
@@ -336,6 +343,7 @@ func (a *API) snapshotHosts() ([]types.Host, error) {
336 if err != nil { 343 if err != nil {
337 return nil, err 344 return nil, err
338 } 345 }
346 hosts = filterHosts(p, hosts)
339 return a.buildHostResponses(hosts, alloc, a.fetchStates(hostIDs(hosts))), nil 347 return a.buildHostResponses(hosts, alloc, a.fetchStates(hostIDs(hosts))), nil
340 } 348 }
341 349
@@ -369,7 +377,7 @@ func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Allo
369 } 377 }
370 378
371 func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) { 379 func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) {
372 out, err := a.snapshotHosts() 380 out, err := a.snapshotHosts(principalFromContext(r))
373 if err != nil { 381 if err != nil {
374 http.Error(w, "internal error", http.StatusInternalServerError) 382 http.Error(w, "internal error", http.StatusInternalServerError)
375 return 383 return
@@ -438,11 +446,12 @@ func deriveLifecycle(vm store.VM, actualPower, phase string) string {
438 // snapshotVMs builds the wire VM list (durable VM rows merged with live 446 // snapshotVMs builds the wire VM list (durable VM rows merged with live
439 // actual-state from the registry). Used by GET /vms; the SSE stream uses 447 // actual-state from the registry). Used by GET /vms; the SSE stream uses
440 // buildVMResponses over a single-tx store.Snapshot instead. 448 // buildVMResponses over a single-tx store.Snapshot instead.
441 func (a *API) snapshotVMs() ([]types.VM, error) { 449 func (a *API) snapshotVMs(p Principal) ([]types.VM, error) {
442 vms, err := a.st.ListVMs() 450 vms, err := a.st.ListVMs()
443 if err != nil { 451 if err != nil {
444 return nil, err 452 return nil, err
445 } 453 }
454 vms = filterVMs(p, vms)
446 return a.buildVMResponses(vms, a.fetchStates(vmHostIDs(vms))), nil 455 return a.buildVMResponses(vms, a.fetchStates(vmHostIDs(vms))), nil
447 } 456 }
448 457
@@ -476,7 +485,7 @@ func (a *API) buildVMResponses(vms []store.VM, states map[string]regState) []typ
476 } 485 }
477 486
478 func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { 487 func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) {
479 out, err := a.snapshotVMs() 488 out, err := a.snapshotVMs(principalFromContext(r))
480 if err != nil { 489 if err != nil {
481 http.Error(w, "internal error", http.StatusInternalServerError) 490 http.Error(w, "internal error", http.StatusInternalServerError)
482 return 491 return
@@ -560,6 +569,24 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
560 return 569 return
561 } 570 }
562 571
572 // Tenant gate: you may only place VMs on hosts in your tenant. The host
573 // read also feeds the namespaced host-cert principal below. CreateVM
574 // re-checks host status in-tx; this pre-read is the AUTHZ point.
575 host, err := a.st.GetHost(req.HostID)
576 if err != nil {
577 if errors.Is(err, sql.ErrNoRows) {
578 http.Error(w, "unknown host_id", http.StatusBadRequest)
579 return
580 }
581 http.Error(w, "internal error", http.StatusInternalServerError)
582 return
583 }
584 if !mayActAs(principalFromContext(r), host.Tenant) {
585 // Indistinguishable from absent: don't confirm foreign hosts exist.
586 http.Error(w, "unknown host_id", http.StatusBadRequest)
587 return
588 }
589
563 // Install the SSH key into user-supplied cloud-init. When only one of the 590 // Install the SSH key into user-supplied cloud-init. When only one of the
564 // two is set the seed builder handles it (verbatim user-data, or the 591 // two is set the seed builder handles it (verbatim user-data, or the
565 // generated default template); it's the BOTH case that used to silently 592 // generated default template); it's the BOTH case that used to silently
@@ -605,12 +632,16 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
605 } 632 }
606 633
607 // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed 634 // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed
608 // host cert (principal = VM name) once at create, so the VM presents a 635 // host cert once at create, so the VM presents a verifiable host key clients
609 // verifiable host key clients accept via `@cert-authority` — no TOFU, no 636 // accept via `@cert-authority` — no TOFU, no host-key-changed warnings when
610 // host-key-changed warnings when names/IPs recycle. The private key is 637 // names/IPs recycle. The private key is WRITE-ONLY: stored, shipped to the
611 // WRITE-ONLY: stored, shipped to the guest via seed, never echoed or logged. 638 // guest via seed, never echoed or logged.
639 //
640 // The cert principal is <tenant>.<name>: under (tenant,name) uniqueness a bare
641 // name is no longer unique across tenants (spec §F6), so clients dial and
642 // verify VMs by their namespaced connect name.
612 if a.hostCerts != nil { 643 if a.hostCerts != nil {
613 keyPEM, cert, err := a.hostCerts.MintHostCert(req.Name) 644 keyPEM, cert, err := a.hostCerts.MintHostCert(host.Tenant + "." + req.Name)
614 if err != nil { 645 if err != nil {
615 http.Error(w, "internal error", http.StatusInternalServerError) 646 http.Error(w, "internal error", http.StatusInternalServerError)
616 return 647 return
@@ -640,17 +671,22 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
640 } 671 }
641 672
642 // mutateVM implements the choreography shared by every VM mutation endpoint 673 // mutateVM implements the choreography shared by every VM mutation endpoint
643 // (patch/delete/restore): run mutate; a sql.ErrNoRows becomes the caller's 674 // (patch/delete/restore): check ownership, then run mutate; a sql.ErrNoRows
644 // not-found response, any other error a generic 500. On success the row 675 // becomes the caller's not-found response, any other error a generic 500. On
645 // always survives the mutation (power change, tombstone, and restore all 676 // success an audit row is appended and the host's desired-state stream poked
646 // keep it), so it is scanned for name/host: a best-effort audit row is 677 // from the pre-read row (Name/HostID are immutable under every mutation, so
647 // appended and the host's desired-state stream poked (a lookup miss is 678 // no post-mutate re-fetch is needed — audit/poke fire even if the row
648 // non-fatal — the mutation already succeeded). Finally SSE watchers are 679 // vanishes post-mutate, since the mutation itself already succeeded).
649 // notified and the response is written as 204, unconditionally. 680 // Finally SSE watchers are notified and the response is written as 204,
650 func (a *API) mutateVM(w http.ResponseWriter, id string, mutate func(id string) error, 681 // unconditionally.
682 //
683 // Ownership is gated before any mutation runs: a foreign-tenant VM answers
684 // exactly like a missing one — existence is not leaked across tenants.
685 func (a *API) mutateVM(w http.ResponseWriter, r *http.Request, id string, mutate func(id string) error,
651 notFoundMsg string, notFoundStatus int, 686 notFoundMsg string, notFoundStatus int,
652 auditAction string, auditDetail func(vm store.VM) map[string]string) { 687 auditAction string, auditDetail func(vm store.VM) map[string]string) {
653 if err := mutate(id); err != nil { 688 vm, err := a.st.GetVM(id)
689 if err != nil {
654 if errors.Is(err, sql.ErrNoRows) { 690 if errors.Is(err, sql.ErrNoRows) {
655 http.Error(w, notFoundMsg, notFoundStatus) 691 http.Error(w, notFoundMsg, notFoundStatus)
656 return 692 return
@@ -658,10 +694,20 @@ func (a *API) mutateVM(w http.ResponseWriter, id string, mutate func(id string)
658 http.Error(w, "internal error", http.StatusInternalServerError) 694 http.Error(w, "internal error", http.StatusInternalServerError)
659 return 695 return
660 } 696 }
661 if vm, ok := a.vmByID(id); ok { 697 if !mayActAs(principalFromContext(r), vm.Tenant) {
662 a.audit(auditAction, auditDetail(vm)) 698 http.Error(w, notFoundMsg, notFoundStatus)
663 a.hub.Poke(vm.HostID) 699 return
700 }
701 if err := mutate(id); err != nil {
702 if errors.Is(err, sql.ErrNoRows) {
703 http.Error(w, notFoundMsg, notFoundStatus)
704 return
705 }
706 http.Error(w, "internal error", http.StatusInternalServerError)
707 return
664 } 708 }
709 a.audit(auditAction, auditDetail(vm))
710 a.hub.Poke(vm.HostID)
665 a.notif.notify() 711 a.notif.notify()
666 w.WriteHeader(http.StatusNoContent) 712 w.WriteHeader(http.StatusNoContent)
667 } 713 }
@@ -676,7 +722,7 @@ func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) {
676 http.Error(w, "power_state must be running or stopped", http.StatusBadRequest) 722 http.Error(w, "power_state must be running or stopped", http.StatusBadRequest)
677 return 723 return
678 } 724 }
679 a.mutateVM(w, id, func(id string) error { return a.st.SetVMPower(id, req.PowerState) }, 725 a.mutateVM(w, r, id, func(id string) error { return a.st.SetVMPower(id, req.PowerState) },
680 "not found", http.StatusNotFound, 726 "not found", http.StatusNotFound,
681 "vm.power", func(vm store.VM) map[string]string { 727 "vm.power", func(vm store.VM) map[string]string {
682 return map[string]string{"vm_id": id, "name": vm.Name, "power": req.PowerState} 728 return map[string]string{"vm_id": id, "name": vm.Name, "power": req.PowerState}
@@ -685,7 +731,7 @@ func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) {
685 731
686 func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { 732 func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) {
687 id := r.PathValue("id") 733 id := r.PathValue("id")
688 a.mutateVM(w, id, a.st.TombstoneVM, 734 a.mutateVM(w, r, id, a.st.TombstoneVM,
689 "not found", http.StatusNotFound, 735 "not found", http.StatusNotFound,
690 "vm.delete", func(vm store.VM) map[string]string { 736 "vm.delete", func(vm store.VM) map[string]string {
691 return map[string]string{"vm_id": id, "name": vm.Name} 737 return map[string]string{"vm_id": id, "name": vm.Name}
@@ -698,7 +744,7 @@ func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) {
698 // converges it back toward its power_state — so the server only pokes. 744 // converges it back toward its power_state — so the server only pokes.
699 func (a *API) handleRestoreVM(w http.ResponseWriter, r *http.Request) { 745 func (a *API) handleRestoreVM(w http.ResponseWriter, r *http.Request) {
700 id := r.PathValue("id") 746 id := r.PathValue("id")
701 a.mutateVM(w, id, a.st.RestoreVM, 747 a.mutateVM(w, r, id, a.st.RestoreVM,
702 "vm not restorable (already destroyed or not deleted)", http.StatusConflict, 748 "vm not restorable (already destroyed or not deleted)", http.StatusConflict,
703 "vm.restore", func(vm store.VM) map[string]string { 749 "vm.restore", func(vm store.VM) map[string]string {
704 return map[string]string{"vm_id": id, "name": vm.Name} 750 return map[string]string{"vm_id": id, "name": vm.Name}
internal/server/api/client/client.go
Old New
@@ -168,20 +168,21 @@ func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
168 } 168 }
169 169
170 // MintUserCert asks the eitri server to mint a short-lived SSH user 170 // MintUserCert asks the eitri server to mint a short-lived SSH user
171 // certificate for pub, signed by the server's CA. 171 // certificate for pub, signed by the server's CA. It returns the cert and the
172 func (c *Client) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) { 172 // minting principal's tenant: clients dial VMs as <tenant>.<name>.
173 func (c *Client) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) {
173 req := types.SSHCertRequest{PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))} 174 req := types.SSHCertRequest{PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))}
174 var out types.SSHCertResponse 175 var out types.SSHCertResponse
175 if err := c.do(ctx, http.MethodPost, "/api/v1/ssh-certs", req, &out); err != nil { 176 if err := c.do(ctx, http.MethodPost, "/api/v1/ssh-certs", req, &out); err != nil {
176 return nil, err 177 return nil, "", err
177 } 178 }
178 parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.Certificate)) 179 parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.Certificate))
179 if err != nil { 180 if err != nil {
180 return nil, fmt.Errorf("client: parsing minted certificate: %w", err) 181 return nil, "", fmt.Errorf("client: parsing minted certificate: %w", err)
181 } 182 }
182 cert, ok := parsed.(*ssh.Certificate) 183 cert, ok := parsed.(*ssh.Certificate)
183 if !ok { 184 if !ok {
184 return nil, errors.New("client: server response is not an SSH certificate") 185 return nil, "", errors.New("client: server response is not an SSH certificate")
185 } 186 }
186 return cert, nil 187 return cert, out.Tenant, nil
187 } 188 }
internal/server/api/console.go
Old New
@@ -31,6 +31,9 @@ const consoleOpenTimeout = 10 * time.Second
31 // admin-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) — 31 // admin-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) —
32 // the admin token never appears in a URL. 32 // the admin token never appears in a URL.
33 func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) { 33 func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) {
34 // The console WS is ticket-authed and tickets carry no principal yet —
35 // per-tenant console isolation is a recorded tenant-#2 blocker (spec
36 // §Deferred), stated here rather than implied.
34 if !a.tickets.consume(r.URL.Query().Get("ticket")) { 37 if !a.tickets.consume(r.URL.Query().Get("ticket")) {
35 http.Error(w, "unauthorized", http.StatusUnauthorized) 38 http.Error(w, "unauthorized", http.StatusUnauthorized)
36 return 39 return
internal/server/api/events.go
Old New
@@ -24,6 +24,9 @@ import (
24 // is gone with the box), reclaiming the CIDR without waiting for a drain that 24 // is gone with the box), reclaiming the CIDR without waiting for a drain that
25 // can never happen. 25 // can never happen.
26 func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { 26 func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
27 if !a.requireFleet(w, r) {
28 return
29 }
27 id := r.PathValue("id") 30 id := r.PathValue("id")
28 31
29 if forceParam(r) { 32 if forceParam(r) {
@@ -77,6 +80,9 @@ func forceParam(r *http.Request) bool {
77 // session is closed by syncsvc within one report tick; the host stays dark 80 // session is closed by syncsvc within one report tick; the host stays dark
78 // until the operator re-enrolls it with a fresh join blob. 81 // until the operator re-enrolls it with a fresh join blob.
79 func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) { 82 func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) {
83 if !a.requireFleet(w, r) {
84 return
85 }
80 id := r.PathValue("id") 86 id := r.PathValue("id")
81 // Audit is written inside the bump transaction (a security action must 87 // Audit is written inside the bump transaction (a security action must
82 // not be able to happen unrecorded). 88 // not be able to happen unrecorded).
@@ -126,7 +132,16 @@ func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent {
126 // handleListAudit returns the newest audit rows (default 100, ?limit=N caps 132 // handleListAudit returns the newest audit rows (default 100, ?limit=N caps
127 // at 1000). Completes the forensic story: rows were previously reachable only 133 // at 1000). Completes the forensic story: rows were previously reachable only
128 // by opening the SQLite file. 134 // by opening the SQLite file.
135 //
136 // Audit read stays Fleet-gated (not per-tenant): audit_log has no tenant column
137 // and some rows are genuinely tenant-less — e.g. host.enroll.denied written from
138 // an UNAUTHENTICATED enroll attempt, before any tenant is known. Per-tenant
139 // audit is a recorded tenant-#2 blocker (spec §Deferred); stated here rather
140 // than implied.
129 func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { 141 func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) {
142 if !a.requireFleet(w, r) {
143 return
144 }
130 limit, ok := parseLimit(w, r) 145 limit, ok := parseLimit(w, r)
131 if !ok { 146 if !ok {
132 return 147 return
@@ -231,6 +246,12 @@ func (a *API) marshalSnapshot() ([]byte, error) {
231 if err != nil { 246 if err != nil {
232 return nil, err 247 return nil, err
233 } 248 }
249 // SSE is ticket-authed and tickets carry no principal yet — per-tenant
250 // stream isolation is a recorded tenant-#2 blocker (spec §Deferred). The
251 // stream therefore remains fleet-wide, stated here rather than implied.
252 fleet := Principal{Fleet: true}
253 hosts = filterHosts(fleet, hosts)
254 vms = filterVMs(fleet, vms)
234 states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms)) 255 states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms))
235 return json.Marshal(types.StateSnapshot{ 256 return json.Marshal(types.StateSnapshot{
236 Hosts: a.buildHostResponses(hosts, alloc, states), 257 Hosts: a.buildHostResponses(hosts, alloc, states),
internal/server/api/principal.go
Old New
@@ -0,0 +1,88 @@
1 package api
2
3 import (
4 "context"
5 "net/http"
6
7 "github.com/a73x/eitri/internal/server/store"
8 )
9
10 // Principal is the authenticated actor attached to every admin-API request by
11 // adminAuth. It is the F3 seam: today the bearer token maps to the single
12 // bootstrap principal; multi-user auth later changes only how a Principal is
13 // RESOLVED, never how handlers consume it.
14 type Principal struct {
15 // Tenant is the tenant scope this principal acts within.
16 Tenant string
17 // Fleet marks a fleet operator: may act across tenants and perform the
18 // fleet-level operations that have no per-tenant owner (enroll-token mint,
19 // host decommission, credential revocation, audit). It is the named form
20 // of the actor those operations already require — not a per-tenant admin
21 // bit, and not required for ordinary tenant-scoped access.
22 Fleet bool
23 }
24
25 // principalKey is the context key for the request principal.
26 type principalKey struct{}
27
28 // withPrincipal returns a request context carrying p.
29 func withPrincipal(ctx context.Context, p Principal) context.Context {
30 return context.WithValue(ctx, principalKey{}, p)
31 }
32
33 // principalFromContext returns the request's principal. A request that never
34 // passed adminAuth yields the zero Principal — no tenant, no fleet bit — so
35 // every check downstream fails closed.
36 func principalFromContext(r *http.Request) Principal {
37 p, _ := r.Context().Value(principalKey{}).(Principal)
38 return p
39 }
40
41 // mayActAs reports whether p may act on a resource owned by tenant. Strict
42 // scope-equality with a fleet override; an empty tenant on either side never
43 // matches (resources always have a tenant; a zero principal must not pair
44 // with a malformed resource via "" == "").
45 func mayActAs(p Principal, tenant string) bool {
46 if p.Fleet {
47 return true
48 }
49 return tenant != "" && p.Tenant == tenant
50 }
51
52 // filterVMs returns only the VMs p may act on; Fleet sees the whole fleet.
53 func filterVMs(p Principal, vms []store.VM) []store.VM {
54 if p.Fleet {
55 return vms
56 }
57 out := make([]store.VM, 0, len(vms))
58 for _, vm := range vms {
59 if mayActAs(p, vm.Tenant) {
60 out = append(out, vm)
61 }
62 }
63 return out
64 }
65
66 // filterHosts is filterVMs for hosts.
67 func filterHosts(p Principal, hosts []store.Host) []store.Host {
68 if p.Fleet {
69 return hosts
70 }
71 out := make([]store.Host, 0, len(hosts))
72 for _, h := range hosts {
73 if mayActAs(p, h.Tenant) {
74 out = append(out, h)
75 }
76 }
77 return out
78 }
79
80 // requireFleet allows only fleet operators through; writes 403 and returns
81 // false otherwise.
82 func (a *API) requireFleet(w http.ResponseWriter, r *http.Request) bool {
83 if !principalFromContext(r).Fleet {
84 http.Error(w, "forbidden", http.StatusForbidden)
85 return false
86 }
87 return true
88 }
internal/server/api/principal_test.go
Old New
@@ -0,0 +1,155 @@
1 package api
2
3 import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9
10 "github.com/a73x/eitri/internal/server/store"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13 )
14
15 func TestMayActAs(t *testing.T) {
16 for _, tc := range []struct {
17 name string
18 p Principal
19 tenant string
20 want bool
21 }{
22 {"same tenant", Principal{Tenant: "t1"}, "t1", true},
23 {"different tenant", Principal{Tenant: "t1"}, "t2", false},
24 {"fleet crosses tenants", Principal{Tenant: "t1", Fleet: true}, "t2", true},
25 {"zero principal fails closed", Principal{}, "t1", false},
26 // The empty-tenant/empty-target degenerate: a zero principal must not
27 // accidentally match a (never-valid) empty resource tenant via "" == "".
28 // Guarded by mayActAs's explicit empty check.
29 {"zero principal vs empty tenant", Principal{}, "", false},
30 } {
31 t.Run(tc.name, func(t *testing.T) {
32 assert.Equal(t, tc.want, mayActAs(tc.p, tc.tenant))
33 })
34 }
35 }
36
37 func TestPrincipalFromContextFailsClosed(t *testing.T) {
38 // A request that never went through adminAuth carries no principal: the
39 // zero value has no tenant and no fleet bit.
40 r := httptest.NewRequest("GET", "/", nil)
41 p := principalFromContext(r)
42 assert.False(t, p.Fleet)
43 assert.Empty(t, p.Tenant)
44 }
45
46 func TestRequireFleetFailsClosed(t *testing.T) {
47 w := httptest.NewRecorder()
48 r := httptest.NewRequest("POST", "/api/v1/enroll-tokens", nil)
49 a := &API{}
50 assert.False(t, a.requireFleet(w, r))
51 assert.Equal(t, 403, w.Code)
52 }
53
54 func TestRequireFleetRejectsTenantWithoutFleetBit(t *testing.T) {
55 // A tenant-scoped principal — not a zero value, just missing Fleet — must
56 // still be turned away. This also exercises the withPrincipal ->
57 // principalFromContext round-trip for the first time.
58 w := httptest.NewRecorder()
59 r := httptest.NewRequest("POST", "/api/v1/enroll-tokens", nil)
60 r = r.WithContext(withPrincipal(r.Context(), Principal{Tenant: "t1"}))
61 a := &API{}
62 assert.False(t, a.requireFleet(w, r))
63 assert.Equal(t, 403, w.Code)
64 }
65
66 func TestFilterByTenant(t *testing.T) {
67 vms := []store.VM{{ID: "a", Tenant: "t1"}, {ID: "b", Tenant: "t2"}}
68 hosts := []store.Host{{ID: "h1", Tenant: "t1"}, {ID: "h2", Tenant: "t2"}}
69
70 member := Principal{Tenant: "t1"}
71 gotVMs := filterVMs(member, vms)
72 require.Len(t, gotVMs, 1)
73 assert.Equal(t, "a", gotVMs[0].ID)
74 gotHosts := filterHosts(member, hosts)
75 require.Len(t, gotHosts, 1)
76 assert.Equal(t, "h1", gotHosts[0].ID)
77
78 fleet := Principal{Tenant: "t1", Fleet: true}
79 assert.Len(t, filterVMs(fleet, vms), 2, "fleet sees everything")
80 assert.Len(t, filterHosts(fleet, hosts), 2)
81 }
82
83 // foreignRequest builds a request carrying a Principal scoped to a tenant
84 // other than "default" (where testServer/enroll resources land), for calling
85 // handlers DIRECTLY — bypassing the mux, since adminAuth would otherwise
86 // overwrite the principal with the Fleet bootstrap principal.
87 func foreignRequest(t *testing.T, method, path string, body any) *http.Request {
88 t.Helper()
89 var buf bytes.Buffer
90 if body != nil {
91 require.NoError(t, json.NewEncoder(&buf).Encode(body))
92 }
93 r := httptest.NewRequest(method, path, &buf)
94 return r.WithContext(withPrincipal(r.Context(), Principal{Tenant: "other"}))
95 }
96
97 // TestHandleListVMsFiltersForeignTenant proves the wiring in snapshotVMs: a
98 // principal from a different tenant sees an empty list even though a VM
99 // exists (in "default", where enroll/create land).
100 func TestHandleListVMsFiltersForeignTenant(t *testing.T) {
101 ts, _, _, _, a := newServer(t)
102 out := enroll(t, ts)
103 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
104 map[string]any{"host_id": out["host_id"], "name": "vm-a"})
105 require.Equal(t, 201, resp.StatusCode)
106
107 r := foreignRequest(t, "GET", "/api/v1/vms", nil)
108 w := httptest.NewRecorder()
109 a.handleListVMs(w, r)
110
111 require.Equal(t, 200, w.Code)
112 var vms []map[string]any
113 require.NoError(t, json.NewDecoder(w.Body).Decode(&vms))
114 assert.Empty(t, vms, "a foreign-tenant principal must not see another tenant's VMs")
115 }
116
117 // TestHandleDeleteVMForeignTenantIsNotFoundAndNoop proves mutateVM's ownership
118 // gate runs BEFORE mutate: a foreign-tenant delete gets the same not-found
119 // response as a missing VM, and the VM is still live afterward.
120 func TestHandleDeleteVMForeignTenantIsNotFoundAndNoop(t *testing.T) {
121 ts, st, _, _, a := newServer(t)
122 out := enroll(t, ts)
123 created := map[string]string{}
124 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
125 map[string]any{"host_id": out["host_id"], "name": "vm-a"})
126 require.Equal(t, 201, resp.StatusCode)
127 require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
128 id := created["id"]
129
130 r := foreignRequest(t, "DELETE", "/api/v1/vms/"+id, nil)
131 r.SetPathValue("id", id)
132 w := httptest.NewRecorder()
133 a.handleDeleteVM(w, r)
134
135 assert.Equal(t, http.StatusNotFound, w.Code)
136
137 vm, err := st.GetVM(id)
138 require.NoError(t, err)
139 assert.Nil(t, vm.DeletedAt, "the ownership gate must reject before mutate runs")
140 }
141
142 // TestHandleCreateVMForeignTenantHostIsUnknown proves the create pre-check: a
143 // foreign-tenant principal placing a VM on a "default"-tenant host gets the
144 // same 400 as an absent host_id.
145 func TestHandleCreateVMForeignTenantHostIsUnknown(t *testing.T) {
146 ts, _, _, _, a := newServer(t)
147 out := enroll(t, ts)
148
149 r := foreignRequest(t, "POST", "/api/v1/vms", map[string]any{"host_id": out["host_id"]})
150 w := httptest.NewRecorder()
151 a.handleCreateVM(w, r)
152
153 assert.Equal(t, http.StatusBadRequest, w.Code)
154 assert.Contains(t, w.Body.String(), "unknown host_id")
155 }
internal/server/api/sshcert.go
Old New
@@ -166,6 +166,7 @@ func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) {
166 }) 166 })
167 writeJSON(w, http.StatusOK, types.SSHCertResponse{ 167 writeJSON(w, http.StatusOK, types.SSHCertResponse{
168 Certificate: string(ssh.MarshalAuthorizedKey(cert)), 168 Certificate: string(ssh.MarshalAuthorizedKey(cert)),
169 Tenant: principalFromContext(r).Tenant,
169 }) 170 })
170 } 171 }
171 172
@@ -175,6 +176,9 @@ func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) {
175 // it does NOT depend on the minter being wired, so unlike mint it never 404s on 176 // it does NOT depend on the minter being wired, so unlike mint it never 404s on
176 // a gate-off server. 177 // a gate-off server.
177 func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { 178 func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
179 if !a.requireFleet(w, r) {
180 return
181 }
178 var req types.RevokeSSHCertRequest 182 var req types.RevokeSSHCertRequest
179 if !decodeJSON(w, r, &req) { 183 if !decodeJSON(w, r, &req) {
180 return 184 return
@@ -220,6 +224,9 @@ func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
220 // handleListRevokedSSHCerts lists the revoked cert serials (+ reason/time), 224 // handleListRevokedSSHCerts lists the revoked cert serials (+ reason/time),
221 // newest first. Admin-authed. 225 // newest first. Admin-authed.
222 func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) { 226 func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) {
227 if !a.requireFleet(w, r) {
228 return
229 }
223 revoked, err := a.st.ListRevokedSSHCerts() 230 revoked, err := a.st.ListRevokedSSHCerts()
224 if err != nil { 231 if err != nil {
225 http.Error(w, "internal error", http.StatusInternalServerError) 232 http.Error(w, "internal error", http.StatusInternalServerError)
internal/server/api/sshcert_test.go
Old New
@@ -11,6 +11,7 @@ import (
11 "testing" 11 "testing"
12 "time" 12 "time"
13 13
14 "github.com/a73x/eitri/internal/server/store"
14 "github.com/stretchr/testify/assert" 15 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require" 16 "github.com/stretchr/testify/require"
16 "golang.org/x/crypto/ssh" 17 "golang.org/x/crypto/ssh"
@@ -122,6 +123,19 @@ func TestSSHCertMintIgnoresClientPrincipals(t *testing.T) {
122 "client-supplied principals must be ignored — always ubuntu") 123 "client-supplied principals must be ignored — always ubuntu")
123 } 124 }
124 125
126 // TestSSHCertMintReturnsTenant asserts the mint response carries the minting
127 // principal's tenant, which clients use to build <tenant>.<name> connect names.
128 func TestSSHCertMintReturnsTenant(t *testing.T) {
129 ts, _ := newServerWithCertMinter(t, 10*time.Minute)
130
131 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs", "admintok",
132 map[string]any{"public_key": genUserPubKey(t)})
133 require.Equal(t, http.StatusOK, resp.StatusCode)
134 var out map[string]string
135 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
136 assert.Equal(t, store.DefaultTenant, out["tenant"], "mint response must carry the tenant")
137 }
138
125 func TestSSHCertMintRequiresAdmin(t *testing.T) { 139 func TestSSHCertMintRequiresAdmin(t *testing.T) {
126 ts, _ := newServerWithCertMinter(t, 10*time.Minute) 140 ts, _ := newServerWithCertMinter(t, 10*time.Minute)
127 body := map[string]any{"public_key": genUserPubKey(t)} 141 body := map[string]any{"public_key": genUserPubKey(t)}
@@ -160,34 +174,52 @@ func TestSSHCAEndpointGateOffIs404(t *testing.T) {
160 assert.Equal(t, http.StatusNotFound, resp.StatusCode) 174 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
161 } 175 }
162 176
177 // recordingHostMinter delegates to a real HostMinter but captures the principal
178 // argument, so a test can assert the caller namespaced it as <tenant>.<name>.
179 type recordingHostMinter struct {
180 inner HostCertMinter
181 principal string
182 }
183
184 func (m *recordingHostMinter) MintHostCert(principal string) (keyPEM, cert string, err error) {
185 m.principal = principal
186 return m.inner.MintHostCert(principal)
187 }
188
163 // TestCreateVMMintsPerVMHostCert asserts that, with the gate enabled, creating a 189 // TestCreateVMMintsPerVMHostCert asserts that, with the gate enabled, creating a
164 // VM persists a per-VM host private key + a CA-signed host cert scoped to the VM 190 // VM persists a per-VM host private key + a CA-signed host cert scoped to the
165 // name — and that neither ever appears in the API's VM response. 191 // namespaced <tenant>.<name> principal — and that neither the private key nor
192 // the raw principal ever appears in the API's VM response.
166 func TestCreateVMMintsPerVMHostCert(t *testing.T) { 193 func TestCreateVMMintsPerVMHostCert(t *testing.T) {
167 ts, st, _, _, a := newServer(t) 194 ts, st, _, _, a := newServer(t)
168 ca := newCASigner(t) 195 ca := newCASigner(t)
169 a.SetHostCertMinter(NewHostMinter(ca)) 196 rec := &recordingHostMinter{inner: NewHostMinter(ca)}
197 a.SetHostCertMinter(rec)
170 out := enroll(t, ts) 198 out := enroll(t, ts)
171 199
172 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 200 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok",
173 map[string]any{"host_id": out["host_id"], "name": "hosty"}) 201 map[string]any{"host_id": out["host_id"], "name": "hosty"})
174 require.Equal(t, http.StatusCreated, resp.StatusCode) 202 require.Equal(t, http.StatusCreated, resp.StatusCode)
175 203
204 // VM create must pass the namespaced principal to the host-cert minter.
205 assert.Equal(t, "default.hosty", rec.principal,
206 "host-cert principal must be <tenant>.<name>")
207
176 // The store row carries the private key PEM + the cert. 208 // The store row carries the private key PEM + the cert.
177 vm, err := st.VMByName("hosty") 209 vm, err := st.VMByTenantName(store.DefaultTenant, "hosty")
178 require.NoError(t, err) 210 require.NoError(t, err)
179 require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted") 211 require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted")
180 require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted") 212 require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted")
181 assert.Contains(t, vm.SSHHostKey, "OPENSSH PRIVATE KEY") 213 assert.Contains(t, vm.SSHHostKey, "OPENSSH PRIVATE KEY")
182 214
183 // The cert is a HOST cert signed by the CA and scoped to the VM name. 215 // The cert is a HOST cert signed by the CA and scoped to <tenant>.<name>.
184 cert := parseCert(t, vm.SSHHostCert) 216 cert := parseCert(t, vm.SSHHostCert)
185 assert.Equal(t, uint32(ssh.HostCert), cert.CertType) 217 assert.Equal(t, uint32(ssh.HostCert), cert.CertType)
186 assert.Equal(t, []string{"hosty"}, cert.ValidPrincipals) 218 assert.Equal(t, []string{"default.hosty"}, cert.ValidPrincipals)
187 checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool { 219 checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool {
188 return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) 220 return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal())
189 }} 221 }}
190 require.NoError(t, checker.CheckHostKey("hosty:22", nil, cert)) 222 require.NoError(t, checker.CheckHostKey("default.hosty:22", nil, cert))
191 223
192 // The private key must NEVER leak through the VM listing. 224 // The private key must NEVER leak through the VM listing.
193 listResp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) 225 listResp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil)
@@ -207,7 +239,7 @@ func TestCreateVMWithoutHostMinterHasNoHostCert(t *testing.T) {
207 map[string]any{"host_id": out["host_id"], "name": "plainvm"}) 239 map[string]any{"host_id": out["host_id"], "name": "plainvm"})
208 require.Equal(t, http.StatusCreated, resp.StatusCode) 240 require.Equal(t, http.StatusCreated, resp.StatusCode)
209 241
210 vm, err := st.VMByName("plainvm") 242 vm, err := st.VMByTenantName(store.DefaultTenant, "plainvm")
211 require.NoError(t, err) 243 require.NoError(t, err)
212 assert.Empty(t, vm.SSHHostKey) 244 assert.Empty(t, vm.SSHHostKey)
213 assert.Empty(t, vm.SSHHostCert) 245 assert.Empty(t, vm.SSHHostCert)
internal/server/api/testdata/ssh-cert-response.golden.json
Old New
@@ -1,3 +1,4 @@
1 { 1 {
2 "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment" 2 "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment",
3 "tenant": "default"
3 } 4 }
internal/server/api/types/types.go
Old New
@@ -156,6 +156,8 @@ type SSHCAResponse struct {
156 // certificate in authorized_keys form. 156 // certificate in authorized_keys form.
157 type SSHCertResponse struct { 157 type SSHCertResponse struct {
158 Certificate string `json:"certificate"` 158 Certificate string `json:"certificate"`
159 // The minting principal's tenant: clients dial VMs as <tenant>.<name>.
160 Tenant string `json:"tenant"`
159 } 161 }
160 162
161 // StreamTicketResponse answers POST /api/v1/stream-tickets. 163 // StreamTicketResponse answers POST /api/v1/stream-tickets.
internal/server/api/wire_golden_test.go
Old New
@@ -155,6 +155,7 @@ func TestWireGolden(t *testing.T) {
155 155
156 goldenCheck(t, "ssh-cert-response", types.SSHCertResponse{ 156 goldenCheck(t, "ssh-cert-response", types.SSHCertResponse{
157 Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment", 157 Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment",
158 Tenant: "default",
158 }) 159 })
159 160
160 goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ 161 goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{
internal/server/sshgate/gate.go
Old New
@@ -1,5 +1,5 @@
1 // Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that 1 // Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that
2 // admins reach with `ssh -J gate ubuntu@<vm>`. It authenticates users by short- 2 // admins reach with `ssh -J gate ubuntu@<tenant>.<vm>`. It authenticates users by short-
3 // lived certificates signed by the eitri user CA and permits exactly one thing — 3 // lived certificates signed by the eitri user CA and permits exactly one thing —
4 // a `direct-tcpip` tunnel to `<vm>:22`, forwarded to the VM's host over the sync 4 // a `direct-tcpip` tunnel to `<vm>:22`, forwarded to the VM's host over the sync
5 // connection. Every other SSH surface is refused: no sessions/shells/exec (which 5 // connection. Every other SSH surface is refused: no sessions/shells/exec (which
@@ -22,23 +22,29 @@ import (
22 "golang.org/x/crypto/ssh" 22 "golang.org/x/crypto/ssh"
23 ) 23 )
24 24
25 // Resolver maps a VM name (as typed in `ssh -J gate user@<name>`) to its host 25 // Resolver maps a bare VM name WITHIN tenant to its host and VM IDs. ok=false
26 // and VM IDs. ok=false ⇒ unknown name; the channel is rejected. Resolution is 26 // ⇒ unknown; the channel is rejected. Resolution is NOT an authorization
27 // NOT an authorization boundary — authz is (§9 M4). 27 // boundary — authorize is; but it IS tenant-scoped, so names never resolve
28 type Resolver func(name string) (hostID, vmID string, ok bool) 28 // across tenants.
29 type Resolver func(tenant, name string) (hostID, vmID string, ok bool)
29 30
30 // Authorizer reports whether the verified cert principal may reach vmID. v1 wires 31 // Authorizer reports whether a connection belonging to tenant may reach vmID.
31 // an always-true stub (single-admin; only the admin can mint certs); the per-user 32 // The tenant is derived from the cert's signing CA at auth time (the ONLY
32 // implementation is the F3 choke point and must fail closed (§5). 33 // authenticated identity on the connection — the cert principal is just the
33 type Authorizer func(principal, vmID string) bool 34 // guest login user). Must fail closed.
35 //
36 // HONEST SCOPE (spec §Gate): with one CA every connection maps to the same
37 // tenant, so this check separates tenants logically, not cryptographically.
38 // The crypto boundary is the CA split — a recorded tenant-#2 blocker.
39 type Authorizer func(tenant, vmID string) bool
34 40
35 // Dialer opens a raw byte pipe to vmID:port on hostID (wired to syncsvc.OpenTCP). 41 // Dialer opens a raw byte pipe to vmID:port on hostID (wired to syncsvc.OpenTCP).
36 type Dialer func(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) 42 type Dialer func(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error)
37 43
38 // principalsExt is the Permissions.Extensions key under which the verified cert 44 // tenantExt is the Permissions.Extensions key carrying the connection's
39 // principal(s) are stashed for the channel handler to authorize on. This is the 45 // tenant from auth to the channel handlers. Per-connection by construction —
40 // ONLY trusted source of the principal — never the requested hostname/username. 46 // a startup-captured tenant would have the wrong lifetime (review finding).
41 const principalsExt = "principals" 47 const tenantExt = "tenant"
42 48
43 // directTCPIP is the wire payload of an SSH `direct-tcpip` channel-open. 49 // directTCPIP is the wire payload of an SSH `direct-tcpip` channel-open.
44 type directTCPIP struct { 50 type directTCPIP struct {
@@ -74,7 +80,7 @@ type Revoker func(serial uint64) bool
74 // userCA, resolves VM names with resolve, gates them with authorize, rejects 80 // userCA, resolves VM names with resolve, gates them with authorize, rejects
75 // certs isRevoked flags, and tunnels through dial. A nil isRevoked disables 81 // certs isRevoked flags, and tunnels through dial. A nil isRevoked disables
76 // revocation checks (nothing is revoked). 82 // revocation checks (nothing is revoked).
77 func New(hostKey ssh.Signer, userCA ssh.PublicKey, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate { 83 func New(hostKey ssh.Signer, userCA ssh.PublicKey, caTenant string, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate {
78 checker := &ssh.CertChecker{ 84 checker := &ssh.CertChecker{
79 IsUserAuthority: func(auth ssh.PublicKey) bool { return keysEqual(auth, userCA) }, 85 IsUserAuthority: func(auth ssh.PublicKey) bool { return keysEqual(auth, userCA) },
80 } 86 }
@@ -116,7 +122,11 @@ func New(hostKey ssh.Signer, userCA ssh.PublicKey, resolve Resolver, authorize A
116 } 122 }
117 return &ssh.Permissions{ 123 return &ssh.Permissions{
118 Extensions: map[string]string{ 124 Extensions: map[string]string{
119 principalsExt: strings.Join(cert.ValidPrincipals, ","), 125 // The tenant of the CA that signed this cert. v1 trusts ONE
126 // CA (IsUserAuthority is an equality check), so this is that
127 // CA's tenant; the multi-CA future maps SignatureKey→tenant
128 // here and NOTHING downstream changes.
129 tenantExt: caTenant,
120 }, 130 },
121 }, nil 131 }, nil
122 }, 132 },
@@ -161,9 +171,9 @@ func (g *Gate) handleConn(nConn net.Conn) {
161 // legitimate here. Draining the channel also keeps the transport unblocked. 171 // legitimate here. Draining the channel also keeps the transport unblocked.
162 go rejectRequests(reqs) 172 go rejectRequests(reqs)
163 173
164 principal := "" 174 tenant := ""
165 if sConn.Permissions != nil { 175 if sConn.Permissions != nil {
166 principal = sConn.Permissions.Extensions[principalsExt] 176 tenant = sConn.Permissions.Extensions[tenantExt]
167 } 177 }
168 for newChan := range chans { 178 for newChan := range chans {
169 // Only direct-tcpip is permitted; this rejects session/exec/shell/ 179 // Only direct-tcpip is permitted; this rejects session/exec/shell/
@@ -172,7 +182,7 @@ func (g *Gate) handleConn(nConn net.Conn) {
172 _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") 182 _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted")
173 continue 183 continue
174 } 184 }
175 go g.handleDirectTCPIP(newChan, principal) 185 go g.handleDirectTCPIP(newChan, tenant)
176 } 186 }
177 } 187 }
178 188
@@ -188,7 +198,7 @@ func rejectRequests(reqs <-chan *ssh.Request) {
188 198
189 // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM, 199 // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM,
190 // and bridges the channel to the VM byte-for-byte. 200 // and bridges the channel to the VM byte-for-byte.
191 func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, principal string) { 201 func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) {
192 var p directTCPIP 202 var p directTCPIP
193 if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil { 203 if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil {
194 _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") 204 _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request")
@@ -200,12 +210,22 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, principal string) {
200 _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") 210 _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted")
201 return 211 return
202 } 212 }
203 hostID, vmID, ok := g.resolve(p.HostToConnect) 213 // Connect names are <tenant>.<name>. VM names are RFC1123 labels (no
214 // dots) and tenant ids are dot-free, so the FIRST dot splits
215 // unambiguously. The prefix must match the connection's own tenant —
216 // naming another tenant is rejected identically to a nonexistent VM, so
217 // tenancy structure is not probeable from the gate.
218 prefix, bare, found := strings.Cut(p.HostToConnect, ".")
219 if !found || prefix != tenant || bare == "" {
220 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
221 return
222 }
223 hostID, vmID, ok := g.resolve(tenant, bare)
204 if !ok { 224 if !ok {
205 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") 225 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
206 return 226 return
207 } 227 }
208 if !g.authorize(principal, vmID) { 228 if !g.authorize(tenant, vmID) {
209 _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM") 229 _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM")
210 return 230 return
211 } 231 }
internal/server/sshgate/gate_test.go
Old New
@@ -80,20 +80,20 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate {
80 dialCalls: make(chan [3]string, 4), 80 dialCalls: make(chan [3]string, 4),
81 authorized: authorized, 81 authorized: authorized,
82 } 82 }
83 resolve := func(name string) (string, string, bool) { 83 resolve := func(tenant, name string) (string, string, bool) {
84 if name == "vm1" { 84 if tenant == "default" && name == "vm1" {
85 return "host-1", "vm-1", true 85 return "host-1", "vm-1", true
86 } 86 }
87 return "", "", false 87 return "", "", false
88 } 88 }
89 authorize := func(principal, vmID string) bool { return tg.authorized } 89 authorize := func(tenant, vmID string) bool { return tg.authorized }
90 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { 90 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) {
91 tg.dialCalls <- [3]string{hostID, vmID, "22"} 91 tg.dialCalls <- [3]string{hostID, vmID, "22"}
92 a, b := net.Pipe() 92 a, b := net.Pipe()
93 go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd 93 go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd
94 return a, nil 94 return a, nil
95 } 95 }
96 g := New(tg.hostKey, userCA, resolve, authorize, dial, nil) 96 g := New(tg.hostKey, userCA, "default", resolve, authorize, dial, nil)
97 97
98 l, err := net.Listen("tcp", "127.0.0.1:0") 98 l, err := net.Listen("tcp", "127.0.0.1:0")
99 require.NoError(t, err) 99 require.NoError(t, err)
@@ -108,20 +108,20 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate {
108 func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *testGate { 108 func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *testGate {
109 t.Helper() 109 t.Helper()
110 tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: true} 110 tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: true}
111 resolve := func(name string) (string, string, bool) { 111 resolve := func(tenant, name string) (string, string, bool) {
112 if name == "vm1" { 112 if tenant == "default" && name == "vm1" {
113 return "host-1", "vm-1", true 113 return "host-1", "vm-1", true
114 } 114 }
115 return "", "", false 115 return "", "", false
116 } 116 }
117 authorize := func(principal, vmID string) bool { return tg.authorized } 117 authorize := func(tenant, vmID string) bool { return tg.authorized }
118 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { 118 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) {
119 tg.dialCalls <- [3]string{hostID, vmID, "22"} 119 tg.dialCalls <- [3]string{hostID, vmID, "22"}
120 a, b := net.Pipe() 120 a, b := net.Pipe()
121 go func() { _, _ = io.Copy(b, b); b.Close() }() 121 go func() { _, _ = io.Copy(b, b); b.Close() }()
122 return a, nil 122 return a, nil
123 } 123 }
124 g := New(tg.hostKey, userCA, resolve, authorize, dial, isRevoked) 124 g := New(tg.hostKey, userCA, "default", resolve, authorize, dial, isRevoked)
125 l, err := net.Listen("tcp", "127.0.0.1:0") 125 l, err := net.Listen("tcp", "127.0.0.1:0")
126 require.NoError(t, err) 126 require.NoError(t, err)
127 tg.addr = l.Addr().String() 127 tg.addr = l.Addr().String()
@@ -170,20 +170,20 @@ func dialClient(t *testing.T, tg *testGate, certSigner ssh.Signer) *ssh.Client {
170 func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey) *testGate { 170 func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey) *testGate {
171 t.Helper() 171 t.Helper()
172 tg := &testGate{hostKey: hostKey, dialCalls: make(chan [3]string, 4), authorized: true} 172 tg := &testGate{hostKey: hostKey, dialCalls: make(chan [3]string, 4), authorized: true}
173 resolve := func(name string) (string, string, bool) { 173 resolve := func(tenant, name string) (string, string, bool) {
174 if name == "vm1" { 174 if tenant == "default" && name == "vm1" {
175 return "host-1", "vm-1", true 175 return "host-1", "vm-1", true
176 } 176 }
177 return "", "", false 177 return "", "", false
178 } 178 }
179 authorize := func(principal, vmID string) bool { return tg.authorized } 179 authorize := func(tenant, vmID string) bool { return tg.authorized }
180 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { 180 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) {
181 tg.dialCalls <- [3]string{hostID, vmID, "22"} 181 tg.dialCalls <- [3]string{hostID, vmID, "22"}
182 a, b := net.Pipe() 182 a, b := net.Pipe()
183 go func() { _, _ = io.Copy(b, b); b.Close() }() 183 go func() { _, _ = io.Copy(b, b); b.Close() }()
184 return a, nil 184 return a, nil
185 } 185 }
186 g := New(hostKey, userCA, resolve, authorize, dial, nil) 186 g := New(hostKey, userCA, "default", resolve, authorize, dial, nil)
187 l, err := net.Listen("tcp", "127.0.0.1:0") 187 l, err := net.Listen("tcp", "127.0.0.1:0")
188 require.NoError(t, err) 188 require.NoError(t, err)
189 tg.addr = l.Addr().String() 189 tg.addr = l.Addr().String()
@@ -238,7 +238,7 @@ func TestGatePresentsCASignedHostCert(t *testing.T) {
238 t.Cleanup(func() { _ = client.Close() }) 238 t.Cleanup(func() { _ = client.Close() })
239 239
240 // And the tunnel still works end-to-end over the cert-authenticated host. 240 // And the tunnel still works end-to-end over the cert-authenticated host.
241 conn, err := client.Dial("tcp", "vm1:22") 241 conn, err := client.Dial("tcp", "default.vm1:22")
242 require.NoError(t, err) 242 require.NoError(t, err)
243 _ = conn.Close() 243 _ = conn.Close()
244 } 244 }
@@ -274,7 +274,7 @@ func TestGateDirectTCPIPToPort22RoundTrips(t *testing.T) {
274 tg := startGate(t, ca.PublicKey(), true) 274 tg := startGate(t, ca.PublicKey(), true)
275 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) 275 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
276 276
277 conn, err := client.Dial("tcp", "vm1:22") 277 conn, err := client.Dial("tcp", "default.vm1:22")
278 require.NoError(t, err) 278 require.NoError(t, err)
279 defer conn.Close() 279 defer conn.Close()
280 280
@@ -328,7 +328,7 @@ func TestGateDirectTCPIPToNonSSHPortRejected(t *testing.T) {
328 tg := startGate(t, ca.PublicKey(), true) 328 tg := startGate(t, ca.PublicKey(), true)
329 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) 329 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
330 330
331 _, err := client.Dial("tcp", "vm1:2222") 331 _, err := client.Dial("tcp", "default.vm1:2222")
332 require.Error(t, err, "only port 22 may be tunnelled") 332 require.Error(t, err, "only port 22 may be tunnelled")
333 } 333 }
334 334
@@ -337,7 +337,7 @@ func TestGateUnknownVMRejected(t *testing.T) {
337 tg := startGate(t, ca.PublicKey(), true) 337 tg := startGate(t, ca.PublicKey(), true)
338 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) 338 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
339 339
340 _, err := client.Dial("tcp", "nope:22") 340 _, err := client.Dial("tcp", "default.nope:22")
341 require.Error(t, err, "unknown VM name must be rejected") 341 require.Error(t, err, "unknown VM name must be rejected")
342 } 342 }
343 343
@@ -346,10 +346,46 @@ func TestGateAuthzDenyRejected(t *testing.T) {
346 tg := startGate(t, ca.PublicKey(), false) // authorize → deny 346 tg := startGate(t, ca.PublicKey(), false) // authorize → deny
347 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) 347 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
348 348
349 _, err := client.Dial("tcp", "vm1:22") 349 _, err := client.Dial("tcp", "default.vm1:22")
350 require.Error(t, err, "authz denial must reject the channel") 350 require.Error(t, err, "authz denial must reject the channel")
351 } 351 }
352 352
353 // TestGateRejectsBareAndForeignTenantNames: the dialed name must be
354 // <tenant>.<name> and the prefix must match the connection's tenant (derived
355 // from the signing CA). Bare (unqualified) names and foreign prefixes are
356 // rejected before resolution.
357 func TestGateRejectsBareAndForeignTenantNames(t *testing.T) {
358 ca := newSigner(t)
359 tg := startGate(t, ca.PublicKey(), true)
360 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
361
362 // rejectMsg dials target (which must be rejected) and returns the gate's
363 // channel-open reject message.
364 rejectMsg := func(target string) string {
365 _, err := client.Dial("tcp", target+":22")
366 require.Error(t, err, "dialing %q must be rejected", target)
367 var oce *ssh.OpenChannelError
368 require.ErrorAs(t, err, &oce)
369 return oce.Message
370 }
371
372 // A bare name (no tenant prefix) is rejected — the gate's tenant is
373 // "default" (the signing CA's tenant) and names must be <tenant>.<name>.
374 _ = rejectMsg("vm1")
375
376 // A foreign tenant prefix must be rejected with the SAME message as a
377 // nonexistent VM in our own tenant: the two are indistinguishable, so
378 // tenancy structure is not probeable from the gate. Test-lock the equal
379 // messages so a future refactor cannot split them silently.
380 assert.Equal(t, rejectMsg("default.nope"), rejectMsg("other.vm1"),
381 "a foreign tenant prefix must be indistinguishable from a nonexistent VM")
382
383 // Control: the correctly namespaced name resolves and tunnels.
384 conn, err := client.Dial("tcp", "default.vm1:22")
385 require.NoError(t, err, "correctly namespaced name must succeed")
386 _ = conn.Close()
387 }
388
353 func TestGateRejectsCertFromForeignCA(t *testing.T) { 389 func TestGateRejectsCertFromForeignCA(t *testing.T) {
354 ca := newSigner(t) 390 ca := newSigner(t)
355 foreignCA := newSigner(t) 391 foreignCA := newSigner(t)
@@ -406,7 +442,7 @@ func TestGateRejectsRevokedCert(t *testing.T) {
406 // Not revoked ⇒ the same serial authenticates and tunnels. 442 // Not revoked ⇒ the same serial authenticates and tunnels.
407 allowed := startGateRevoked(t, ca.PublicKey(), func(uint64) bool { return false }) 443 allowed := startGateRevoked(t, ca.PublicKey(), func(uint64) bool { return false })
408 client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial)) 444 client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial))
409 conn, err := client.Dial("tcp", "vm1:22") 445 conn, err := client.Dial("tcp", "default.vm1:22")
410 require.NoError(t, err, "a non-revoked cert must still tunnel") 446 require.NoError(t, err, "a non-revoked cert must still tunnel")
411 _ = conn.Close() 447 _ = conn.Close()
412 } 448 }
internal/server/store/allocation_test.go
Old New
@@ -48,7 +48,7 @@ func TestAllocatedByHostExcludesTombstoned(t *testing.T) {
48 func TestAllocatedByHostSeparatesHosts(t *testing.T) { 48 func TestAllocatedByHostSeparatesHosts(t *testing.T) {
49 s := newStore(t) 49 s := newStore(t)
50 h1 := enrollHost(t, s) 50 h1 := enrollHost(t, s)
51 tok, _ := s.CreateEnrollmentToken() 51 tok, _ := s.CreateEnrollmentToken(DefaultTenant)
52 h2, _ := s.RedeemEnrollmentToken(tok, "h2", "linux", "amd64", "cloudhv", "") 52 h2, _ := s.RedeemEnrollmentToken(tok, "h2", "linux", "amd64", "cloudhv", "")
53 vmWithResources(t, s, h1, "a", 2, 2048, 10) 53 vmWithResources(t, s, h1, "a", 2, 2048, 10)
54 vmWithResources(t, s, h2, "b", 8, 8192, 40) 54 vmWithResources(t, s, h2, "b", 8, 8192, 40)
internal/server/store/decommission_test.go
Old New
@@ -49,7 +49,7 @@ func TestRemoveHostFreesCIDRForReuse(t *testing.T) {
49 s := newStore(t) 49 s := newStore(t)
50 h1 := enrollHost(t, s) 50 h1 := enrollHost(t, s)
51 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) 51 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)
52 tok2, _ := s.CreateEnrollmentToken() 52 tok2, _ := s.CreateEnrollmentToken(DefaultTenant)
53 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") 53 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
54 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) 54 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)
55 55
@@ -64,7 +64,7 @@ func TestRemoveHostFreesCIDRForReuse(t *testing.T) {
64 assert.Error(t, err, "removed host should be gone") 64 assert.Error(t, err, "removed host should be gone")
65 65
66 // A new enrollment reuses h1's freed CIDR rather than allocating a fresh one. 66 // A new enrollment reuses h1's freed CIDR rather than allocating a fresh one.
67 tok3, _ := s.CreateEnrollmentToken() 67 tok3, _ := s.CreateEnrollmentToken(DefaultTenant)
68 h3, err := s.RedeemEnrollmentToken(tok3, "c", "linux", "amd64", "cloudhv", "") 68 h3, err := s.RedeemEnrollmentToken(tok3, "c", "linux", "amd64", "cloudhv", "")
69 require.NoError(t, err) 69 require.NoError(t, err)
70 assert.Equal(t, "10.77.1.0/24", h3.BridgeCIDR, "freed CIDR should be reused") 70 assert.Equal(t, "10.77.1.0/24", h3.BridgeCIDR, "freed CIDR should be reused")
internal/server/store/store.go
Old New
@@ -16,15 +16,14 @@ import (
16 "os" 16 "os"
17 "path/filepath" 17 "path/filepath"
18 "strconv" 18 "strconv"
19 "strings"
20 "time" 19 "time"
21 20
22 "github.com/a73x/eitri/internal/random" 21 "github.com/a73x/eitri/internal/random"
23 "github.com/a73x/eitri/internal/transport" 22 "github.com/a73x/eitri/internal/transport"
24 _ "modernc.org/sqlite" 23 sqlite "modernc.org/sqlite"
25 ) 24 )
26 25
27 // ErrNameTaken is returned by CreateVM when the name is already in use by a live VM. 26 // ErrNameTaken is returned by CreateVM when the name is already in use by a live VM in the same tenant.
28 var ErrNameTaken = errors.New("vm name already in use") 27 var ErrNameTaken = errors.New("vm name already in use")
29 28
30 // ErrHostNotFound is returned by CreateVM when the host_id does not exist. 29 // ErrHostNotFound is returned by CreateVM when the host_id does not exist.
@@ -34,6 +33,13 @@ var ErrHostNotFound = errors.New("host not found")
34 // not accepting new VMs (e.g. it is decommissioning). 33 // not accepting new VMs (e.g. it is decommissioning).
35 var ErrHostNotEnrolled = errors.New("host not accepting new VMs") 34 var ErrHostNotEnrolled = errors.New("host not accepting new VMs")
36 35
36 // DefaultTenant is the reserved bootstrap tenant every resource collapses to
37 // in v1. It is NOT special: no authz/resolve/uniqueness code may branch on
38 // this literal — it is seeded like any tenant and compared value-agnostically.
39 // The only legitimate reference points are bootstrap wiring (the admin
40 // principal, the single CA's tenant) and tests.
41 const DefaultTenant = "default"
42
37 type Store struct { 43 type Store struct {
38 db *sql.DB 44 db *sql.DB
39 dbDir string 45 dbDir string
@@ -46,6 +52,8 @@ type Host struct {
46 // one host's outstanding credential without rotating the fleet secret. 52 // one host's outstanding credential without rotating the fleet secret.
47 CredGeneration int64 53 CredGeneration int64
48 EnrolledAt time.Time 54 EnrolledAt time.Time
55 // Tenant is the owning tenant (partition key); derived from the enroll token.
56 Tenant string
49 } 57 }
50 58
51 type VM struct { 59 type VM struct {
@@ -63,6 +71,8 @@ type VM struct {
63 // The VM's reachable address is AssignedIP (the agent-reported bridge IP). 71 // The VM's reachable address is AssignedIP (the agent-reported bridge IP).
64 CreatedAt time.Time 72 CreatedAt time.Time
65 DeletedAt *time.Time 73 DeletedAt *time.Time
74 // Tenant is the owning tenant, always derived from the host's — never client-set.
75 Tenant string
66 } 76 }
67 77
68 const schema = ` 78 const schema = `
@@ -71,6 +81,17 @@ CREATE TABLE IF NOT EXISTS meta (
71 value TEXT NOT NULL 81 value TEXT NOT NULL
72 ); 82 );
73 83
84 -- tenant ids MUST remain DOT-FREE: the jump gate's connect name is
85 -- <tenant>.<name> and it splits on the FIRST dot (see sshgate/gate.go), while VM
86 -- names are RFC1123 labels (also dot-free). A dotted tenant id would make the
87 -- split ambiguous. Enforce this at tenant CRUD when it lands (no CRUD yet — the
88 -- only tenant is the seeded 'default').
89 CREATE TABLE IF NOT EXISTS tenants (
90 id TEXT PRIMARY KEY,
91 name TEXT NOT NULL,
92 created_at DATETIME NOT NULL
93 );
94
74 CREATE TABLE IF NOT EXISTS hosts ( 95 CREATE TABLE IF NOT EXISTS hosts (
75 id TEXT PRIMARY KEY, 96 id TEXT PRIMARY KEY,
76 name TEXT NOT NULL, 97 name TEXT NOT NULL,
@@ -80,13 +101,15 @@ CREATE TABLE IF NOT EXISTS hosts (
80 bridge_cidr TEXT NOT NULL, 101 bridge_cidr TEXT NOT NULL,
81 status TEXT NOT NULL DEFAULT 'enrolled', 102 status TEXT NOT NULL DEFAULT 'enrolled',
82 enrolled_at DATETIME NOT NULL, 103 enrolled_at DATETIME NOT NULL,
83 cred_generation INTEGER NOT NULL DEFAULT 1 104 cred_generation INTEGER NOT NULL DEFAULT 1,
105 tenant TEXT NOT NULL DEFAULT 'default' REFERENCES tenants(id)
84 ); 106 );
85 107
86 CREATE TABLE IF NOT EXISTS enrollment_tokens ( 108 CREATE TABLE IF NOT EXISTS enrollment_tokens (
87 token_hash TEXT PRIMARY KEY, 109 token_hash TEXT PRIMARY KEY,
88 expires_at DATETIME NOT NULL, 110 expires_at DATETIME NOT NULL,
89 used_at DATETIME 111 used_at DATETIME,
112 tenant TEXT NOT NULL DEFAULT 'default'
90 ); 113 );
91 114
92 CREATE TABLE IF NOT EXISTS vms ( 115 CREATE TABLE IF NOT EXISTS vms (
@@ -108,10 +131,12 @@ CREATE TABLE IF NOT EXISTS vms (
108 last_error TEXT NOT NULL DEFAULT '', 131 last_error TEXT NOT NULL DEFAULT '',
109 assigned_ip TEXT NOT NULL DEFAULT '', 132 assigned_ip TEXT NOT NULL DEFAULT '',
110 created_at DATETIME NOT NULL, 133 created_at DATETIME NOT NULL,
111 deleted_at DATETIME 134 deleted_at DATETIME,
135 tenant TEXT NOT NULL DEFAULT 'default' REFERENCES tenants(id)
112 ); 136 );
113 137
114 CREATE UNIQUE INDEX IF NOT EXISTS vms_name ON vms(name) WHERE deleted_at IS NULL; 138 -- vms name uniqueness is per-tenant.
139 CREATE UNIQUE INDEX IF NOT EXISTS vms_tenant_name ON vms(tenant, name) WHERE deleted_at IS NULL;
115 140
116 -- bridge CIDRs returned to the pool by host decommission, available for reuse 141 -- bridge CIDRs returned to the pool by host decommission, available for reuse
117 -- before the monotonic next_cidr_index allocator is consulted. 142 -- before the monotonic next_cidr_index allocator is consulted.
@@ -137,6 +162,11 @@ CREATE TABLE IF NOT EXISTS revoked_ssh_certs (
137 -- config). The at column is second-precision UTC RFC3339, which makes 162 -- config). The at column is second-precision UTC RFC3339, which makes
138 -- lexicographic comparison chronological -- PruneAudit's DELETE relies on 163 -- lexicographic comparison chronological -- PruneAudit's DELETE relies on
139 -- every writer keeping that format. 164 -- every writer keeping that format.
165 --
166 -- No tenant column BY DESIGN: some rows are genuinely tenant-less — e.g.
167 -- host.enroll.denied written from an UNAUTHENTICATED enroll attempt, before any
168 -- tenant is known. Audit read therefore stays Fleet-gated (handleListAudit);
169 -- per-tenant audit is a recorded tenant-#2 blocker (spec §Deferred).
140 CREATE TABLE IF NOT EXISTS audit_log ( 170 CREATE TABLE IF NOT EXISTS audit_log (
141 id INTEGER PRIMARY KEY AUTOINCREMENT, 171 id INTEGER PRIMARY KEY AUTOINCREMENT,
142 at DATETIME NOT NULL, 172 at DATETIME NOT NULL,
@@ -161,6 +191,13 @@ func Open(path, cidrPool string) (*Store, error) {
161 return nil, fmt.Errorf("apply schema: %w", err) 191 return nil, fmt.Errorf("apply schema: %w", err)
162 } 192 }
163 193
194 // Seed the bootstrap tenant; first call wins, like cidr_pool below.
195 if _, err := db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING`,
196 DefaultTenant, DefaultTenant, time.Now().UTC().Format(time.RFC3339)); err != nil {
197 db.Close()
198 return nil, fmt.Errorf("seed default tenant: %w", err)
199 }
200
164 // Store cidr_pool; ON CONFLICT DO NOTHING means the first call wins. 201 // Store cidr_pool; ON CONFLICT DO NOTHING means the first call wins.
165 if _, err := db.Exec(`INSERT INTO meta(key, value) VALUES ('cidr_pool', ?) ON CONFLICT DO NOTHING`, cidrPool); err != nil { 202 if _, err := db.Exec(`INSERT INTO meta(key, value) VALUES ('cidr_pool', ?) ON CONFLICT DO NOTHING`, cidrPool); err != nil {
166 db.Close() 203 db.Close()
@@ -210,14 +247,26 @@ func subnetForIndex(pool netip.Prefix, idx int64) (string, error) {
210 return fmt.Sprintf("%d.%d.%d.0/24", start>>24&0xff, start>>16&0xff, start>>8&0xff), nil 247 return fmt.Sprintf("%d.%d.%d.0/24", start>>24&0xff, start>>16&0xff, start>>8&0xff), nil
211 } 248 }
212 249
213 func (s *Store) CreateEnrollmentToken() (string, error) { 250 // CreateEnrollmentToken mints a single-use enroll token bound to tenant. The
251 // enroll endpoint is unauthenticated, so the token IS the tenant credential —
252 // the enrolling host lands in this tenant. Tenant existence is checked here
253 // (the column carries no FK; the table is transient).
254 func (s *Store) CreateEnrollmentToken(tenant string) (string, error) {
255 var one int
256 if err := s.db.QueryRow(`SELECT 1 FROM tenants WHERE id=?`, tenant).Scan(&one); err != nil {
257 if errors.Is(err, sql.ErrNoRows) {
258 return "", fmt.Errorf("unknown tenant %q", tenant)
259 }
260 return "", fmt.Errorf("check tenant: %w", err)
261 }
262
214 tok := random.Hex(32) 263 tok := random.Hex(32)
215 h := sha256.Sum256([]byte(tok)) 264 h := sha256.Sum256([]byte(tok))
216 hash := hex.EncodeToString(h[:]) 265 hash := hex.EncodeToString(h[:])
217 expiresAt := time.Now().UTC().Add(15 * time.Minute) 266 expiresAt := time.Now().UTC().Add(15 * time.Minute)
218 _, err := s.db.Exec( 267 _, err := s.db.Exec(
219 `INSERT INTO enrollment_tokens(token_hash, expires_at) VALUES (?, ?)`, 268 `INSERT INTO enrollment_tokens(token_hash, expires_at, tenant) VALUES (?, ?, ?)`,
220 hash, expiresAt.Format(time.RFC3339), 269 hash, expiresAt.Format(time.RFC3339), tenant,
221 ) 270 )
222 if err != nil { 271 if err != nil {
223 return "", fmt.Errorf("insert token: %w", err) 272 return "", fmt.Errorf("insert token: %w", err)
@@ -225,10 +274,11 @@ func (s *Store) CreateEnrollmentToken() (string, error) {
225 return tok, nil 274 return tok, nil
226 } 275 }
227 276
228 // RedeemEnrollmentToken atomically consumes tok and creates the host row. 277 // RedeemEnrollmentToken atomically consumes tok and creates the host row. The
229 // remote (the enrolling client's IP) is recorded in a host.enroll audit row 278 // created host inherits the tenant bound to the consumed token. remote (the
230 // written in the SAME transaction, so an enrolled host can never exist 279 // enrolling client's IP) is recorded in a host.enroll audit row written in
231 // without its durable audit record. 280 // the SAME transaction, so an enrolled host can never exist without its
281 // durable audit record.
232 func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remote string) (Host, error) { 282 func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remote string) (Host, error) {
233 h := sha256.Sum256([]byte(tok)) 283 h := sha256.Sum256([]byte(tok))
234 hash := hex.EncodeToString(h[:]) 284 hash := hex.EncodeToString(h[:])
@@ -252,6 +302,13 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo
252 return Host{}, fmt.Errorf("token invalid, expired, or already used") 302 return Host{}, fmt.Errorf("token invalid, expired, or already used")
253 } 303 }
254 304
305 // The consumed token's tenant becomes the host's — the derivation chain
306 // (token → host → VM) starts here.
307 var tenant string
308 if err := tx.QueryRow(`SELECT tenant FROM enrollment_tokens WHERE token_hash=?`, hash).Scan(&tenant); err != nil {
309 return Host{}, fmt.Errorf("read token tenant: %w", err)
310 }
311
255 var cidrPool string 312 var cidrPool string
256 var nextIdx int64 313 var nextIdx int64
257 if err := tx.QueryRow(`SELECT value FROM meta WHERE key='cidr_pool'`).Scan(&cidrPool); err != nil { 314 if err := tx.QueryRow(`SELECT value FROM meta WHERE key='cidr_pool'`).Scan(&cidrPool); err != nil {
@@ -288,8 +345,8 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo
288 id := random.Hex(16) 345 id := random.Hex(16)
289 346
290 if _, err := tx.Exec( 347 if _, err := tx.Exec(
291 `INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at) VALUES (?,?,?,?,?,?,?)`, 348 `INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at, tenant) VALUES (?,?,?,?,?,?,?,?)`,
292 id, name, osName, arch, provisioner, bridgeCIDR, now.Format(time.RFC3339), 349 id, name, osName, arch, provisioner, bridgeCIDR, now.Format(time.RFC3339), tenant,
293 ); err != nil { 350 ); err != nil {
294 return Host{}, fmt.Errorf("insert host: %w", err) 351 return Host{}, fmt.Errorf("insert host: %w", err)
295 } 352 }
@@ -320,6 +377,7 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo
320 Status: "enrolled", 377 Status: "enrolled",
321 CredGeneration: 1, 378 CredGeneration: 1,
322 EnrolledAt: now, 379 EnrolledAt: now,
380 Tenant: tenant,
323 }, nil 381 }, nil
324 } 382 }
325 383
@@ -327,8 +385,8 @@ func (s *Store) GetHost(id string) (Host, error) {
327 var h Host 385 var h Host
328 var enrolledAt string 386 var enrolledAt string
329 err := s.db.QueryRow( 387 err := s.db.QueryRow(
330 `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation FROM hosts WHERE id=?`, id, 388 `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant FROM hosts WHERE id=?`, id,
331 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration) 389 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant)
332 if err != nil { 390 if err != nil {
333 return Host{}, err 391 return Host{}, err
334 } 392 }
@@ -372,7 +430,7 @@ type querier interface {
372 func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } 430 func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) }
373 431
374 func listHosts(q querier) ([]Host, error) { 432 func listHosts(q querier) ([]Host, error) {
375 rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation FROM hosts`) 433 rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant FROM hosts`)
376 if err != nil { 434 if err != nil {
377 return nil, err 435 return nil, err
378 } 436 }
@@ -381,7 +439,7 @@ func listHosts(q querier) ([]Host, error) {
381 for rows.Next() { 439 for rows.Next() {
382 var h Host 440 var h Host
383 var enrolledAt string 441 var enrolledAt string
384 if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration); err != nil { 442 if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant); err != nil {
385 return nil, err 443 return nil, err
386 } 444 }
387 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) 445 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt)
@@ -402,10 +460,12 @@ func (s *Store) CreateVM(vm VM) error {
402 vm.ID = random.Hex(16) 460 vm.ID = random.Hex(16)
403 } 461 }
404 462
405 // Refuse to place a VM on a host that is not enrolled (e.g. mid-decommission); 463 // Refuse to place a VM on a host that is not enrolled, and read the host's
406 // the FK below only proves the host row exists, not that it accepts new VMs. 464 // tenant in the SAME tx: a VM's tenant is ALWAYS its host's — derived
407 var hostStatus string 465 // here, never accepted from the caller (the API handler cannot override
408 switch err := tx.QueryRow(`SELECT status FROM hosts WHERE id=?`, vm.HostID).Scan(&hostStatus); { 466 // it; this is the enforcement point for the partition invariant).
467 var hostStatus, hostTenant string
468 switch err := tx.QueryRow(`SELECT status, tenant FROM hosts WHERE id=?`, vm.HostID).Scan(&hostStatus, &hostTenant); {
409 case errors.Is(err, sql.ErrNoRows): 469 case errors.Is(err, sql.ErrNoRows):
410 return ErrHostNotFound 470 return ErrHostNotFound
411 case err != nil: 471 case err != nil:
@@ -413,25 +473,38 @@ func (s *Store) CreateVM(vm VM) error {
413 case hostStatus != "enrolled": 473 case hostStatus != "enrolled":
414 return ErrHostNotEnrolled 474 return ErrHostNotEnrolled
415 } 475 }
476 vm.Tenant = hostTenant
416 477
417 _, err = tx.Exec( 478 _, err = tx.Exec(
418 `INSERT INTO vms(id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, 479 `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
419 ssh_host_key, ssh_host_cert, 480 ssh_host_key, ssh_host_cert,
420 vcpus, mem_mb, disk_gb, persistent, power_state, created_at) 481 vcpus, mem_mb, disk_gb, persistent, power_state, created_at)
421 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, 482 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
422 vm.ID, vm.HostID, vm.Name, vm.ImageURL, vm.ImageSHA256, 483 vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256,
423 vm.CloudInit, vm.SSHAuthorizedKey, 484 vm.CloudInit, vm.SSHAuthorizedKey,
424 vm.SSHHostKey, vm.SSHHostCert, 485 vm.SSHHostKey, vm.SSHHostCert,
425 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, 486 vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState,
426 now.Format(time.RFC3339), 487 now.Format(time.RFC3339),
427 ) 488 )
428 if err != nil { 489 if err != nil {
429 msg := err.Error() 490 // SQLITE_CONSTRAINT_UNIQUE (2067): the only UNIQUE constraint an
430 if strings.Contains(msg, "UNIQUE constraint failed: vms.name") { 491 // insert can trip besides the PK (which is 1555 and random-hex) is
431 return ErrNameTaken 492 // vms_tenant_name — a name collision within the tenant. Matched by
432 } 493 // errno, not message text: the message embeds the index's column list
433 if strings.Contains(msg, "FOREIGN KEY constraint failed") { 494 // and silently breaks on the next index change (review finding).
434 return ErrHostNotFound 495 var serr *sqlite.Error
496 if errors.As(err, &serr) {
497 switch serr.Code() {
498 case 2067: // SQLITE_CONSTRAINT_UNIQUE
499 return ErrNameTaken
500 case 787: // SQLITE_CONSTRAINT_FOREIGNKEY: tenant is derived in-tx
501 // from the host row above, and hosts.tenant itself carries an
502 // enforced FK, so vm.Tenant always references an existing
503 // tenant — it can't be what trips this. host_id -> hosts is
504 // the only FK left that a CreateVM insert can violate, so 787
505 // unambiguously means the host is gone.
506 return ErrHostNotFound
507 }
435 } 508 }
436 return fmt.Errorf("insert vm: %w", err) 509 return fmt.Errorf("insert vm: %w", err)
437 } 510 }
@@ -826,7 +899,7 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error {
826 // name-based). queryVMs is the sole caller, so adding a column is a single 899 // name-based). queryVMs is the sole caller, so adding a column is a single
827 // edit here plus scanVM — every VM query goes through it and can't drift out 900 // edit here plus scanVM — every VM query goes through it and can't drift out
828 // of lockstep. 901 // of lockstep.
829 const vmColumns = `id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, 902 const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key,
830 ssh_host_key, ssh_host_cert, 903 ssh_host_key, ssh_host_cert,
831 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, 904 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip,
832 created_at, deleted_at` 905 created_at, deleted_at`
@@ -838,7 +911,7 @@ func scanVM(rows *sql.Rows) (VM, error) {
838 var createdAt string 911 var createdAt string
839 var deletedAt sql.NullString 912 var deletedAt sql.NullString
840 err := rows.Scan( 913 err := rows.Scan(
841 &vm.ID, &vm.HostID, &vm.Name, &vm.ImageURL, &vm.ImageSHA256, 914 &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256,
842 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, 915 &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert,
843 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, 916 &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent,
844 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, 917 &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP,
@@ -857,9 +930,9 @@ func scanVM(rows *sql.Rows) (VM, error) {
857 930
858 // queryVMs runs a vmColumns-projected SELECT against vms, with where appended 931 // queryVMs runs a vmColumns-projected SELECT against vms, with where appended
859 // verbatim after `FROM vms` (e.g. " WHERE id=?", or "" for none) and args 932 // verbatim after `FROM vms` (e.g. " WHERE id=?", or "" for none) and args
860 // bound in order, scanning every matching row. listVMs, VMByName, GetVM, and 933 // bound in order, scanning every matching row. listVMs, VMByTenantName, GetVM,
861 // DesiredForHost all share this — they differ only in WHERE clause, row-count 934 // and DesiredForHost all share this — they differ only in WHERE clause,
862 // expectations, and whether q is *sql.DB or an in-flight *sql.Tx. 935 // row-count expectations, and whether q is *sql.DB or an in-flight *sql.Tx.
863 func queryVMs(q querier, where string, args ...any) ([]VM, error) { 936 func queryVMs(q querier, where string, args ...any) ([]VM, error) {
864 rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) 937 rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...)
865 if err != nil { 938 if err != nil {
@@ -881,12 +954,13 @@ func (s *Store) ListVMs() ([]VM, error) { return listVMs(s.db) }
881 954
882 func listVMs(q querier) ([]VM, error) { return queryVMs(q, "") } 955 func listVMs(q querier) ([]VM, error) { return queryVMs(q, "") }
883 956
884 // VMByName returns the live (non-tombstoned) VM with the given name. The 957 // VMByTenantName returns the live (non-tombstoned) VM with the given name
885 // vms_name unique index guarantees at most one match. sql.ErrNoRows ⇒ no such 958 // WITHIN tenant. The vms_tenant_name unique index guarantees at most one
886 // VM. Read-only; used by the SSH jump gate to resolve `ssh -J gate user@<name>` 959 // match. Resolution is tenant-scoped by construction: a name in another
887 // to a host/VM ID pair. 960 // tenant is sql.ErrNoRows, indistinguishable from absent. Used by the SSH
888 func (s *Store) VMByName(name string) (VM, error) { 961 // jump gate to resolve `ssh -J gate user@<tenant>.<name>`.
889 vms, err := queryVMs(s.db, ` WHERE name=? AND deleted_at IS NULL`, name) 962 func (s *Store) VMByTenantName(tenant, name string) (VM, error) {
963 vms, err := queryVMs(s.db, ` WHERE tenant=? AND name=? AND deleted_at IS NULL`, tenant, name)
890 if err != nil { 964 if err != nil {
891 return VM{}, err 965 return VM{}, err
892 } 966 }
@@ -897,7 +971,7 @@ func (s *Store) VMByName(name string) (VM, error) {
897 } 971 }
898 972
899 // GetVM returns the VM with the given id via a single indexed lookup on the 973 // GetVM returns the VM with the given id via a single indexed lookup on the
900 // primary key. Unlike VMByName it does NOT filter on deleted_at: the 974 // primary key. Unlike VMByTenantName it does NOT filter on deleted_at: the
901 // patch/delete/restore callers operate on VMs that may be tombstoned, so the 975 // patch/delete/restore callers operate on VMs that may be tombstoned, so the
902 // row must be found regardless of tombstone state. sql.ErrNoRows ⇒ no such VM. 976 // row must be found regardless of tombstone state. sql.ErrNoRows ⇒ no such VM.
903 func (s *Store) GetVM(id string) (VM, error) { 977 func (s *Store) GetVM(id string) (VM, error) {
internal/server/store/store_test.go
Old New
@@ -30,30 +30,30 @@ func newStore(t *testing.T) *Store {
30 30
31 func enrollHost(t *testing.T, s *Store) Host { 31 func enrollHost(t *testing.T, s *Store) Host {
32 t.Helper() 32 t.Helper()
33 tok, err := s.CreateEnrollmentToken() 33 tok, err := s.CreateEnrollmentToken(DefaultTenant)
34 require.NoError(t, err) 34 require.NoError(t, err)
35 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "") 35 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "")
36 require.NoError(t, err) 36 require.NoError(t, err)
37 return h 37 return h
38 } 38 }
39 39
40 func TestVMByName(t *testing.T) { 40 func TestVMByTenantName(t *testing.T) {
41 s := newStore(t) 41 s := newStore(t)
42 h := enrollHost(t, s) 42 h := enrollHost(t, s)
43 vm := makeVM(t, s, h, "web-1") 43 vm := makeVM(t, s, h, "web-1")
44 44
45 got, err := s.VMByName("web-1") 45 got, err := s.VMByTenantName(DefaultTenant, "web-1")
46 require.NoError(t, err) 46 require.NoError(t, err)
47 assert.Equal(t, vm.ID, got.ID) 47 assert.Equal(t, vm.ID, got.ID)
48 assert.Equal(t, h.ID, got.HostID) 48 assert.Equal(t, h.ID, got.HostID)
49 49
50 // Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false). 50 // Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false).
51 _, err = s.VMByName("nope") 51 _, err = s.VMByTenantName(DefaultTenant, "nope")
52 assert.ErrorIs(t, err, sql.ErrNoRows) 52 assert.ErrorIs(t, err, sql.ErrNoRows)
53 53
54 // Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM. 54 // Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM.
55 require.NoError(t, s.TombstoneVM(vm.ID)) 55 require.NoError(t, s.TombstoneVM(vm.ID))
56 _, err = s.VMByName("web-1") 56 _, err = s.VMByTenantName(DefaultTenant, "web-1")
57 assert.ErrorIs(t, err, sql.ErrNoRows) 57 assert.ErrorIs(t, err, sql.ErrNoRows)
58 } 58 }
59 59
@@ -72,7 +72,7 @@ func TestGetVM(t *testing.T) {
72 _, err = s.GetVM("no-such-id") 72 _, err = s.GetVM("no-such-id")
73 assert.ErrorIs(t, err, sql.ErrNoRows) 73 assert.ErrorIs(t, err, sql.ErrNoRows)
74 74
75 // Unlike VMByName, GetVM must still find a tombstoned row — the 75 // Unlike VMByTenantName, GetVM must still find a tombstoned row — the
76 // patch/delete/restore callers operate on VMs that may be tombstoned. 76 // patch/delete/restore callers operate on VMs that may be tombstoned.
77 require.NoError(t, s.TombstoneVM(vm.ID)) 77 require.NoError(t, s.TombstoneVM(vm.ID))
78 got, err = s.GetVM(vm.ID) 78 got, err = s.GetVM(vm.ID)
@@ -94,14 +94,14 @@ func TestVMHostKeyAndCertPersist(t *testing.T) {
94 })) 94 }))
95 95
96 // The private key + cert must round-trip through both read paths the 96 // The private key + cert must round-trip through both read paths the
97 // snapshot/gate rely on: DesiredForHost (agent-facing) and VMByName. 97 // snapshot/gate rely on: DesiredForHost (agent-facing) and VMByTenantName.
98 _, vms, err := s.DesiredForHost(h.ID) 98 _, vms, err := s.DesiredForHost(h.ID)
99 require.NoError(t, err) 99 require.NoError(t, err)
100 require.Len(t, vms, 1) 100 require.Len(t, vms, 1)
101 assert.Equal(t, keyPEM, vms[0].SSHHostKey) 101 assert.Equal(t, keyPEM, vms[0].SSHHostKey)
102 assert.Equal(t, cert, vms[0].SSHHostCert) 102 assert.Equal(t, cert, vms[0].SSHHostCert)
103 103
104 byName, err := s.VMByName("with-hostcert") 104 byName, err := s.VMByTenantName(DefaultTenant, "with-hostcert")
105 require.NoError(t, err) 105 require.NoError(t, err)
106 assert.Equal(t, keyPEM, byName.SSHHostKey) 106 assert.Equal(t, keyPEM, byName.SSHHostKey)
107 assert.Equal(t, cert, byName.SSHHostCert) 107 assert.Equal(t, cert, byName.SSHHostCert)
@@ -163,7 +163,7 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) {
163 163
164 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { 164 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
165 s := newStore(t) 165 s := newStore(t)
166 tok1, _ := s.CreateEnrollmentToken() 166 tok1, _ := s.CreateEnrollmentToken(DefaultTenant)
167 h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") 167 h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "")
168 require.NoError(t, err) 168 require.NoError(t, err)
169 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) 169 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)
@@ -171,7 +171,7 @@ func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
171 _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "") 171 _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "")
172 assert.Error(t, err, "token must be one-time use") 172 assert.Error(t, err, "token must be one-time use")
173 173
174 tok2, _ := s.CreateEnrollmentToken() 174 tok2, _ := s.CreateEnrollmentToken(DefaultTenant)
175 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") 175 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
176 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) 176 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)
177 } 177 }
@@ -258,7 +258,7 @@ func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) {
258 s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22") 258 s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22")
259 require.NoError(t, err) 259 require.NoError(t, err)
260 defer s.Close() 260 defer s.Close()
261 tok, _ := s.CreateEnrollmentToken() 261 tok, _ := s.CreateEnrollmentToken(DefaultTenant)
262 h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "") 262 h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "")
263 require.NoError(t, err) 263 require.NoError(t, err)
264 assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved") 264 assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved")
@@ -268,11 +268,11 @@ func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) {
268 s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1) 268 s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1)
269 require.NoError(t, err) 269 require.NoError(t, err)
270 defer s.Close() 270 defer s.Close()
271 tok1, _ := s.CreateEnrollmentToken() 271 tok1, _ := s.CreateEnrollmentToken(DefaultTenant)
272 h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") 272 h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "")
273 require.NoError(t, err) 273 require.NoError(t, err)
274 assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR) 274 assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR)
275 tok2, _ := s.CreateEnrollmentToken() 275 tok2, _ := s.CreateEnrollmentToken(DefaultTenant)
276 _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") 276 _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
277 assert.ErrorContains(t, err, "exhausted") 277 assert.ErrorContains(t, err, "exhausted")
278 } 278 }
@@ -310,7 +310,7 @@ func TestForceRemoveHostPurgesVMsAndFreesCIDR(t *testing.T) {
310 310
311 // The freed CIDR is consulted before the monotonic allocator, so a fresh 311 // The freed CIDR is consulted before the monotonic allocator, so a fresh
312 // enrollment reuses it. 312 // enrollment reuses it.
313 tok, err := s.CreateEnrollmentToken() 313 tok, err := s.CreateEnrollmentToken(DefaultTenant)
314 require.NoError(t, err) 314 require.NoError(t, err)
315 h2, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "") 315 h2, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "")
316 require.NoError(t, err) 316 require.NoError(t, err)
@@ -482,7 +482,7 @@ func TestListVMEvents(t *testing.T) {
482 // enrolled host can never exist without its durable audit record. 482 // enrolled host can never exist without its durable audit record.
483 func TestRedeemWritesAuditRowAtomically(t *testing.T) { 483 func TestRedeemWritesAuditRowAtomically(t *testing.T) {
484 s := newStore(t) 484 s := newStore(t)
485 tok, err := s.CreateEnrollmentToken() 485 tok, err := s.CreateEnrollmentToken(DefaultTenant)
486 require.NoError(t, err) 486 require.NoError(t, err)
487 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "192.0.2.9") 487 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "192.0.2.9")
488 require.NoError(t, err) 488 require.NoError(t, err)
@@ -569,3 +569,87 @@ func TestPruneAuditGuardsNonPositiveWindow(t *testing.T) {
569 require.NoError(t, err) 569 require.NoError(t, err)
570 assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window") 570 assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window")
571 } 571 }
572
573 func TestOpenSeedsDefaultTenant(t *testing.T) {
574 s := newStore(t)
575 var name string
576 require.NoError(t, s.db.QueryRow(`SELECT name FROM tenants WHERE id=?`, DefaultTenant).Scan(&name))
577 assert.Equal(t, "default", name)
578 // enrollment_tokens carries the tenant the enrolling host will join.
579 var tenantCol int
580 require.NoError(t, s.db.QueryRow(
581 `SELECT count(*) FROM pragma_table_info('enrollment_tokens') WHERE name='tenant'`).Scan(&tenantCol))
582 assert.Equal(t, 1, tenantCol)
583 }
584
585 // insertTenantHost plants a tenant + host row directly, bypassing enrollment.
586 func insertTenantHost(t *testing.T, s *Store, tenant, hostID string) {
587 t.Helper()
588 _, err := s.db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING`,
589 tenant, tenant, "2026-01-01T00:00:00Z")
590 require.NoError(t, err)
591 _, err = s.db.Exec(`INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at, tenant)
592 VALUES (?, ?, 'linux', 'amd64', 'cloudhv', ?, '2026-01-01T00:00:00Z', ?)`,
593 hostID, "host-"+hostID, "10.77.0.0/24", tenant)
594 require.NoError(t, err)
595 }
596
597 func TestCreateVMDerivesTenantFromHost(t *testing.T) {
598 s := newStore(t)
599 insertTenantHost(t, s, "t2", "h-t2")
600 // Client-supplied Tenant must be IGNORED — derivation is authoritative.
601 require.NoError(t, s.CreateVM(VM{ID: "v1", HostID: "h-t2", Name: "web", ImageURL: "u", ImageSHA256: "s",
602 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Tenant: "attacker-chosen"}))
603 vm, err := s.GetVM("v1")
604 require.NoError(t, err)
605 assert.Equal(t, "t2", vm.Tenant)
606 }
607
608 func TestVMNameUniquePerTenant(t *testing.T) {
609 s := newStore(t)
610 insertTenantHost(t, s, "t2", "h-t2")
611 insertTenantHost(t, s, "t3", "h-t3")
612 mk := func(id, host, name string) error {
613 return s.CreateVM(VM{ID: id, HostID: host, Name: name, ImageURL: "u", ImageSHA256: "s",
614 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})
615 }
616 require.NoError(t, mk("v1", "h-t2", "web"))
617 require.NoError(t, mk("v2", "h-t3", "web"), "same name in a DIFFERENT tenant must be allowed")
618 assert.ErrorIs(t, mk("v3", "h-t2", "web"), ErrNameTaken, "same name in the SAME tenant must collide")
619 }
620
621 func TestVMByTenantNameIsScoped(t *testing.T) {
622 s := newStore(t)
623 insertTenantHost(t, s, "t2", "h-t2")
624 insertTenantHost(t, s, "t3", "h-t3")
625 require.NoError(t, s.CreateVM(VM{ID: "v1", HostID: "h-t2", Name: "web", ImageURL: "u", ImageSHA256: "s",
626 VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
627 got, err := s.VMByTenantName("t2", "web")
628 require.NoError(t, err)
629 assert.Equal(t, "v1", got.ID)
630 _, err = s.VMByTenantName("t3", "web")
631 assert.ErrorIs(t, err, sql.ErrNoRows, "resolution must not cross tenants")
632 _, err = s.VMByTenantName(DefaultTenant, "web")
633 assert.ErrorIs(t, err, sql.ErrNoRows)
634 }
635
636 func TestEnrollmentCarriesTenantToHost(t *testing.T) {
637 s := newStore(t)
638 _, err := s.db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES ('t2','t2','2026-01-01T00:00:00Z')`)
639 require.NoError(t, err)
640
641 tok, err := s.CreateEnrollmentToken("t2")
642 require.NoError(t, err)
643 h, err := s.RedeemEnrollmentToken(tok, "box", "linux", "amd64", "cloudhv", "203.0.113.9")
644 require.NoError(t, err)
645 assert.Equal(t, "t2", h.Tenant, "host must inherit the token's tenant")
646 got, err := s.GetHost(h.ID)
647 require.NoError(t, err)
648 assert.Equal(t, "t2", got.Tenant)
649 }
650
651 func TestCreateEnrollmentTokenRejectsUnknownTenant(t *testing.T) {
652 s := newStore(t)
653 _, err := s.CreateEnrollmentToken("no-such-tenant")
654 require.ErrorContains(t, err, "unknown tenant", "minting for a nonexistent tenant must fail at mint, not at redeem")
655 }
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -43,7 +43,7 @@ func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture {
43 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 43 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
44 require.NoError(t, err) 44 require.NoError(t, err)
45 t.Cleanup(func() { st.Close() }) 45 t.Cleanup(func() { st.Close() })
46 tok, _ := st.CreateEnrollmentToken() 46 tok, _ := st.CreateEnrollmentToken(store.DefaultTenant)
47 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 47 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
48 require.NoError(t, err) 48 require.NoError(t, err)
49 49
@@ -453,7 +453,7 @@ func TestExpiredCredentialRejectedWhenMaxAgeSet(t *testing.T) {
453 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 453 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
454 require.NoError(t, err) 454 require.NoError(t, err)
455 t.Cleanup(func() { st.Close() }) 455 t.Cleanup(func() { st.Close() })
456 tok, _ := st.CreateEnrollmentToken() 456 tok, _ := st.CreateEnrollmentToken(store.DefaultTenant)
457 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 457 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
458 require.NoError(t, err) 458 require.NoError(t, err)
459 459
@@ -495,7 +495,7 @@ func TestMaxAgeEnforcedMidSession(t *testing.T) {
495 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 495 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
496 require.NoError(t, err) 496 require.NoError(t, err)
497 t.Cleanup(func() { st.Close() }) 497 t.Cleanup(func() { st.Close() })
498 tok, _ := st.CreateEnrollmentToken() 498 tok, _ := st.CreateEnrollmentToken(store.DefaultTenant)
499 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 499 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
500 require.NoError(t, err) 500 require.NoError(t, err)
501 501
web/src/lib/api-types.ts
Old New
@@ -950,6 +950,7 @@ export interface components {
950 }; 950 };
951 SSHCertResponse: { 951 SSHCertResponse: {
952 certificate: string; 952 certificate: string;
953 tenant: string;
953 }; 954 };
954 StateSnapshot: { 955 StateSnapshot: {
955 hosts: components["schemas"]["Host"][]; 956 hosts: components["schemas"]["Host"][];