a73x

043bd8dc

feat: the credential is the tenant

a73x   2026-07-29 18:30

Commit message
feat: the credential is the tenant

A caller never names their tenant: the PAT or session names it on the
API (POST/GET /api/v1/user-cas operate on the caller's own tenant), and
the SSH cert's CA names it at the gate (a bare VM connect name resolves
within the cert's tenant). The explicit forms — /tenants/{tenant} routes,
<tenant>.<vm> connect names, EITRI_TENANT, eitri ca upload <tenant> —
remain for the one ambiguous corner: a user CA or human belonging to
more than one tenant. Naming another tenant at the gate stays
indistinguishable from a nonexistent VM.

`eitri ssh` dials the namespaced <tenant>.<vm> name — a VM's host cert
carries exactly that one principal, and the CLI verifies the dialed name
against it under strict host checking — resolving the tenant from the
credential via /me when EITRI_TENANT is unset, so the user still never
has to know or type it.

The CLI's defaults are the hosted service — EITRI_URL falls back to
https://console.eitri.sh and EITRI_GATE to gate.eitri.sh:2222; self-hosters
and tests override via environment. A fresh laptop needs exactly one
variable: EITRI_TOKEN.

docs/openapi.json
Old New
@@ -1325,6 +1325,81 @@
1325 "summary": "Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak)." 1325 "summary": "Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak)."
1326 } 1326 }
1327 }, 1327 },
1328 "/api/v1/user-cas": {
1329 "get": {
1330 "responses": {
1331 "200": {
1332 "content": {
1333 "application/json": {
1334 "schema": {
1335 "items": {
1336 "$ref": "#/components/schemas/UserCA"
1337 },
1338 "type": "array"
1339 }
1340 }
1341 },
1342 "description": "success"
1343 },
1344 "default": {
1345 "content": {
1346 "text/plain": {
1347 "schema": {
1348 "type": "string"
1349 }
1350 }
1351 },
1352 "description": "error (plain text)"
1353 }
1354 },
1355 "security": [
1356 {
1357 "patToken": []
1358 }
1359 ],
1360 "summary": "List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint)."
1361 },
1362 "post": {
1363 "requestBody": {
1364 "content": {
1365 "application/json": {
1366 "schema": {
1367 "$ref": "#/components/schemas/UserCARequest"
1368 }
1369 }
1370 },
1371 "required": true
1372 },
1373 "responses": {
1374 "201": {
1375 "content": {
1376 "application/json": {
1377 "schema": {
1378 "$ref": "#/components/schemas/UserCAUploadResponse"
1379 }
1380 }
1381 },
1382 "description": "success"
1383 },
1384 "default": {
1385 "content": {
1386 "text/plain": {
1387 "schema": {
1388 "type": "string"
1389 }
1390 }
1391 },
1392 "description": "error (plain text)"
1393 }
1394 },
1395 "security": [
1396 {
1397 "patToken": []
1398 }
1399 ],
1400 "summary": "Register a BYO SSH user CA public key for the caller's own tenant; eitri never holds a user signing key."
1401 }
1402 },
1328 "/api/v1/vms": { 1403 "/api/v1/vms": {
1329 "get": { 1404 "get": {
1330 "responses": { 1405 "responses": {
docs/quickstart.md
Old New
@@ -49,8 +49,9 @@ browser serial console.
49 ### SSH in 49 ### SSH in
50 50
51 SSH access uses certificates signed by **your** CA — eitri never holds a 51 SSH access uses certificates signed by **your** CA — eitri never holds a
52 user key that can enter your VMs. On your laptop, download the client CLI, 52 user key that can enter your VMs. On your laptop, download the client CLI. It
53 then put a personal access token and your tenant handle in the environment: 53 targets the hosted service by default, so the only thing to set is a personal
54 access token:
54 55
55 ```sh 56 ```sh
56 V=v0.0.1 57 V=v0.0.1
@@ -58,23 +59,23 @@ curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd6
58 tar xzf eitri-cli_${V}_*.tar.gz 59 tar xzf eitri-cli_${V}_*.tar.gz
59 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri 60 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri
60 61
61 export EITRI_URL=https://console.eitri.sh
62 export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens 62 export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens
63 export EITRI_TENANT=<your-tenant> # shown on the console's Settings page, under Identity
64 ``` 63 ```
65 64
66 Register a user CA once — eitri gets the public key, never the private one: 65 Register a user CA once — eitri gets the public key, never the private one:
67 66
68 ```sh 67 ```sh
69 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" 68 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
70 eitri ca upload "$EITRI_TENANT" ~/.ssh/eitri_user_ca.pub 69 eitri ca upload ~/.ssh/eitri_user_ca.pub
71 ``` 70 ```
72 71
73 Then SSH in. Unlike self-hosting, the console and the SSH gate are 72 Your token names the tenant, so neither command needs one. In more than one
74 separate names, so set the gate too: 73 tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly.
74
75 Then SSH in. The console and gate are hosted defaults, and `eitri ssh` uses
76 your token to look up your tenant for the connect name — nothing else to set:
75 77
76 ```sh 78 ```sh
77 export EITRI_GATE=gate.eitri.sh:2222
78 eitri ssh <vm-name> 79 eitri ssh <vm-name>
79 eitri ssh <vm-name> uptime 80 eitri ssh <vm-name> uptime
80 ``` 81 ```
@@ -260,11 +261,11 @@ export EITRI_URL=http://192.0.2.10:8080
260 export EITRI_TOKEN=<pat> # mint one in the console → Settings 261 export EITRI_TOKEN=<pat> # mint one in the console → Settings
261 262
262 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" 263 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
263 eitri ca upload <your-tenant> ~/.ssh/eitri_user_ca.pub 264 eitri ca upload ~/.ssh/eitri_user_ca.pub
264 ``` 265 ```
265 266
266 `<your-tenant>` is the handle shown on the Settings page — derived from your 267 Your token names the tenant, so `ca upload` needs no handle. In more than one
267 email on first sign-in. 268 tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly.
268 269
269 **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the 270 **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the
270 default image. Status reads `creating` while the image downloads and the 271 default image. Status reads `creating` while the image downloads and the
@@ -273,17 +274,22 @@ guest boots, then `ready`. Power reads `running`, an IP appears, you're on.
273 ### SSH in 274 ### SSH in
274 275
275 ```sh 276 ```sh
276 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain 277 export EITRI_URL=http://192.0.2.10:8080 # set earlier, when you booted the VM
277 export EITRI_TENANT=<your-tenant> # same handle you uploaded the CA to 278 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain
278 279
279 eitri ssh <vm-name> 280 eitri ssh <vm-name>
280 eitri ssh <vm-name> uptime 281 eitri ssh <vm-name> uptime
281 ``` 282 ```
282 283
284 Unset, `EITRI_URL` and `EITRI_GATE` default to the hosted service at eitri.sh,
285 so self-hosting always sets them to your own box.
286
283 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with 287 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with
284 your CA, pins eitri's host CA, and jumps the gate to 288 your CA, pins eitri's host CA, and jumps the gate to `ubuntu@<tenant>.<vm-name>`
285 `ubuntu@<your-tenant>.<vm-name>`. No token. [ssh-access.md](ssh-access.md) 289 — the VM's host cert carries that one namespaced principal, so that is the name
286 shows it done by hand. 290 verified. You still type only `<vm-name>`; `eitri ssh` looks the tenant up from
291 your token (or `EITRI_TENANT`). [ssh-access.md](ssh-access.md) shows it done by
292 hand.
287 293
288 ### More 294 ### More
289 295
docs/ssh-access.md
Old New
@@ -20,16 +20,17 @@ a server compromise cannot mint user credentials.
20 20
21 ## Bring your own CA (once per tenant) 21 ## Bring your own CA (once per tenant)
22 22
23 Generate a user CA and register its **public** key with your tenant 23 Generate a user CA and register its **public** key with your tenant. The token
24 (`POST /api/v1/tenants/<tenant>/user-cas`, authenticated by a personal access 24 names the tenant (`POST /api/v1/user-cas`), so no handle is needed; a token that
25 token): 25 can act for more than one tenant pins one with `eitri ca upload <tenant> <key>`
26 (`POST /api/v1/tenants/<tenant>/user-cas`):
26 27
27 ```sh 28 ```sh
28 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA" 29 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA"
29 30
30 export EITRI_URL=https://eitri.example.com 31 export EITRI_URL=https://eitri.example.com
31 export EITRI_TOKEN=<personal-access-token> # mint one in the console → Settings 32 export EITRI_TOKEN=<personal-access-token> # mint one in the console → Settings
32 eitri ca upload <your-tenant> ~/.ssh/eitri_user_ca.pub 33 eitri ca upload ~/.ssh/eitri_user_ca.pub
33 ``` 34 ```
34 35
35 The CA's private key never leaves your machine; the server stores only the 36 The CA's private key never leaves your machine; the server stores only the
@@ -47,26 +48,36 @@ eitri ssh <vm-name> # opens a shell on the VM
47 eitri ssh <vm-name> uptime # runs a command and exits 48 eitri ssh <vm-name> uptime # runs a command and exits
48 ``` 49 ```
49 50
50 You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect 51 You pass the bare `<vm-name>`, but the name that reaches the wire is always the
51 name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `eitri ssh` 52 **gate connect name** `<tenant>.<vm-name>` — a VM's host cert carries exactly
52 builds it from `EITRI_TENANT`. 53 that one principal, and `eitri ssh` verifies the dialed name against it under
54 strict checking, so a bare name would fail host verification. (The gate itself
55 also *resolves* a bare name within the connection's tenant, but the VM's cert
56 does not, so the client sends the namespaced form.) You never need to know your
57 tenant: `eitri ssh` derives it from your credential via `/me`, or takes it from
58 `EITRI_TENANT` when set (offline, and the escape hatch for a CA registered in
59 more than one tenant).
53 60
54 Environment variables: 61 Environment variables:
55 62
56 | Var | Meaning | 63 | Var | Meaning |
57 | ------------- | --------------------------------------------------------- | 64 | ------------- | --------------------------------------------------------- |
58 | `EITRI_URL` | Base URL of the eitri server | 65 | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) |
59 | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`) | 66 | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`; default the hosted `gate.eitri.sh:2222`) |
60 | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) | 67 | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) |
61 | `EITRI_TENANT`| Tenant your CA was uploaded to (default `default`) | 68 | `EITRI_TOKEN` | Personal access token, used only to look up your tenant for the connect name (skipped when `EITRI_TENANT` is set) |
69 | `EITRI_TENANT`| Optional: pins the tenant (offline, and the escape hatch when your CA is registered in more than one); otherwise derived from the token |
62 | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | 70 | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) |
63 | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | 71 | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) |
64 72
65 No token: the helper needs no API credential — your signing CA *is* the 73 The SSH session authenticates with no API credential — your signing CA *is* the
66 credential. It generates `~/.ssh/id_ed25519` if missing, self-signs a 30-minute 74 credential. `eitri ssh` generates `~/.ssh/id_ed25519` if missing, self-signs a
67 cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the eitri host CA 75 30-minute cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the
68 and pins it as `@cert-authority *` in a dedicated known_hosts file, and execs 76 eitri host CA and pins it as `@cert-authority *` in a dedicated known_hosts
69 `ssh` with both hops verified. 77 file, and execs `ssh` with both hops verified. The one thing it needs your
78 `EITRI_TOKEN` for is looking up your tenant to build the namespaced connect
79 name — set `EITRI_TENANT` instead to skip that call (offline, or the
80 multi-tenant pin).
70 81
71 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, 82 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal,
72 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of 83 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of
internal/cli/ca.go
Old New
@@ -10,11 +10,13 @@ import (
10 "github.com/a73x/eitri/internal/server/api/client" 10 "github.com/a73x/eitri/internal/server/api/client"
11 ) 11 )
12 12
13 // UploadUserCA registers the tenant user-CA public key in pubFile with the 13 // UploadUserCA registers the user-CA public key in pubFile with the server
14 // server (admin-authenticated). The key is parse-validated locally — garbage 14 // under the PAT token. An empty tenant registers on the token's own tenant (the
15 // is rejected before it leaves the machine — and the returned summary line 15 // credential names it); a non-empty tenant pins one explicitly. The key is
16 // carries the locally computed fingerprint (identical to the server's echo: 16 // parse-validated locally — garbage is rejected before it leaves the machine —
17 // both fingerprint the same key). 17 // and the returned summary line carries the locally computed fingerprint
18 // (identical to the server's echo: both fingerprint the same key), naming the
19 // tenant only when we pinned one.
18 func UploadUserCA(ctx context.Context, baseURL, token, tenant, pubFile string) (string, error) { 20 func UploadUserCA(ctx context.Context, baseURL, token, tenant, pubFile string) (string, error) {
19 pub, err := os.ReadFile(pubFile) 21 pub, err := os.ReadFile(pubFile)
20 if err != nil { 22 if err != nil {
@@ -28,5 +30,9 @@ func UploadUserCA(ctx context.Context, baseURL, token, tenant, pubFile string) (
28 if err := c.UploadUserCA(ctx, tenant, string(pub)); err != nil { 30 if err := c.UploadUserCA(ctx, tenant, string(pub)); err != nil {
29 return "", err 31 return "", err
30 } 32 }
31 return fmt.Sprintf("registered user CA %s for tenant %s", ssh.FingerprintSHA256(pk), tenant), nil 33 fp := ssh.FingerprintSHA256(pk)
34 if tenant == "" {
35 return fmt.Sprintf("registered user CA %s", fp), nil
36 }
37 return fmt.Sprintf("registered user CA %s for tenant %s", fp, tenant), nil
32 } 38 }
internal/cli/ca_test.go
Old New
@@ -54,6 +54,38 @@ func TestUploadUserCA(t *testing.T) {
54 } 54 }
55 } 55 }
56 56
57 // An empty tenant targets the tenant-less endpoint (the token names the tenant)
58 // and the summary line makes no tenant claim.
59 func TestUploadUserCATokenNamedTenant(t *testing.T) {
60 var gotPath string
61 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62 gotPath = r.URL.Path
63 w.Write([]byte(`{"fingerprint":"SHA256:server-echo"}`))
64 }))
65 defer srv.Close()
66
67 pub := filepath.Join(t.TempDir(), "ca.pub")
68 os.WriteFile(pub, []byte(testUserCALine), 0o644)
69
70 out, err := UploadUserCA(context.Background(), srv.URL, "tok123", "", pub)
71 if err != nil {
72 t.Fatal(err)
73 }
74 if gotPath != "/api/v1/user-cas" {
75 t.Errorf("path = %q, want /api/v1/user-cas", gotPath)
76 }
77 if strings.Contains(out, "for tenant") {
78 t.Errorf("out must not claim a tenant we did not pin: %q", out)
79 }
80 pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testUserCALine))
81 if err != nil {
82 t.Fatal(err)
83 }
84 if !strings.Contains(out, ssh.FingerprintSHA256(pk)) {
85 t.Errorf("out = %q", out)
86 }
87 }
88
57 func TestUploadUserCAErrorSurfacesBody(t *testing.T) { 89 func TestUploadUserCAErrorSurfacesBody(t *testing.T) {
58 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59 http.Error(w, "tenant not found", http.StatusNotFound) 91 http.Error(w, "tenant not found", http.StatusNotFound)
internal/cli/env.go
Old New
@@ -6,39 +6,58 @@
6 package cli 6 package cli
7 7
8 import ( 8 import (
9 "fmt"
10 "os" 9 "os"
11 "path/filepath" 10 "path/filepath"
12 ) 11 )
13 12
13 // Hosted-service defaults: unset, the CLI targets the hosted service at
14 // eitri.sh, so a fresh laptop needs no configuration to reach it. Self-hosters
15 // and tests point elsewhere by setting the matching environment variable.
16 const (
17 defaultURL = "https://console.eitri.sh"
18 defaultGate = "gate.eitri.sh:2222"
19 )
20
14 // Env is the client configuration, resolved from EITRI_* variables. 21 // Env is the client configuration, resolved from EITRI_* variables.
15 type Env struct { 22 type Env struct {
16 URL string // server base URL (required) 23 URL string // server base URL (EITRI_URL, else the hosted default)
17 Gate string // gate host[:port] (required) 24 Gate string // gate host[:port] (EITRI_GATE, else the hosted default)
18 CA string // tenant user-CA private key path 25 CA string // tenant user-CA private key path
19 Tenant string 26 Tenant string
20 Key string // user SSH private key path 27 Key string // user SSH private key path
21 KnownHosts string // dedicated pin file — never the user's main known_hosts 28 KnownHosts string // dedicated pin file — never the user's main known_hosts
22 } 29 }
23 30
24 // FromEnv resolves the env contract shared with the former scripts: 31 // URLFromEnv is the server base URL: EITRI_URL, or the hosted-service default.
25 // EITRI_URL, EITRI_GATE and EITRI_TENANT are required (there is no implicit 32 // It is the SINGLE source of that default — every consumer (ssh and ca alike)
26 // tenant — connect names are <tenant>.<vm> and guessing one would fail with an 33 // resolves the URL through it, so the default string is written in exactly one
27 // opaque gate refusal); the path fields default under $HOME/.ssh. 34 // place.
35 func URLFromEnv() string {
36 if u := os.Getenv("EITRI_URL"); u != "" {
37 return u
38 }
39 return defaultURL
40 }
41
42 // FromEnv resolves the client configuration. The CLI's defaults are the hosted
43 // service at eitri.sh: EITRI_URL falls back to the hosted console and
44 // EITRI_GATE to the hosted gate, so nothing needs setting to reach it;
45 // self-hosters and tests override via the environment. EITRI_TENANT is optional
46 // — `eitri ssh` derives the tenant from the credential (via /me) to build the
47 // namespaced connect name, so it is only needed offline or to disambiguate a
48 // user CA registered in more than one tenant. The path fields default under
49 // $HOME/.ssh.
28 func FromEnv() (Env, error) { 50 func FromEnv() (Env, error) {
29 e := Env{ 51 e := Env{
30 URL: os.Getenv("EITRI_URL"), 52 URL: URLFromEnv(),
31 Gate: os.Getenv("EITRI_GATE"), 53 Gate: os.Getenv("EITRI_GATE"),
32 CA: os.Getenv("EITRI_CA"), 54 CA: os.Getenv("EITRI_CA"),
33 Tenant: os.Getenv("EITRI_TENANT"), 55 Tenant: os.Getenv("EITRI_TENANT"),
34 Key: os.Getenv("EITRI_KEY"), 56 Key: os.Getenv("EITRI_KEY"),
35 KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"), 57 KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"),
36 } 58 }
37 if e.URL == "" || e.Gate == "" { 59 if e.Gate == "" {
38 return Env{}, fmt.Errorf("set EITRI_URL (server base URL) and EITRI_GATE (gate host:port)") 60 e.Gate = defaultGate
39 }
40 if e.Tenant == "" {
41 return Env{}, fmt.Errorf("set EITRI_TENANT (your tenant handle — shown on the console's Settings page)")
42 } 61 }
43 home, err := os.UserHomeDir() 62 home, err := os.UserHomeDir()
44 if err != nil { 63 if err != nil {
internal/cli/env_test.go
Old New
@@ -2,7 +2,6 @@ package cli
2 2
3 import ( 3 import (
4 "path/filepath" 4 "path/filepath"
5 "strings"
6 "testing" 5 "testing"
7 ) 6 )
8 7
@@ -33,26 +32,33 @@ func TestFromEnvDefaults(t *testing.T) {
33 } 32 }
34 } 33 }
35 34
36 func TestFromEnvRequiresURLAndGate(t *testing.T) { 35 func TestFromEnvHostedDefaults(t *testing.T) {
37 t.Setenv("EITRI_URL", "") 36 // A clean environment targets the hosted service at eitri.sh — no
38 t.Setenv("EITRI_GATE", "") 37 // configuration needed to reach it (TestFromEnvDefaults proves the env
39 if _, err := FromEnv(); err == nil { 38 // still overrides these).
40 t.Fatal("want error without EITRI_URL/EITRI_GATE") 39 for _, v := range []string{"EITRI_URL", "EITRI_GATE", "EITRI_TENANT", "EITRI_CA", "EITRI_KEY", "EITRI_KNOWN_HOSTS"} {
40 t.Setenv(v, "")
41 }
42 e, err := FromEnv()
43 if err != nil {
44 t.Fatalf("clean env must succeed: %v", err)
45 }
46 if e.URL != "https://console.eitri.sh" || e.Gate != "gate.eitri.sh:2222" {
47 t.Errorf("hosted defaults: URL=%q Gate=%q", e.URL, e.Gate)
41 } 48 }
42 } 49 }
43 50
44 func TestFromEnvRequiresTenant(t *testing.T) { 51 func TestFromEnvTenantOptional(t *testing.T) {
45 // There is no implicit tenant: connect names are <tenant>.<vm>, and a 52 // EITRI_TENANT is optional: `eitri ssh` derives the tenant from the
46 // guessed tenant would surface as an opaque gate refusal instead of this 53 // credential when it is unset, so an unset tenant is not a FromEnv error.
47 // actionable error.
48 t.Setenv("EITRI_URL", "http://192.0.2.10:8080") 54 t.Setenv("EITRI_URL", "http://192.0.2.10:8080")
49 t.Setenv("EITRI_GATE", "192.0.2.10:2222") 55 t.Setenv("EITRI_GATE", "192.0.2.10:2222")
50 t.Setenv("EITRI_TENANT", "") 56 t.Setenv("EITRI_TENANT", "")
51 _, err := FromEnv() 57 e, err := FromEnv()
52 if err == nil { 58 if err != nil {
53 t.Fatal("want error without EITRI_TENANT") 59 t.Fatalf("unset EITRI_TENANT must be accepted: %v", err)
54 } 60 }
55 if !strings.Contains(err.Error(), "EITRI_TENANT") { 61 if e.Tenant != "" {
56 t.Errorf("error must name the missing variable: %v", err) 62 t.Errorf("tenant = %q, want empty", e.Tenant)
57 } 63 }
58 } 64 }
internal/cli/main.go
Old New
@@ -18,9 +18,11 @@ const Usage = `usage:
18 eitri ca upload [<tenant>] <ca-public-key-file> 18 eitri ca upload [<tenant>] <ca-public-key-file>
19 eitri --version 19 eitri --version
20 20
21 env: EITRI_URL, EITRI_GATE, EITRI_TENANT (required for ssh); 21 env: all optional — EITRI_URL and EITRI_GATE default to the hosted service at
22 EITRI_URL, EITRI_TOKEN and a tenant (argument or EITRI_TENANT) for ca; 22 eitri.sh (self-hosters and tests override). ca needs EITRI_TOKEN (a PAT);
23 EITRI_CA, EITRI_KEY, EITRI_KNOWN_HOSTS (optional, default under ~/.ssh)` 23 its tenant is optional (argument, else EITRI_TENANT, else the token's own).
24 EITRI_TENANT disambiguates a user CA registered in more than one tenant.
25 EITRI_CA, EITRI_KEY, EITRI_KNOWN_HOSTS default under ~/.ssh.`
24 26
25 // ErrUsage marks a bad invocation: the caller prints Usage and exits 2 rather 27 // ErrUsage marks a bad invocation: the caller prints Usage and exits 2 rather
26 // than treating it as a runtime failure. 28 // than treating it as a runtime failure.
@@ -66,21 +68,18 @@ func runCA(args []string, stdout io.Writer) error {
66 if len(rest) < 1 || len(rest) > 2 { 68 if len(rest) < 1 || len(rest) > 2 {
67 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>") 69 return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>")
68 } 70 }
69 // The tenant comes from the positional when given, else EITRI_TENANT. 71 // One arg: the token names the tenant, unless EITRI_TENANT pins one. Two
70 // There is no implicit tenant (a guess would surface as an opaque 404). 72 // args: the leading positional pins the tenant explicitly (for a token that
73 // can act for more than one).
71 tenant, pub := os.Getenv("EITRI_TENANT"), rest[0] 74 tenant, pub := os.Getenv("EITRI_TENANT"), rest[0]
72 if len(rest) == 2 { 75 if len(rest) == 2 {
73 tenant, pub = rest[0], rest[1] 76 tenant, pub = rest[0], rest[1]
74 } 77 }
75 if tenant == "" {
76 return fmt.Errorf("no tenant: pass one (eitri ca upload <tenant> <file>) or set EITRI_TENANT")
77 }
78 url := os.Getenv("EITRI_URL")
79 token := os.Getenv("EITRI_TOKEN") 78 token := os.Getenv("EITRI_TOKEN")
80 if url == "" || token == "" { 79 if token == "" {
81 return fmt.Errorf("set EITRI_URL and EITRI_TOKEN (a personal access token)") 80 return fmt.Errorf("set EITRI_TOKEN (a personal access token)")
82 } 81 }
83 out, err := UploadUserCA(context.Background(), url, token, tenant, pub) 82 out, err := UploadUserCA(context.Background(), URLFromEnv(), token, tenant, pub)
84 if err != nil { 83 if err != nil {
85 return err 84 return err
86 } 85 }
internal/cli/main_test.go
Old New
@@ -27,9 +27,12 @@ func TestMainDispatch(t *testing.T) {
27 } 27 }
28 } 28 }
29 29
30 // TestRunCATenantResolution pins how `eitri ca upload` finds its tenant: 30 // TestRunCATenantResolution pins how `eitri ca upload` finds its tenant: a
31 // positional wins, EITRI_TENANT is the fallback, and no tenant at all is an 31 // leading positional pins it, EITRI_TENANT is the fallback, and with neither the
32 // actionable error before anything touches the network. 32 // token names it — no tenant is needed. EITRI_URL defaults to the hosted
33 // service, so the only network variable left to require is EITRI_TOKEN; each
34 // path reaching that check proves tenant resolution passed rather than
35 // short-circuiting on a missing tenant.
33 func TestRunCATenantResolution(t *testing.T) { 36 func TestRunCATenantResolution(t *testing.T) {
34 var out bytes.Buffer 37 var out bytes.Buffer
35 t.Setenv("EITRI_URL", "") 38 t.Setenv("EITRI_URL", "")
@@ -43,21 +46,21 @@ func TestRunCATenantResolution(t *testing.T) {
43 t.Errorf("upload with no args: got %v", err) 46 t.Errorf("upload with no args: got %v", err)
44 } 47 }
45 48
46 // No positional tenant, no EITRI_TENANT: the error names both remedies. 49 // One arg, no EITRI_TENANT: the token names the tenant, so resolution is not
50 // an error — it falls through to the token check.
47 err := Main([]string{"ca", "upload", "ca.pub"}, &out) 51 err := Main([]string{"ca", "upload", "ca.pub"}, &out)
48 if err == nil || !strings.Contains(err.Error(), "EITRI_TENANT") { 52 if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") {
49 t.Errorf("tenantless: got %v", err) 53 t.Errorf("token-named tenant: got %v", err)
50 } 54 }
51 55
52 // A tenant (positional or env) but no URL/token: fails on the env contract 56 // Two args pin the tenant explicitly; still falls through to the token check.
53 // — proving tenant resolution passed.
54 err = Main([]string{"ca", "upload", "acme", "ca.pub"}, &out) 57 err = Main([]string{"ca", "upload", "acme", "ca.pub"}, &out)
55 if err == nil || !strings.Contains(err.Error(), "EITRI_URL and EITRI_TOKEN") { 58 if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") {
56 t.Errorf("positional tenant: got %v", err) 59 t.Errorf("positional tenant: got %v", err)
57 } 60 }
58 t.Setenv("EITRI_TENANT", "acme") 61 t.Setenv("EITRI_TENANT", "acme")
59 err = Main([]string{"ca", "upload", "ca.pub"}, &out) 62 err = Main([]string{"ca", "upload", "ca.pub"}, &out)
60 if err == nil || !strings.Contains(err.Error(), "EITRI_URL and EITRI_TOKEN") { 63 if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") {
61 t.Errorf("env tenant: got %v", err) 64 t.Errorf("env tenant: got %v", err)
62 } 65 }
63 } 66 }
internal/cli/sshcmd.go
Old New
@@ -9,9 +9,17 @@ import (
9 "os/user" 9 "os/user"
10 "strings" 10 "strings"
11 "syscall" 11 "syscall"
12
13 "github.com/a73x/eitri/internal/server/api/client"
12 ) 14 )
13 15
14 // SSHArgv builds the system-ssh invocation for a session to <tenant>.<vm>. 16 // SSHArgv builds the system-ssh invocation for a session to a VM. The connect
17 // name sent on the wire is ALWAYS the namespaced <tenant>.<vm>: a VM's host cert
18 // carries exactly one principal, its <tenant>.<vm> name, and the final hop
19 // verifies the dialed name against that principal under strict checking, so a
20 // bare name would fail host verification (the gate resolves bare names, but the
21 // VM's cert does not). e.Tenant is resolved before this call (RunSSH derives it
22 // from the credential when unset), so the user still never types their tenant.
15 // The gate hop rides an explicit ProxyCommand — NOT -J — because command-line 23 // The gate hop rides an explicit ProxyCommand — NOT -J — because command-line
16 // -o options reach only the final hop; both hops must verify the presented 24 // -o options reach only the final hop; both hops must verify the presented
17 // host certificate against the pinned eitri CA with strict checking. The 25 // host certificate against the pinned eitri CA with strict checking. The
@@ -40,6 +48,33 @@ func SSHArgv(e Env, vm string, extra []string) []string {
40 return append(argv, extra...) 48 return append(argv, extra...)
41 } 49 }
42 50
51 // resolveTenant returns the tenant for the VM's gate connect name. EITRI_TENANT
52 // pins it — offline, and the escape hatch when a user CA lives in more than one
53 // tenant — and otherwise the CLI derives it from the credential via /me. The
54 // connect name must be the namespaced <tenant>.<vm> (the VM host cert's one
55 // principal, see SSHArgv), so the tenant is always needed; deriving it means the
56 // user never has to know or type it. Deriving needs EITRI_TOKEN; with neither
57 // the tenant nor a token, the connect name cannot be built.
58 func resolveTenant(e Env) (string, error) {
59 if e.Tenant != "" {
60 return e.Tenant, nil
61 }
62 token := os.Getenv("EITRI_TOKEN")
63 if token == "" {
64 return "", fmt.Errorf("set EITRI_TOKEN (a personal access token) or EITRI_TENANT — the connect name needs your tenant")
65 }
66 // client.Me is deliberately context-free — its own do-timeout bounds this
67 // one synchronous probe (see the method's doc), so there is no ctx to thread.
68 me, err := (&client.Client{BaseURL: e.URL, Token: token}).Me()
69 if err != nil {
70 return "", fmt.Errorf("resolving your tenant from the token: %w", err)
71 }
72 if me.Tenant == "" {
73 return "", fmt.Errorf("your token resolves to no tenant")
74 }
75 return me.Tenant, nil
76 }
77
43 // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via 78 // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via
44 // the user's shell). 79 // the user's shell).
45 func shq(s string) string { 80 func shq(s string) string {
@@ -60,6 +95,13 @@ func keyID() string {
60 // process with the system ssh. Exec (not a child process) so the TTY, signals, 95 // process with the system ssh. Exec (not a child process) so the TTY, signals,
61 // and exit code belong to ssh itself. 96 // and exit code belong to ssh itself.
62 func RunSSH(ctx context.Context, e Env, vm string, extra []string) error { 97 func RunSSH(ctx context.Context, e Env, vm string, extra []string) error {
98 // resolveTenant's only network call is the deliberately context-free
99 // client.Me, so there is no ctx for it to forward.
100 tenant, err := resolveTenant(e) //nolint:contextcheck
101 if err != nil {
102 return err
103 }
104 e.Tenant = tenant
63 if err := EnsureKeypair(e.Key); err != nil { 105 if err := EnsureKeypair(e.Key); err != nil {
64 return err 106 return err
65 } 107 }
internal/cli/sshcmd_test.go
Old New
@@ -1,6 +1,8 @@
1 package cli 1 package cli
2 2
3 import ( 3 import (
4 "net/http"
5 "net/http/httptest"
4 "slices" 6 "slices"
5 "strings" 7 "strings"
6 "testing" 8 "testing"
@@ -32,6 +34,103 @@ func TestSSHArgvTwoVerifiedHops(t *testing.T) {
32 } 34 }
33 } 35 }
34 36
37 // The wire name is ALWAYS the namespaced <tenant>.<vm> — a VM host cert's one
38 // principal — so SSHArgv never emits a bare target.
39 func TestSSHArgvAlwaysNamespaced(t *testing.T) {
40 got := SSHArgv(testEnv(), "dev", nil)
41 if want := "ubuntu@default.dev"; got[len(got)-1] != want {
42 t.Errorf("target = %q, want %q", got[len(got)-1], want)
43 }
44 }
45
46 // resolveTenant derives the tenant from the credential (via /me) when
47 // EITRI_TENANT is unset, so the namespaced connect name can be built without the
48 // user knowing their tenant.
49 func TestResolveTenantFromMe(t *testing.T) {
50 var gotAuth string
51 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 gotAuth = r.Header.Get("Authorization")
53 if r.URL.Path != "/api/v1/me" {
54 t.Errorf("path = %q, want /api/v1/me", r.URL.Path)
55 }
56 w.Write([]byte(`{"tenant":"acme","email":"me@acme.example"}`))
57 }))
58 defer srv.Close()
59 t.Setenv("EITRI_TOKEN", "tok123")
60
61 e := testEnv()
62 e.URL = srv.URL
63 e.Tenant = ""
64 got, err := resolveTenant(e)
65 if err != nil {
66 t.Fatal(err)
67 }
68 if got != "acme" {
69 t.Errorf("tenant = %q, want acme", got)
70 }
71 if gotAuth != "Bearer tok123" {
72 t.Errorf("auth = %q, want Bearer tok123", gotAuth)
73 }
74 }
75
76 // EITRI_TENANT pins the tenant with no HTTP call — offline and the multi-tenant
77 // escape hatch.
78 func TestResolveTenantEnvShortCircuits(t *testing.T) {
79 srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
80 t.Error("EITRI_TENANT must short-circuit — no /me call")
81 }))
82 defer srv.Close()
83
84 e := testEnv()
85 e.URL = srv.URL
86 e.Tenant = "team"
87 got, err := resolveTenant(e)
88 if err != nil {
89 t.Fatal(err)
90 }
91 if got != "team" {
92 t.Errorf("tenant = %q, want team", got)
93 }
94 }
95
96 // A credential that resolves to no tenant, or a failing /me, is a clear error.
97 func TestResolveTenantMeFailures(t *testing.T) {
98 t.Setenv("EITRI_TOKEN", "tok")
99
100 // Empty tenant in the /me response.
101 empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102 w.Write([]byte(`{"tenant":"","email":"x@y"}`))
103 }))
104 defer empty.Close()
105 e := testEnv()
106 e.URL, e.Tenant = empty.URL, ""
107 if _, err := resolveTenant(e); err == nil || !strings.Contains(err.Error(), "no tenant") {
108 t.Errorf("empty tenant: got %v", err)
109 }
110
111 // A non-2xx /me surfaces as an error naming the resolution step.
112 boom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
113 http.Error(w, "nope", http.StatusUnauthorized)
114 }))
115 defer boom.Close()
116 e.URL = boom.URL
117 if _, err := resolveTenant(e); err == nil || !strings.Contains(err.Error(), "resolving your tenant") {
118 t.Errorf("me error: got %v", err)
119 }
120 }
121
122 // With neither EITRI_TENANT nor EITRI_TOKEN the connect name cannot be built;
123 // the error names both remedies before any network call.
124 func TestResolveTenantNoTokenNoTenant(t *testing.T) {
125 t.Setenv("EITRI_TOKEN", "")
126 e := testEnv()
127 e.Tenant = ""
128 _, err := resolveTenant(e)
129 if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") || !strings.Contains(err.Error(), "EITRI_TENANT") {
130 t.Errorf("want error naming EITRI_TOKEN and EITRI_TENANT, got %v", err)
131 }
132 }
133
35 func TestSSHArgvGateDefaultPort(t *testing.T) { 134 func TestSSHArgvGateDefaultPort(t *testing.T) {
36 e := testEnv() 135 e := testEnv()
37 e.Gate = "gate.example" 136 e.Gate = "gate.example"
internal/server/api/client/client.go
Old New
@@ -190,10 +190,16 @@ func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) {
190 return pub, nil 190 return pub, nil
191 } 191 }
192 192
193 // UploadUserCA registers caLine (a BYO user-CA authorized_keys line) with 193 // UploadUserCA registers caLine (a BYO user-CA authorized_keys line) with a
194 // tenant, labeled with c.UserCALabel, so the tenant's VMs trust certs that CA 194 // tenant, labeled with c.UserCALabel, so the tenant's VMs trust certs that CA
195 // signs. Idempotent server-side. 195 // signs. An empty tenant targets the tenant-less endpoint, which registers on
196 // the CALLER'S OWN tenant (the credential names it); a non-empty tenant pins
197 // one explicitly. Idempotent server-side.
196 func (c *Client) UploadUserCA(ctx context.Context, tenant, caLine string) error { 198 func (c *Client) UploadUserCA(ctx context.Context, tenant, caLine string) error {
197 req := types.UserCARequest{PublicKey: caLine, Label: c.UserCALabel} 199 req := types.UserCARequest{PublicKey: caLine, Label: c.UserCALabel}
198 return c.do(ctx, http.MethodPost, "/api/v1/tenants/"+url.PathEscape(tenant)+"/user-cas", req, nil) 200 path := "/api/v1/user-cas"
201 if tenant != "" {
202 path = "/api/v1/tenants/" + url.PathEscape(tenant) + "/user-cas"
203 }
204 return c.do(ctx, http.MethodPost, path, req, nil)
199 } 205 }
internal/server/api/client/client_test.go
Old New
@@ -394,6 +394,21 @@ func TestUploadUserCA(t *testing.T) {
394 } 394 }
395 } 395 }
396 396
397 // An empty tenant targets the tenant-less endpoint, which registers on the
398 // caller's own tenant (the credential names it).
399 func TestUploadUserCAEmptyTenantHitsOwnEndpoint(t *testing.T) {
400 var cap capture
401 srv := serve(t, &cap, http.StatusCreated, `{"fingerprint":"SHA256:abc"}`)
402 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
403
404 if err := c.UploadUserCA(context.Background(), "", testCALine); err != nil {
405 t.Fatalf("UploadUserCA: %v", err)
406 }
407 if cap.method != http.MethodPost || cap.path != "/api/v1/user-cas" {
408 t.Errorf("request = %s %s, want POST /api/v1/user-cas", cap.method, cap.path)
409 }
410 }
411
397 func TestUploadUserCAEscapesTenant(t *testing.T) { 412 func TestUploadUserCAEscapesTenant(t *testing.T) {
398 var cap capture 413 var cap capture
399 srv := serve(t, &cap, http.StatusCreated, `{}`) 414 srv := serve(t, &cap, http.StatusCreated, `{}`)
internal/server/api/routes.go
Old New
@@ -266,6 +266,29 @@ var routeTable = []Route{
266 Doc: "Remove a registered SSH user CA by its public_key line.", 266 Doc: "Remove a registered SSH user CA by its public_key line.",
267 handler: (*API).handleDeleteUserCA, 267 handler: (*API).handleDeleteUserCA,
268 }, 268 },
269 // Tenant-less siblings that operate on the CALLER'S OWN tenant: the PAT or
270 // session already names the tenant, so no {tenant} rides in the path.
271 {
272 Method: "POST",
273 Path: "/api/v1/user-cas",
274 Auth: AuthUser,
275 Kind: KindJSON,
276 Request: (*types.UserCARequest)(nil),
277 Response: (*types.UserCAUploadResponse)(nil),
278 Success: http.StatusCreated,
279 Doc: "Register a BYO SSH user CA public key for the caller's own tenant; eitri never holds a user signing key.",
280 handler: (*API).handleUploadUserCA,
281 },
282 {
283 Method: "GET",
284 Path: "/api/v1/user-cas",
285 Auth: AuthUser,
286 Kind: KindJSON,
287 Response: []types.UserCA(nil),
288 Success: http.StatusOK,
289 Doc: "List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint).",
290 handler: (*API).handleListUserCAs,
291 },
269 // Revoke a minted user cert (by serial or cert line) and list revocations — 292 // Revoke a minted user cert (by serial or cert line) and list revocations —
270 // enforced at the gate before a cert's short TTL expires. Pure store ops, 293 // enforced at the gate before a cert's short TTL expires. Pure store ops,
271 // available regardless of whether the minter is wired. 294 // available regardless of whether the minter is wired.
internal/server/api/routes_test.go
Old New
@@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type {
34 // `required` array for request schemas, so a type serving both roles would 34 // `required` array for request schemas, so a type serving both roles would
35 // get the wrong treatment on one of them). 35 // get the wrong treatment on one of them).
36 func TestRouteTable(t *testing.T) { 36 func TestRouteTable(t *testing.T) {
37 const wantRoutes = 26 37 const wantRoutes = 28
38 if len(routeTable) != wantRoutes { 38 if len(routeTable) != wantRoutes {
39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) 39 t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes)
40 } 40 }
internal/server/api/usercas.go
Old New
@@ -8,13 +8,32 @@ import (
8 "golang.org/x/crypto/ssh" 8 "golang.org/x/crypto/ssh"
9 ) 9 )
10 10
11 // handleUploadUserCA registers a BYO user-CA public key for the {tenant} in the 11 // userCATenant picks the tenant a user-CA request operates on and authorizes
12 // path. eitri stores only the pubkey (canonical line) — it never holds a user 12 // the caller against it. The {tenant} path segment names it on the explicit
13 // signing key. Tenant-scoped: the caller must act for {tenant}. 13 // /tenants/{tenant} routes; on the tenant-less sibling routes there is no
14 func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) { 14 // segment and the caller's own credential names it. Either way mayActAs is the
15 // gate — an empty tenant (a principal with no tenant on a tenant-less route) is
16 // a 403. On refusal it writes the response and returns ok=false.
17 func (a *API) userCATenant(w http.ResponseWriter, r *http.Request) (string, bool) {
18 p := principalFromContext(r)
15 tenant := r.PathValue("tenant") 19 tenant := r.PathValue("tenant")
16 if !mayActAs(principalFromContext(r), tenant) { 20 if tenant == "" {
21 tenant = p.Tenant
22 }
23 if !mayActAs(p, tenant) {
17 http.Error(w, "forbidden", http.StatusForbidden) 24 http.Error(w, "forbidden", http.StatusForbidden)
25 return "", false
26 }
27 return tenant, true
28 }
29
30 // handleUploadUserCA registers a BYO user-CA public key for a tenant: the
31 // {tenant} path segment on the explicit route, or the caller's own tenant on
32 // the tenant-less sibling. eitri stores only the pubkey (canonical line) — it
33 // never holds a user signing key. The caller must act for that tenant.
34 func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) {
35 tenant, ok := a.userCATenant(w, r)
36 if !ok {
18 return 37 return
19 } 38 }
20 var req types.UserCARequest 39 var req types.UserCARequest
@@ -35,11 +54,12 @@ func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) {
35 writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)}) 54 writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)})
36 } 55 }
37 56
38 // handleListUserCAs lists a tenant's registered user CAs (pubkey + label + fp). 57 // handleListUserCAs lists a tenant's registered user CAs (pubkey + label + fp)
58 // — the {tenant} path segment on the explicit route, or the caller's own tenant
59 // on the tenant-less sibling.
39 func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) { 60 func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) {
40 tenant := r.PathValue("tenant") 61 tenant, ok := a.userCATenant(w, r)
41 if !mayActAs(principalFromContext(r), tenant) { 62 if !ok {
42 http.Error(w, "forbidden", http.StatusForbidden)
43 return 63 return
44 } 64 }
45 cas, err := a.st.ListTenantUserCAs(tenant) 65 cas, err := a.st.ListTenantUserCAs(tenant)
@@ -61,9 +81,8 @@ func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) {
61 81
62 // handleDeleteUserCA removes a registered CA by its public_key line. 82 // handleDeleteUserCA removes a registered CA by its public_key line.
63 func (a *API) handleDeleteUserCA(w http.ResponseWriter, r *http.Request) { 83 func (a *API) handleDeleteUserCA(w http.ResponseWriter, r *http.Request) {
64 tenant := r.PathValue("tenant") 84 tenant, ok := a.userCATenant(w, r)
65 if !mayActAs(principalFromContext(r), tenant) { 85 if !ok {
66 http.Error(w, "forbidden", http.StatusForbidden)
67 return 86 return
68 } 87 }
69 var req types.UserCARequest 88 var req types.UserCARequest
internal/server/api/usercas_test.go
Old New
@@ -5,6 +5,7 @@ import (
5 "net/http" 5 "net/http"
6 "testing" 6 "testing"
7 7
8 "github.com/a73x/eitri/internal/server/api/types"
8 "github.com/a73x/eitri/internal/server/sshca" 9 "github.com/a73x/eitri/internal/server/sshca"
9 "github.com/stretchr/testify/assert" 10 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require" 11 "github.com/stretchr/testify/require"
@@ -35,6 +36,53 @@ func TestUploadUserCAStoresCanonicalLine(t *testing.T) {
35 assert.Equal(t, testTenant, tenant) 36 assert.Equal(t, testTenant, tenant)
36 } 37 }
37 38
39 // TestUploadUserCATenantlessLandsOnCallerTenant posts to the tenant-less
40 // /api/v1/user-cas and asserts the CA registers under the CALLER's own tenant
41 // (the credential names it), while a second tenant neither lands there nor sees
42 // it through its own tenant-less list.
43 func TestUploadUserCATenantlessLandsOnCallerTenant(t *testing.T) {
44 w := newTwoTenant(t)
45
46 _, signer, err := sshca.GenerateHostKey()
47 require.NoError(t, err)
48 line := sshca.AuthorizedKeyLine(signer.PublicKey())
49
50 // default's PAT uploads via the tenant-less endpoint.
51 resp := do(t, "POST", w.ts.URL+"/api/v1/user-cas", testPAT,
52 map[string]any{"public_key": line, "label": "mine"})
53 require.Equal(t, http.StatusCreated, resp.StatusCode)
54
55 // It resolves back to default — the caller's own tenant, not beta.
56 tenant, ok, err := w.st.TenantForUserCA(line)
57 require.NoError(t, err)
58 require.True(t, ok)
59 assert.Equal(t, testTenant, tenant)
60
61 // default's own tenant-less list includes it.
62 require.True(t, tenantlessListHas(t, w.ts.URL, testPAT, line),
63 "default's tenant-less list must include its own CA")
64
65 // beta's tenant-less list (its own tenant) must not.
66 require.False(t, tenantlessListHas(t, w.ts.URL, w.betaPAT, line),
67 "beta must not see default's CA through its own tenant-less list")
68 }
69
70 // tenantlessListHas reports whether GET /api/v1/user-cas as pat returns a CA
71 // whose pubkey line equals want.
72 func tenantlessListHas(t *testing.T, baseURL, pat, want string) bool {
73 t.Helper()
74 resp := do(t, "GET", baseURL+"/api/v1/user-cas", pat, nil)
75 require.Equal(t, http.StatusOK, resp.StatusCode)
76 var cas []types.UserCA
77 require.NoError(t, json.NewDecoder(resp.Body).Decode(&cas))
78 for _, c := range cas {
79 if c.PubKey == want {
80 return true
81 }
82 }
83 return false
84 }
85
38 func TestUploadUserCAGarbageKeyIs400(t *testing.T) { 86 func TestUploadUserCAGarbageKeyIs400(t *testing.T) {
39 ts, _, _, _, _ := newServer(t) 87 ts, _, _, _, _ := newServer(t)
40 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT, 88 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT,
internal/server/sshgate/gate.go
Old New
@@ -222,13 +222,25 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) {
222 _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") 222 _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted")
223 return 223 return
224 } 224 }
225 // Connect names are <tenant>.<name>. VM names are RFC1123 labels (no 225 // Connect names take two forms, both resolving within the connection's
226 // dots) and tenant ids are dot-free, so the FIRST dot splits 226 // own tenant (the cert's signing CA already names the tenant):
227 // unambiguously. The prefix must match the connection's own tenant — 227 // <name> — a bare VM name in the connection's tenant.
228 // naming another tenant is rejected identically to a nonexistent VM, so 228 // <tenant>.<name> — the explicit form; the prefix MUST equal the
229 // tenancy structure is not probeable from the gate. 229 // connection's tenant. Naming another tenant is
230 prefix, bare, found := strings.Cut(p.HostToConnect, ".") 230 // rejected identically to a nonexistent VM, so
231 if !found || prefix != tenant || bare == "" { 231 // tenancy structure is not probeable from the gate.
232 // VM names are RFC1123 labels (no dots) and tenant ids are dot-free, so
233 // the FIRST dot splits the explicit form unambiguously; a name with no dot
234 // is unambiguously bare.
235 bare := p.HostToConnect
236 if prefix, rest, found := strings.Cut(p.HostToConnect, "."); found {
237 if prefix != tenant || rest == "" {
238 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
239 return
240 }
241 bare = rest
242 }
243 if bare == "" {
232 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") 244 _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM")
233 return 245 return
234 } 246 }
internal/server/sshgate/gate_test.go
Old New
@@ -369,11 +369,67 @@ func TestGateAuthzDenyRejected(t *testing.T) {
369 require.Error(t, err, "authz denial must reject the channel") 369 require.Error(t, err, "authz denial must reject the channel")
370 } 370 }
371 371
372 // TestGateRejectsBareAndForeignTenantNames: the dialed name must be 372 // TestGateBareNameResolvesInCertTenant: a connect name with NO tenant prefix is
373 // <tenant>.<name> and the prefix must match the connection's tenant (derived 373 // the bare VM name within the connection's own tenant — the cert's signing CA
374 // from the signing CA). Bare (unqualified) names and foreign prefixes are 374 // already names the tenant — so it resolves and tunnels exactly like the
375 // rejected before resolution. 375 // explicit <tenant>.<vm> form.
376 func TestGateRejectsBareAndForeignTenantNames(t *testing.T) { 376 func TestGateBareNameResolvesInCertTenant(t *testing.T) {
377 ca := newSigner(t)
378 tg := startGate(t, ca.PublicKey(), true)
379 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
380
381 conn, err := client.Dial("tcp", "vm1:22")
382 require.NoError(t, err, "a bare VM name must resolve within the cert's tenant")
383 defer conn.Close()
384
385 // The dialer must have been reached with the VM resolved inside "default".
386 select {
387 case got := <-tg.dialCalls:
388 assert.Equal(t, [3]string{"host-1", "vm-1", "22"}, got)
389 case <-time.After(2 * time.Second):
390 t.Fatal("dialer was never called")
391 }
392 }
393
394 // TestGateBareNameForeignTenantRejected: resolution is tenant-scoped, so a bare
395 // name that belongs to ANOTHER tenant does not resolve within the connection's
396 // tenant and is rejected as an unknown VM.
397 func TestGateBareNameForeignTenantRejected(t *testing.T) {
398 ca := newSigner(t)
399 // The only known VM ("victim") lives in tenant "other", while the cert (and
400 // thus the connection) is tenant "default": a tenant-scoped resolver never
401 // hands it back for "default".
402 tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: true}
403 resolve := func(tenant, name string) (string, string, bool) {
404 if tenant == "other" && name == "victim" {
405 return "host-x", "vm-x", true
406 }
407 return "", "", false
408 }
409 authorize := func(string, string) bool { return true }
410 dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) {
411 tg.dialCalls <- [3]string{hostID, vmID, "22"}
412 a, b := net.Pipe()
413 go func() { _, _ = io.Copy(b, b); b.Close() }()
414 return a, nil
415 }
416 g := New(tg.hostKey, singleCALookup(ca.PublicKey(), "default"), resolve, authorize, dial, nil)
417 l, err := net.Listen("tcp", "127.0.0.1:0")
418 require.NoError(t, err)
419 tg.addr = l.Addr().String()
420 go func() { _ = g.Serve(l) }()
421 t.Cleanup(func() { _ = l.Close() })
422
423 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
424 _, err = client.Dial("tcp", "victim:22")
425 require.Error(t, err, "a bare name for another tenant's VM must be rejected as unknown")
426 }
427
428 // TestGateForeignTenantNameIndistinguishable: an explicit <tenant>.<vm> whose
429 // prefix is a FOREIGN tenant is rejected with the SAME message as a nonexistent
430 // VM in the connection's own tenant, so tenancy structure is not probeable from
431 // the gate.
432 func TestGateForeignTenantNameIndistinguishable(t *testing.T) {
377 ca := newSigner(t) 433 ca := newSigner(t)
378 tg := startGate(t, ca.PublicKey(), true) 434 tg := startGate(t, ca.PublicKey(), true)
379 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) 435 client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t)))
@@ -388,14 +444,9 @@ func TestGateRejectsBareAndForeignTenantNames(t *testing.T) {
388 return oce.Message 444 return oce.Message
389 } 445 }
390 446
391 // A bare name (no tenant prefix) is rejected — the gate's tenant is
392 // "default" (the signing CA's tenant) and names must be <tenant>.<name>.
393 _ = rejectMsg("vm1")
394
395 // A foreign tenant prefix must be rejected with the SAME message as a 447 // A foreign tenant prefix must be rejected with the SAME message as a
396 // nonexistent VM in our own tenant: the two are indistinguishable, so 448 // nonexistent VM in our own tenant: the two are indistinguishable. Test-lock
397 // tenancy structure is not probeable from the gate. Test-lock the equal 449 // the equal messages so a future refactor cannot split them silently.
398 // messages so a future refactor cannot split them silently.
399 assert.Equal(t, rejectMsg("default.nope"), rejectMsg("other.vm1"), 450 assert.Equal(t, rejectMsg("default.nope"), rejectMsg("other.vm1"),
400 "a foreign tenant prefix must be indistinguishable from a nonexistent VM") 451 "a foreign tenant prefix must be indistinguishable from a nonexistent VM")
401 452
web/src/lib/api-types.ts
Old New
@@ -842,6 +842,84 @@ export interface paths {
842 patch?: never; 842 patch?: never;
843 trace?: never; 843 trace?: never;
844 }; 844 };
845 "/api/v1/user-cas": {
846 parameters: {
847 query?: never;
848 header?: never;
849 path?: never;
850 cookie?: never;
851 };
852 /** List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint). */
853 get: {
854 parameters: {
855 query?: never;
856 header?: never;
857 path?: never;
858 cookie?: never;
859 };
860 requestBody?: never;
861 responses: {
862 /** @description success */
863 200: {
864 headers: {
865 [name: string]: unknown;
866 };
867 content: {
868 "application/json": components["schemas"]["UserCA"][];
869 };
870 };
871 /** @description error (plain text) */
872 default: {
873 headers: {
874 [name: string]: unknown;
875 };
876 content: {
877 "text/plain": string;
878 };
879 };
880 };
881 };
882 put?: never;
883 /** Register a BYO SSH user CA public key for the caller's own tenant; eitri never holds a user signing key. */
884 post: {
885 parameters: {
886 query?: never;
887 header?: never;
888 path?: never;
889 cookie?: never;
890 };
891 requestBody: {
892 content: {
893 "application/json": components["schemas"]["UserCARequest"];
894 };
895 };
896 responses: {
897 /** @description success */
898 201: {
899 headers: {
900 [name: string]: unknown;
901 };
902 content: {
903 "application/json": components["schemas"]["UserCAUploadResponse"];
904 };
905 };
906 /** @description error (plain text) */
907 default: {
908 headers: {
909 [name: string]: unknown;
910 };
911 content: {
912 "text/plain": string;
913 };
914 };
915 };
916 };
917 delete?: never;
918 options?: never;
919 head?: never;
920 patch?: never;
921 trace?: never;
922 };
845 "/api/v1/vms": { 923 "/api/v1/vms": {
846 parameters: { 924 parameters: {
847 query?: never; 925 query?: never;