a73x

d7e93399

feat: multi-tenant console with OIDC sign-in

a73x   2026-07-29 05:05

Commit message
feat: multi-tenant console with OIDC sign-in

The console signs in through OIDC and every request resolves to a
tenant. eitri-server is a pure relying party (auth-code + PKCE via
go-oidc); server-side sessions and personal access tokens are the only
user credentials, and the admin token is gone. Each identity's first
sign-in JIT-provisions its own tenant (handle derived from the email
local part) — there is no seeded tenant and no implicit one anywhere:
the CLI requires EITRI_TENANT, and events with no resolvable tenant
file under the reserved 'system' audit scope, which no principal can
ever hold.

An identity requires a verified email: the OIDC claims contract is
email + email_verified:true, and the callback rejects an issuer that
cannot vouch for the address before JIT provisioning — an unverified
address could otherwise pass a domain allowlist it doesn't own. The
bundled issuer asserts the claim: its email is the account a password
authenticates.

internal/oidcprovider + cmd/eitri-oidc are a minimal bundled issuer
(discovery, authorize, token, JWKS; argon2id flat-file users managed by
'eitri-oidc user add'; PKCE S256 only; codes consumed only after client
validation) for fleets without an IdP. The server cannot tell it from a
real one — docs/byo-idp.md shows pointing oidc.issuer at Google, Okta
or Keycloak instead, with an optional signup gate (allowed_domains /
allowed_identities). It ships as its own release tarball; arch rule R13
pins the issuer/relying-party boundary.

Tenancy is enforced end to end: tenant-scoped SSE snapshots, console
ownership, audit rows, host operations, and PATs (eitri_pat_ prefix,
sha256 at rest, TTL clamped to a century, minted and revoked from the
console's settings page alongside per-tenant SSH CA management).

The deploy boot-gate authenticates through the real code flow headlessly
as the deploy machine identity to prove the credential chain, then runs
the VM lifecycle on an operator-minted PAT from CI_PAT_FILE.

Makefile
Old New
@@ -36,6 +36,7 @@ GO_LDFLAGS := -ldflags "-X github.com/a73x/eitri/internal/version.Version=$(VERS
36 36
37 build: web 37 build: web
38 go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server 38 go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server
39 go build $(GO_LDFLAGS) -o $(BIN)/eitri-oidc ./cmd/eitri-oidc
39 go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent 40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent
40 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp 41 go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp
41 go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke 42 go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke
cmd/eitri-mcp/main.go
Old New
@@ -60,7 +60,7 @@ func run(cfgPath string) error {
60 // own user CA, and verifies both hops' host certs against the eitri host CA. 60 // own user CA, and verifies both hops' host certs against the eitri host CA.
61 // The user CA's public key is uploaded to the tenant once (Register, below) 61 // The user CA's public key is uploaded to the tenant once (Register, below)
62 // so VMs trust those certs. GateAuth is backed by the same API client. 62 // so VMs trust those certs. GateAuth is backed by the same API client.
63 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.AdminToken} 63 api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token}
64 gateAuth := gateclient.NewGateAuth(api, userCA, cfg.Tenant, nil) 64 gateAuth := gateclient.NewGateAuth(api, userCA, cfg.Tenant, nil)
65 tools := &mcpserver.Tools{ 65 tools := &mcpserver.Tools{
66 API: mcpserver.API{Client: api}, 66 API: mcpserver.API{Client: api},
cmd/eitri-oidc/main.go
Old New
@@ -0,0 +1,24 @@
1 // eitri-oidc: the bundled OIDC issuer. It runs as a sibling process to
2 // eitri-server with its own systemd unit and release tarball, so "with/without
3 // local OIDC" is a pure deployment choice. All behavior lives in
4 // internal/oidcprovider (RunCLI); this package is wiring only (arch R14).
5 package main
6
7 import (
8 "fmt"
9 "os"
10
11 "github.com/a73x/eitri/internal/oidcprovider"
12 "github.com/a73x/eitri/internal/version"
13 )
14
15 func main() {
16 if len(os.Args) > 1 && os.Args[1] == "--version" {
17 fmt.Println(version.Version)
18 return
19 }
20 if err := oidcprovider.RunCLI(os.Args[1:], os.Stdout, os.Stderr); err != nil {
21 fmt.Fprintln(os.Stderr, "eitri-oidc:", err)
22 os.Exit(1)
23 }
24 }
cmd/eitri-server/main.go
Old New
@@ -10,9 +10,11 @@ import (
10 "fmt" 10 "fmt"
11 "log/slog" 11 "log/slog"
12 "net/http" 12 "net/http"
13 "net/url"
13 "os" 14 "os"
14 "os/signal" 15 "os/signal"
15 "regexp" 16 "regexp"
17 "strings"
16 "syscall" 18 "syscall"
17 "time" 19 "time"
18 20
@@ -80,10 +82,40 @@ func main() {
80 slog.Error("parse config", "err", err) 82 slog.Error("parse config", "err", err)
81 os.Exit(1) 83 os.Exit(1)
82 } 84 }
83 if cfg.AdminToken == "" || cfg.HostSecret == "" { 85 if cfg.HostSecret == "" {
84 slog.Error("admin_token and host_secret are required") 86 slog.Error("host_secret is required")
85 os.Exit(1) 87 os.Exit(1)
86 } 88 }
89 // The server is a pure OIDC relying party (spec §2): issuer, client_id and
90 // public_url are required. Collect every missing key so the operator fixes
91 // server.json in one pass rather than one restart per key.
92 var missingOIDC []string
93 if cfg.OIDC.Issuer == "" {
94 missingOIDC = append(missingOIDC, "oidc.issuer")
95 }
96 if cfg.OIDC.ClientID == "" {
97 missingOIDC = append(missingOIDC, "oidc.client_id")
98 }
99 if cfg.OIDC.PublicURL == "" {
100 missingOIDC = append(missingOIDC, "oidc.public_url")
101 }
102 if len(missingOIDC) > 0 {
103 slog.Error("server.json: missing required keys (point them at eitri-oidc or your IdP)", "keys", strings.Join(missingOIDC, ", "))
104 os.Exit(1)
105 }
106 // public_url builds the OIDC callback URL, so it must be an absolute
107 // http(s) URL with a host — catch a bare host, missing scheme, or
108 // scheme-only URL at boot, not at the first redirect.
109 if u, err := url.Parse(cfg.OIDC.PublicURL); err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
110 slog.Error("server.json: oidc.public_url must be an absolute http(s) URL", "value", cfg.OIDC.PublicURL)
111 os.Exit(1)
112 }
113 // admin_token is retired (spec §7): ignored if present, still passed to
114 // api.Config until the PAT/session middleware replaces it. Warn once so the
115 // operator prunes the stale key.
116 if cfg.AdminToken != "" {
117 slog.Warn("server.json: admin_token is no longer used and is ignored; remove it")
118 }
87 if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" { 119 if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" {
88 slog.Error("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)") 120 slog.Error("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)")
89 os.Exit(1) 121 os.Exit(1)
@@ -177,11 +209,19 @@ func main() {
177 reg := registry.New(time.Now) 209 reg := registry.New(time.Now)
178 h := hub.New() 210 h := hub.New()
179 211
180 a := api.New(api.Config{AdminToken: cfg.AdminToken, HostSecret: []byte(cfg.HostSecret), 212 a := api.New(api.Config{HostSecret: []byte(cfg.HostSecret),
181 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA}, 213 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA},
182 ServerCertSHA256: certFP, 214 ServerCertSHA256: certFP,
183 AdvertiseHTTP: cfg.AdvertiseHTTP, 215 AdvertiseHTTP: cfg.AdvertiseHTTP,
184 AdvertiseQUIC: cfg.AdvertiseQUIC}, 216 AdvertiseQUIC: cfg.AdvertiseQUIC,
217 OIDC: api.OIDCConfig{
218 Issuer: cfg.OIDC.Issuer,
219 ClientID: cfg.OIDC.ClientID,
220 ClientSecret: cfg.OIDC.ClientSecret,
221 PublicURL: cfg.OIDC.PublicURL,
222 AllowedDomains: cfg.OIDC.AllowedDomains,
223 AllowedIdentities: cfg.OIDC.AllowedIdentities,
224 }},
185 st, reg, h) 225 st, reg, h)
186 226
187 tlsConf, err := transport.ServerTLS(certPEM, keyPEM) 227 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
@@ -223,6 +263,9 @@ func main() {
223 // Serve the REST API + SSE under /api/ and the embedded SPA everywhere else. 263 // Serve the REST API + SSE under /api/ and the embedded SPA everywhere else.
224 root := http.NewServeMux() 264 root := http.NewServeMux()
225 root.Handle("/api/", a.Handler()) 265 root.Handle("/api/", a.Handler())
266 // Sign-in endpoints live OUTSIDE /api/ and its auth middleware: they are how
267 // a browser establishes a session in the first place (spec §2).
268 root.Handle("/auth/", a.AuthHandler())
226 // Unauthenticated probes (outside /api/, so a load balancer or the deploy 269 // Unauthenticated probes (outside /api/, so a load balancer or the deploy
227 // script needs no token). /livez is process-up; /readyz gates on the 270 // script needs no token). /livez is process-up; /readyz gates on the
228 // dependencies the server needs to actually serve — the DB. The QUIC 271 // dependencies the server needs to actually serve — the DB. The QUIC
cmd/eitri-server/sshgate.go
Old New
@@ -97,10 +97,9 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) {
97 } 97 }
98 return vm.HostID, vm.ID, true 98 return vm.HostID, vm.ID, true
99 } 99 }
100 // authorize re-reads the VM row and requires tenant equality. With one CA 100 // authorize re-reads the VM row and requires tenant equality: the tenant
101 // every connection is DefaultTenant so this always passes TODAY — but it 101 // that the connection's user cert resolved to (via its per-tenant CA) must
102 // is a live per-connection comparison, not a stub: tenant #2's CA maps to 102 // own the VM, or the connection is refused.
103 // its own tenant here with no code-shape change.
104 authorize := func(tenant, vmID string) bool { 103 authorize := func(tenant, vmID string) bool {
105 vm, err := st.GetVM(vmID) 104 vm, err := st.GetVM(vmID)
106 return err == nil && vm.DeletedAt == nil && vm.Tenant == tenant 105 return err == nil && vm.DeletedAt == nil && vm.Tenant == tenant
cmd/eitri-smoke/config.go
Old New
@@ -12,11 +12,13 @@ import (
12 // Config holds the environment-sourced settings for one smoke run. It mirrors 12 // Config holds the environment-sourced settings for one smoke run. It mirrors
13 // the variables the deploy boot-gate reads from $EITRI_DEPLOY_ENV. 13 // the variables the deploy boot-gate reads from $EITRI_DEPLOY_ENV.
14 type Config struct { 14 type Config struct {
15 ServerURL string 15 ServerURL string
16 TokenFile string 16 CIUser string
17 AgentUserHost string 17 CIPasswordFile string
18 AgentPort int 18 CIPATFile string
19 AgentStateDir string 19 AgentUserHost string
20 AgentPort int
21 AgentStateDir string
20 22
21 // Carried for a later coverage-collection task; optional here. 23 // Carried for a later coverage-collection task; optional here.
22 ServerGocoverdir string 24 ServerGocoverdir string
@@ -26,7 +28,6 @@ type Config struct {
26 // SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the 28 // SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the
27 // scenario proves guest access through the gate (a hard gate). Optional. 29 // scenario proves guest access through the gate (a hard gate). Optional.
28 SmokeGate string // SMOKE_GATE, "<gate-domain>:<port>" 30 SmokeGate string // SMOKE_GATE, "<gate-domain>:<port>"
29 SmokeTenant string // SMOKE_TENANT, default "default"
30 SmokeVMUser string // SMOKE_VM_USER, default "ubuntu" 31 SmokeVMUser string // SMOKE_VM_USER, default "ubuntu"
31 SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key 32 SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key
32 } 33 }
@@ -36,7 +37,9 @@ type Config struct {
36 // returns an error naming the first missing required variable. 37 // returns an error naming the first missing required variable.
37 func loadConfig(getenv func(string) string) (Config, error) { 38 func loadConfig(getenv func(string) string) (Config, error) {
38 serverURL := getenv("SERVER_URL") 39 serverURL := getenv("SERVER_URL")
39 tokenFile := getenv("ADMIN_TOKEN_FILE") 40 ciUser := getenv("CI_USER")
41 ciPasswordFile := getenv("CI_PASSWORD_FILE")
42 ciPATFile := getenv("CI_PAT_FILE")
40 agentHosts := getenv("AGENT_HOSTS") 43 agentHosts := getenv("AGENT_HOSTS")
41 agentStateDir := getenv("AGENT_STATE_DIR") 44 agentStateDir := getenv("AGENT_STATE_DIR")
42 45
@@ -44,8 +47,14 @@ func loadConfig(getenv func(string) string) (Config, error) {
44 if serverURL == "" { 47 if serverURL == "" {
45 missing = append(missing, "SERVER_URL") 48 missing = append(missing, "SERVER_URL")
46 } 49 }
47 if tokenFile == "" { 50 if ciUser == "" {
48 missing = append(missing, "ADMIN_TOKEN_FILE") 51 missing = append(missing, "CI_USER")
52 }
53 if ciPasswordFile == "" {
54 missing = append(missing, "CI_PASSWORD_FILE")
55 }
56 if ciPATFile == "" {
57 missing = append(missing, "CI_PAT_FILE")
49 } 58 }
50 if agentHosts == "" { 59 if agentHosts == "" {
51 missing = append(missing, "AGENT_HOSTS") 60 missing = append(missing, "AGENT_HOSTS")
@@ -59,10 +68,6 @@ func loadConfig(getenv func(string) string) (Config, error) {
59 68
60 userHost, port := parseAgentHost(agentHosts) 69 userHost, port := parseAgentHost(agentHosts)
61 70
62 smokeTenant := getenv("SMOKE_TENANT")
63 if smokeTenant == "" {
64 smokeTenant = "default"
65 }
66 smokeVMUser := getenv("SMOKE_VM_USER") 71 smokeVMUser := getenv("SMOKE_VM_USER")
67 if smokeVMUser == "" { 72 if smokeVMUser == "" {
68 smokeVMUser = "ubuntu" 73 smokeVMUser = "ubuntu"
@@ -70,7 +75,9 @@ func loadConfig(getenv func(string) string) (Config, error) {
70 75
71 return Config{ 76 return Config{
72 ServerURL: serverURL, 77 ServerURL: serverURL,
73 TokenFile: tokenFile, 78 CIUser: ciUser,
79 CIPasswordFile: ciPasswordFile,
80 CIPATFile: ciPATFile,
74 AgentUserHost: userHost, 81 AgentUserHost: userHost,
75 AgentPort: port, 82 AgentPort: port,
76 AgentStateDir: agentStateDir, 83 AgentStateDir: agentStateDir,
@@ -78,7 +85,6 @@ func loadConfig(getenv func(string) string) (Config, error) {
78 AgentGocoverdir: getenv("AGENT_GOCOVERDIR"), 85 AgentGocoverdir: getenv("AGENT_GOCOVERDIR"),
79 CoverOut: getenv("COVER_OUT"), 86 CoverOut: getenv("COVER_OUT"),
80 SmokeGate: getenv("SMOKE_GATE"), 87 SmokeGate: getenv("SMOKE_GATE"),
81 SmokeTenant: smokeTenant,
82 SmokeVMUser: smokeVMUser, 88 SmokeVMUser: smokeVMUser,
83 SmokeUserCAFile: getenv("SMOKE_USER_CA_FILE"), 89 SmokeUserCAFile: getenv("SMOKE_USER_CA_FILE"),
84 }, nil 90 }, nil
cmd/eitri-smoke/config_test.go
Old New
@@ -14,7 +14,9 @@ func fakeGetenv(vals map[string]string) func(string) string {
14 func requiredVals() map[string]string { 14 func requiredVals() map[string]string {
15 return map[string]string{ 15 return map[string]string{
16 "SERVER_URL": "https://server.example:8443", 16 "SERVER_URL": "https://server.example:8443",
17 "ADMIN_TOKEN_FILE": "/etc/eitri/admin.token", 17 "CI_USER": "ci@eitri.local",
18 "CI_PASSWORD_FILE": "/etc/eitri/ci-password",
19 "CI_PAT_FILE": "/etc/eitri/ci-pat",
18 "AGENT_HOSTS": "ubuntu@10.0.0.5:2222", 20 "AGENT_HOSTS": "ubuntu@10.0.0.5:2222",
19 "AGENT_STATE_DIR": "/var/lib/eitri-agent", 21 "AGENT_STATE_DIR": "/var/lib/eitri-agent",
20 } 22 }
@@ -67,7 +69,9 @@ func TestLoadConfigMissingRequiredVars(t *testing.T) {
67 wantErr string 69 wantErr string
68 }{ 70 }{
69 {"missing server url", "SERVER_URL", "SERVER_URL"}, 71 {"missing server url", "SERVER_URL", "SERVER_URL"},
70 {"missing token file", "ADMIN_TOKEN_FILE", "ADMIN_TOKEN_FILE"}, 72 {"missing ci user", "CI_USER", "CI_USER"},
73 {"missing ci password file", "CI_PASSWORD_FILE", "CI_PASSWORD_FILE"},
74 {"missing ci pat file", "CI_PAT_FILE", "CI_PAT_FILE"},
71 {"missing agent hosts", "AGENT_HOSTS", "AGENT_HOSTS"}, 75 {"missing agent hosts", "AGENT_HOSTS", "AGENT_HOSTS"},
72 {"missing agent state dir", "AGENT_STATE_DIR", "AGENT_STATE_DIR"}, 76 {"missing agent state dir", "AGENT_STATE_DIR", "AGENT_STATE_DIR"},
73 } 77 }
@@ -91,7 +95,7 @@ func TestLoadConfigMissingAllRequiredVars(t *testing.T) {
91 if err == nil { 95 if err == nil {
92 t.Fatal("loadConfig: want error, got nil") 96 t.Fatal("loadConfig: want error, got nil")
93 } 97 }
94 for _, want := range []string{"SERVER_URL", "ADMIN_TOKEN_FILE", "AGENT_HOSTS", "AGENT_STATE_DIR"} { 98 for _, want := range []string{"SERVER_URL", "CI_USER", "CI_PASSWORD_FILE", "CI_PAT_FILE", "AGENT_HOSTS", "AGENT_STATE_DIR"} {
95 if !strings.Contains(err.Error(), want) { 99 if !strings.Contains(err.Error(), want) {
96 t.Errorf("error = %q, missing %q", err.Error(), want) 100 t.Errorf("error = %q, missing %q", err.Error(), want)
97 } 101 }
@@ -106,9 +110,6 @@ func TestLoadConfigSmokeGateDefaults(t *testing.T) {
106 if cfg.SmokeGate != "" { 110 if cfg.SmokeGate != "" {
107 t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate) 111 t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate)
108 } 112 }
109 if cfg.SmokeTenant != "default" {
110 t.Errorf("SmokeTenant = %q, want default", cfg.SmokeTenant)
111 }
112 if cfg.SmokeVMUser != "ubuntu" { 113 if cfg.SmokeVMUser != "ubuntu" {
113 t.Errorf("SmokeVMUser = %q, want ubuntu", cfg.SmokeVMUser) 114 t.Errorf("SmokeVMUser = %q, want ubuntu", cfg.SmokeVMUser)
114 } 115 }
@@ -120,7 +121,6 @@ func TestLoadConfigSmokeGateDefaults(t *testing.T) {
120 func TestLoadConfigSmokeGatePassThrough(t *testing.T) { 121 func TestLoadConfigSmokeGatePassThrough(t *testing.T) {
121 vals := requiredVals() 122 vals := requiredVals()
122 vals["SMOKE_GATE"] = "gate.example:2222" 123 vals["SMOKE_GATE"] = "gate.example:2222"
123 vals["SMOKE_TENANT"] = "acme"
124 vals["SMOKE_VM_USER"] = "debian" 124 vals["SMOKE_VM_USER"] = "debian"
125 vals["SMOKE_USER_CA_FILE"] = "/etc/eitri-smoke/user_ca" 125 vals["SMOKE_USER_CA_FILE"] = "/etc/eitri-smoke/user_ca"
126 cfg, err := loadConfig(fakeGetenv(vals)) 126 cfg, err := loadConfig(fakeGetenv(vals))
@@ -130,9 +130,6 @@ func TestLoadConfigSmokeGatePassThrough(t *testing.T) {
130 if cfg.SmokeGate != "gate.example:2222" { 130 if cfg.SmokeGate != "gate.example:2222" {
131 t.Errorf("SmokeGate = %q, want gate.example:2222", cfg.SmokeGate) 131 t.Errorf("SmokeGate = %q, want gate.example:2222", cfg.SmokeGate)
132 } 132 }
133 if cfg.SmokeTenant != "acme" {
134 t.Errorf("SmokeTenant = %q, want acme", cfg.SmokeTenant)
135 }
136 if cfg.SmokeVMUser != "debian" { 133 if cfg.SmokeVMUser != "debian" {
137 t.Errorf("SmokeVMUser = %q, want debian", cfg.SmokeVMUser) 134 t.Errorf("SmokeVMUser = %q, want debian", cfg.SmokeVMUser)
138 } 135 }
cmd/eitri-smoke/gatecheck.go
Old New
@@ -12,9 +12,11 @@ import (
12 ) 12 )
13 13
14 // realGateHooks builds the live SSH-CA gate steps: register the smoke user CA 14 // realGateHooks builds the live SSH-CA gate steps: register the smoke user CA
15 // with the tenant, and reach the guest through the gate to prove access. 15 // with the tenant, and reach the guest through the gate to prove access. The
16 func realGateHooks(cfg Config, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks { 16 // tenant is the operator PAT's own tenant (derived via Me() by the caller), so
17 auth := gateclient.NewGateAuth(ca, userCA, cfg.SmokeTenant, now) 17 // the CA registration and connect names match the fleet's real partition.
18 func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks {
19 auth := gateclient.NewGateAuth(ca, userCA, tenant, now)
18 return &gateHooks{ 20 return &gateHooks{
19 register: func(ctx context.Context) error { return auth.Register(ctx) }, 21 register: func(ctx context.Context) error { return auth.Register(ctx) },
20 exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) }, 22 exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) },
cmd/eitri-smoke/login.go
Old New
@@ -0,0 +1,108 @@
1 package main
2
3 import (
4 "fmt"
5 "net/http"
6 "net/http/cookiejar"
7 "net/url"
8 "time"
9
10 "github.com/a73x/eitri/internal/server/api/client"
11 )
12
13 // sessionCookie is the name of the console session cookie the callback sets.
14 // Its presence in the jar after the credential POST is the one honest signal
15 // that sign-in succeeded — a failed password re-renders the login form as a
16 // plain 200 without ever setting it.
17 const sessionCookie = "eitri_session"
18
19 // loginPAT signs in through the real OIDC code flow — the same doors a browser
20 // walks — and mints a short-lived PAT for this run. There is no special grant
21 // for machines (spec §3): CI registers a flat-file user and then drives the
22 // standard authorization-code flow headlessly against the plain-HTML login
23 // form, lands an ordinary session, and mints a PAT through the normal route.
24 func loginPAT(serverURL, email, password string) (string, error) {
25 base, err := url.Parse(serverURL)
26 if err != nil {
27 return "", fmt.Errorf("parse server url %q: %w", serverURL, err)
28 }
29 jar, err := cookiejar.New(nil)
30 if err != nil {
31 return "", fmt.Errorf("cookie jar: %w", err)
32 }
33 // A default (redirect-following) client: GET /auth/login bounces through the
34 // issuer's authorize endpoint to the login form, and the credential POST
35 // runs issuer -> /auth/callback -> / — we want every hop followed so the
36 // session cookie lands in the jar and resp.Request.URL is the form's URL.
37 hc := &http.Client{Jar: jar, Timeout: 30 * time.Second}
38
39 // GET /auth/login follows the redirect chain to the issuer's login form.
40 // We POST credentials straight back to resp.Request.URL — the authorize
41 // URL we landed on, query intact — so no HTML parsing is needed and the
42 // form's action attribute is never consulted. (eitri-oidc accepts the
43 // credential POST on that same URL by contract, spec §2.1.)
44 resp, err := hc.Get(serverURL + "/auth/login")
45 if err != nil {
46 return "", fmt.Errorf("GET /auth/login: %w", err)
47 }
48 resp.Body.Close()
49 if resp.StatusCode != http.StatusOK {
50 return "", fmt.Errorf("sign-in: login form GET returned %d (want 200) at %s", resp.StatusCode, resp.Request.URL)
51 }
52 formURL := resp.Request.URL.String()
53
54 // POST credentials to the form URL; the redirects run issuer -> /auth/callback
55 // -> / and the callback plants the session cookie in the jar on success.
56 resp2, err := hc.PostForm(formURL, url.Values{
57 "email": {email},
58 "password": {password},
59 })
60 if err != nil {
61 return "", fmt.Errorf("POST credentials: %w", err)
62 }
63 resp2.Body.Close()
64
65 // A failed sign-in re-renders the login form as a 200 without a session
66 // cookie. Detect success by the cookie's presence in the jar, never by the
67 // status. The password is never included in the error.
68 if cookieByName(jar.Cookies(base), sessionCookie) == nil {
69 return "", fmt.Errorf("sign-in failed for %q: no %s cookie after credential POST "+
70 "(final status %d at %s) — check the CI user exists in eitri-oidc and the password matches",
71 email, sessionCookie, resp2.StatusCode, resp2.Request.URL)
72 }
73
74 // Mint the PAT through the shared client, riding the session cookie in the
75 // jar (Token left empty so no Bearer header is sent).
76 c := &client.Client{BaseURL: serverURL, HTTP: hc}
77 tok, err := c.CreateAPIToken("boot-gate", time.Hour)
78 if err != nil {
79 return "", fmt.Errorf("mint boot-gate PAT: %w", err)
80 }
81 return tok.Token, nil
82 }
83
84 // proveCredentialChain is the boot-gate's phase-1 proof: it confirms the minted
85 // PAT resolves to a non-empty tenant via GET /api/v1/me, exercising the whole
86 // issuer -> login -> session -> PAT-mint chain without touching VMs. It returns
87 // the tenant handle so the caller can log which tenant the ci user landed in.
88 func proveCredentialChain(serverURL, token string) (string, error) {
89 c := &client.Client{BaseURL: serverURL, Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}}
90 me, err := c.Me()
91 if err != nil {
92 return "", fmt.Errorf("credential-chain proof: Me() with minted PAT: %w", err)
93 }
94 if me.Tenant == "" {
95 return "", fmt.Errorf("credential-chain proof: minted PAT resolved to an empty tenant")
96 }
97 return me.Tenant, nil
98 }
99
100 // cookieByName returns the named cookie from cookies, or nil if absent.
101 func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
102 for _, c := range cookies {
103 if c.Name == name {
104 return c
105 }
106 }
107 return nil
108 }
cmd/eitri-smoke/login_test.go
Old New
@@ -0,0 +1,171 @@
1 package main
2
3 import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/a73x/eitri/internal/oidcprovider"
13 "github.com/a73x/eitri/internal/server/api"
14 "github.com/a73x/eitri/internal/server/api/client"
15 "github.com/a73x/eitri/internal/server/hub"
16 "github.com/a73x/eitri/internal/server/registry"
17 "github.com/a73x/eitri/internal/server/store"
18 )
19
20 // smokeLoginEnv is a whole eitri-server /auth + /api stack wired to a real
21 // internal/oidcprovider issuer — the exact credential chain a deploy walks. It
22 // exists to prove loginPAT end-to-end: sign in through the login form, land a
23 // session, mint a PAT, and use it against the API.
24 type smokeLoginEnv struct {
25 serverURL string
26 st *store.Store
27 }
28
29 // newSmokeLoginEnv stands up the issuer and server with one seeded user and
30 // returns the server's base URL. It mirrors internal/server/api/auth_test.go's
31 // fixture: the httptest listener binds on construction so we know the server's
32 // address (hence the OIDC redirect URL) before wiring the two ends.
33 func newSmokeLoginEnv(t *testing.T, email, password string) smokeLoginEnv {
34 t.Helper()
35
36 apiSrv := httptest.NewUnstartedServer(nil)
37 publicURL := "http://" + apiSrv.Listener.Addr().String()
38
39 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
40 if err != nil {
41 t.Fatalf("store.Open: %v", err)
42 }
43 t.Cleanup(func() { st.Close() })
44
45 usersPath := filepath.Join(t.TempDir(), "users.json")
46 if err := oidcprovider.AddUser(usersPath, email, password); err != nil {
47 t.Fatalf("AddUser: %v", err)
48 }
49 prov, err := oidcprovider.New(oidcprovider.Config{
50 UsersFile: usersPath,
51 SigningKey: filepath.Join(t.TempDir(), "signing.key"),
52 Clients: []oidcprovider.Client{{ID: "eitri-console", RedirectURL: publicURL + "/auth/callback"}},
53 })
54 if err != nil {
55 t.Fatalf("oidcprovider.New: %v", err)
56 }
57 oidcSrv := httptest.NewServer(prov.Handler())
58 t.Cleanup(oidcSrv.Close)
59 prov.SetIssuer(oidcSrv.URL)
60
61 a := api.New(api.Config{
62 HostSecret: []byte("hostsecret"),
63 OIDC: api.OIDCConfig{Issuer: oidcSrv.URL, ClientID: "eitri-console", PublicURL: publicURL},
64 }, st, registry.New(time.Now), hub.New())
65 t.Cleanup(a.Close)
66
67 root := http.NewServeMux()
68 root.Handle("/api/", a.Handler())
69 root.Handle("/auth/", a.AuthHandler())
70 apiSrv.Config.Handler = root
71 apiSrv.Start()
72 t.Cleanup(apiSrv.Close)
73
74 return smokeLoginEnv{serverURL: publicURL, st: st}
75 }
76
77 func TestLoginPATMintsUsableToken(t *testing.T) {
78 const (
79 email = "ci@eitri.local"
80 password = "hunter2hunter2"
81 )
82 env := newSmokeLoginEnv(t, email, password)
83
84 token, err := loginPAT(env.serverURL, email, password)
85 if err != nil {
86 t.Fatalf("loginPAT: %v", err)
87 }
88 if token == "" {
89 t.Fatal("loginPAT returned an empty token")
90 }
91
92 // The PAT must actually authenticate an ordinary API call.
93 c := &client.Client{BaseURL: env.serverURL, Token: token, HTTP: &http.Client{Timeout: 10 * time.Second}}
94 me, err := c.Me()
95 if err != nil {
96 t.Fatalf("Me() with minted PAT: %v", err)
97 }
98 if me.Email != email {
99 t.Errorf("Me().Email = %q, want %q", me.Email, email)
100 }
101 if _, err := c.ListHosts(context.Background()); err != nil {
102 t.Errorf("ListHosts() with minted PAT: %v", err)
103 }
104 }
105
106 // TestProveCredentialChain is the phase-1 proof: signing in and minting a PAT
107 // must resolve to a non-empty tenant via Me() (the ci user's JIT tenant),
108 // without any VM involvement.
109 func TestProveCredentialChain(t *testing.T) {
110 const (
111 email = "ci@eitri.local"
112 password = "hunter2hunter2"
113 )
114 env := newSmokeLoginEnv(t, email, password)
115
116 token, err := loginPAT(env.serverURL, email, password)
117 if err != nil {
118 t.Fatalf("loginPAT: %v", err)
119 }
120 tenant, err := proveCredentialChain(env.serverURL, token)
121 if err != nil {
122 t.Fatalf("proveCredentialChain: %v", err)
123 }
124 if tenant == "" {
125 t.Fatal("proveCredentialChain returned an empty tenant")
126 }
127 }
128
129 // TestScenarioPATDerivesTenant is the phase-2 contract: a scenario client built
130 // from an operator-minted PAT derives its tenant via Me() — not from any env or
131 // hardcoded default. The store mints the PAT directly, standing in for the
132 // console-minted "deploy" token a real operator saves to CI_PAT_FILE.
133 func TestScenarioPATDerivesTenant(t *testing.T) {
134 env := newSmokeLoginEnv(t, "ci@eitri.local", "hunter2hunter2")
135
136 // A distinct operator tenant with a row (handleMe 401s on a rowless tenant).
137 tn, err := env.st.CreateTenantForIdentity("https://op.example", "op-subject", "op@example.com")
138 if err != nil {
139 t.Fatalf("CreateTenantForIdentity: %v", err)
140 }
141 secret, _, err := env.st.CreateAPIToken(tn.ID, "deploy", 0)
142 if err != nil {
143 t.Fatalf("CreateAPIToken: %v", err)
144 }
145
146 c := &client.Client{BaseURL: env.serverURL, Token: secret, HTTP: &http.Client{Timeout: 10 * time.Second}}
147 me, err := c.Me()
148 if err != nil {
149 t.Fatalf("Me() with store-minted PAT: %v", err)
150 }
151 if me.Tenant != tn.ID {
152 t.Errorf("Me().Tenant = %q, want %q (derived, not assumed)", me.Tenant, tn.ID)
153 }
154 }
155
156 func TestLoginPATWrongPasswordFails(t *testing.T) {
157 const email = "ci@eitri.local"
158 env := newSmokeLoginEnv(t, email, "the-right-password")
159
160 _, err := loginPAT(env.serverURL, email, "the-wrong-password")
161 if err == nil {
162 t.Fatal("loginPAT: want error on wrong password, got nil")
163 }
164 // The failure must name the sign-in problem and must not echo the password.
165 if !strings.Contains(err.Error(), "sign-in failed") || !strings.Contains(err.Error(), email) {
166 t.Errorf("error = %q, want it to mention the sign-in failure and the user", err)
167 }
168 if strings.Contains(err.Error(), "the-wrong-password") {
169 t.Errorf("error must not echo the password: %q", err)
170 }
171 }
cmd/eitri-smoke/main.go
Old New
@@ -34,18 +34,55 @@ func run() error {
34 return err 34 return err
35 } 35 }
36 36
37 tokenBytes, err := os.ReadFile(cfg.TokenFile) 37 // Phase 1 — credential-chain proof. The admin token is gone: the boot-gate
38 // authenticates like a human. Read the machine identity's password (CI_USER,
39 // the deploy identity), sign in through the real OIDC code flow, mint a
40 // short-lived PAT, and confirm it resolves to a tenant (spec §3). This proves
41 // issuer, login form, session, and PAT mint end to end; it deliberately never
42 // touches VMs — the machine identity's JIT tenant owns no hosts (the fleet's
43 // `default` tenant is human-owned), so the lifecycle half runs as the operator
44 // below.
45 pwBytes, err := os.ReadFile(cfg.CIPasswordFile)
38 if err != nil { 46 if err != nil {
39 return fmt.Errorf("read admin token file %q: %w", cfg.TokenFile, err) 47 return fmt.Errorf("read CI password file %q: %w", cfg.CIPasswordFile, err)
40 } 48 }
41 token := strings.TrimRight(string(tokenBytes), "\n") 49 password := strings.TrimRight(string(pwBytes), "\r\n")
50
51 token, err := loginPAT(cfg.ServerURL, cfg.CIUser, password)
52 if err != nil {
53 return err
54 }
55 ciTenant, err := proveCredentialChain(cfg.ServerURL, token)
56 if err != nil {
57 return err
58 }
59 fmt.Printf("credential chain OK (tenant %s)\n", ciTenant)
60
61 // Phase 2 — VM lifecycle. Authenticate with an operator-minted PAT read from
62 // CI_PAT_FILE (a normal, tenant-scoped console PAT — spec §3's automation
63 // story). The scenario's tenant is DERIVED via Me(), never assumed, and
64 // threaded into the user-CA registration and gate connect name below.
65 patBytes, err := os.ReadFile(cfg.CIPATFile)
66 if err != nil {
67 return fmt.Errorf("read CI PAT file %q: %w", cfg.CIPATFile, err)
68 }
69 pat := strings.TrimRight(string(patBytes), "\r\n")
42 70
43 api := &client.Client{ 71 api := &client.Client{
44 BaseURL: cfg.ServerURL, 72 BaseURL: cfg.ServerURL,
45 Token: token, 73 Token: pat,
46 UserCALabel: "eitri-smoke", 74 UserCALabel: "eitri-smoke",
47 HTTP: &http.Client{Timeout: 30 * time.Second}, 75 HTTP: &http.Client{Timeout: 30 * time.Second},
48 } 76 }
77 me, err := api.Me()
78 if err != nil {
79 return fmt.Errorf("resolve operator PAT tenant: %w", err)
80 }
81 if me.Tenant == "" {
82 return fmt.Errorf("operator PAT (%s) resolved to an empty tenant", cfg.CIPATFile)
83 }
84 tenant := me.Tenant
85 fmt.Printf("operator PAT tenant: %s\n", tenant)
49 86
50 vmName, err := randVMName() 87 vmName, err := randVMName()
51 if err != nil { 88 if err != nil {
@@ -58,7 +95,7 @@ func run() error {
58 if err != nil { 95 if err != nil {
59 return fmt.Errorf("load smoke user CA: %w", err) 96 return fmt.Errorf("load smoke user CA: %w", err)
60 } 97 }
61 gate = realGateHooks(cfg, api, userCA, time.Now, time.Sleep) 98 gate = realGateHooks(cfg, tenant, api, userCA, time.Now, time.Sleep)
62 } else { 99 } else {
63 fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)") 100 fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)")
64 } 101 }
cmd/eitri/main.go
Old New
@@ -68,14 +68,17 @@ func runCA(args []string) error {
68 if len(rest) < 1 || len(rest) > 2 { 68 if len(rest) < 1 || len(rest) > 2 {
69 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>")
70 } 70 }
71 tenant, pub := "default", rest[0] 71 tenant, pub := os.Getenv("EITRI_TENANT"), rest[0]
72 if len(rest) == 2 { 72 if len(rest) == 2 {
73 tenant, pub = rest[0], rest[1] 73 tenant, pub = rest[0], rest[1]
74 } 74 }
75 if tenant == "" {
76 return fmt.Errorf("no tenant: pass one (eitri ca upload <tenant> <file>) or set EITRI_TENANT")
77 }
75 url := os.Getenv("EITRI_URL") 78 url := os.Getenv("EITRI_URL")
76 token := os.Getenv("EITRI_TOKEN") 79 token := os.Getenv("EITRI_TOKEN")
77 if url == "" || token == "" { 80 if url == "" || token == "" {
78 return fmt.Errorf("set EITRI_URL and EITRI_TOKEN (admin bearer token)") 81 return fmt.Errorf("set EITRI_URL and EITRI_TOKEN (a personal access token)")
79 } 82 }
80 out, err := cli.UploadUserCA(context.Background(), url, token, tenant, pub) 83 out, err := cli.UploadUserCA(context.Background(), url, token, tenant, pub)
81 if err != nil { 84 if err != nil {
docs/architecture.md
Old New
@@ -39,6 +39,7 @@ bridge IP (`assigned_ip`) via the agent.
39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | 39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. |
40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the addressing seam (`NetEnv.ReserveIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | 40 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the addressing seam (`NetEnv.ReserveIP`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. |
41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | 41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). |
42 | **R13** | The OIDC issuer and the relying party stay separate binaries. `eitri-server` is a pure relying party: the bundled issuer (`internal/oidcprovider`) is importable only by its own binary `cmd/eitri-oidc`, and the `go-oidc` verifier module only by `internal/server/api` (the RP). A server import of the issuer would silently rebuild the embedded-IdP coupling; `go-oidc` anywhere but the RP means a second relying party is being hand-rolled. | `internal/arch` `TestIssuerAndRelyingPartyAreSeparate`. |
42 43
43 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see 44 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see
44 > edges changing elsewhere in the module. Always run them with `-count=1` 45 > edges changing elsewhere in the module. Always run them with `-count=1`
@@ -80,7 +81,7 @@ why the invariants hold:
80 |------|--------|---------------| 81 |------|--------|---------------|
81 | Compile all packages | `make build-go` | yes | 82 | Compile all packages | `make build-go` | yes |
82 | `go vet` | `make vet` | yes | 83 | `go vet` | `make vet` | yes |
83 | Architecture fitness tests (R1–R7) | `make arch` | yes | 84 | Architecture fitness tests (R1–R13) | `make arch` | yes |
84 | Block-tier lint (boundaries + correctness) | `make lint` | yes | 85 | Block-tier lint (boundaries + correctness) | `make lint` | yes |
85 | Race-detector tests | `make test` | yes | 86 | Race-detector tests | `make test` | yes |
86 | Per-package coverage ratchet | `make cover` | yes | 87 | Per-package coverage ratchet | `make cover` | yes |
docs/byo-idp.md
Old New
@@ -0,0 +1,47 @@
1 # Bring your own IdP
2
3 The console signs in through OIDC. The [quickstart](quickstart.md) uses the
4 bundled `eitri-oidc` issuer, but any OIDC provider works — Google, Authentik,
5 Okta, Keycloak. Point the server at yours and you never install `eitri-oidc`;
6 the tarball isn't even on the box.
7
8 ## Register a client
9
10 At your IdP, register a **confidential** web client with:
11
12 - **Redirect URL:** `<public_url>/auth/callback` — `public_url` is where
13 browsers reach your console (e.g. `https://eitri.example.com/auth/callback`).
14 - **Scopes:** `openid email`. The server requires an `email` claim **and
15 `email_verified: true`**; sign-in fails against an issuer that omits either —
16 an unverified address cannot mint an identity.
17
18 Note the client ID and secret it gives you.
19
20 ## Fill the `oidc` block
21
22 In `server.json`:
23
24 ```json
25 "oidc": {
26 "issuer": "https://id.example.com",
27 "client_id": "eitri-console",
28 "client_secret": "...",
29 "public_url": "https://eitri.example.com",
30 "allowed_domains": ["example.com"],
31 "allowed_identities": ["alex@example.com"]
32 }
33 ```
34
35 - `issuer` is the IdP's base URL; the server discovers its endpoints from
36 `<issuer>/.well-known/openid-configuration`.
37 - `client_secret` is required for an external confidential client (the bundled
38 issuer omits it — it's a public PKCE client).
39 - `public_url` builds the redirect and must match what you registered.
40
41 ## The signup gate
42
43 `allowed_domains` and `allowed_identities` are the signup gate. If either is
44 set, an identity matching neither is rejected at callback — no tenant created.
45 Leave both unset for open signup (anyone your IdP authenticates gets a tenant).
46
47 Each new identity's first sign-in creates its own tenant.
docs/credential-revocation.md
Old New
@@ -14,10 +14,13 @@ sessions within one tick.
14 ## Single credential leaked (the common case) 14 ## Single credential leaked (the common case)
15 15
16 ``` 16 ```
17 curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \ 17 curl -X POST -H "Authorization: Bearer $EITRI_TOKEN" \
18 http://server:8080/api/v1/hosts/<host_id>/revoke-credential 18 http://server:8080/api/v1/hosts/<host_id>/revoke-credential
19 ``` 19 ```
20 20
21 `$EITRI_TOKEN` is a personal access token (console → Settings); the call
22 revokes a credential on one of your own hosts.
23
21 - Bumps that host's generation: the leaked credential is dead fleet-wide 24 - Bumps that host's generation: the leaked credential is dead fleet-wide
22 within ~10s; every other host is untouched. 25 within ~10s; every other host is untouched.
23 - The host's VMs keep running (the agent reconciles autonomously); the host 26 - The host's VMs keep running (the agent reconciles autonomously); the host
@@ -48,7 +51,7 @@ The short validity you sign with (`eitri ssh` uses 30 minutes) is the
48 first line of defense: a leaked cert expires on its own. 51 first line of defense: a leaked cert expires on its own.
49 52
50 Before it does, a specific cert can be revoked at the gate by serial 53 Before it does, a specific cert can be revoked at the gate by serial
51 (admin-authed, idempotent): 54 (tenant-scoped, idempotent):
52 55
53 ``` 56 ```
54 POST /api/v1/ssh-certs/revoke {"serial": N} or {"certificate": "<cert line>"} 57 POST /api/v1/ssh-certs/revoke {"serial": N} or {"certificate": "<cert line>"}
docs/mcp.md
Old New
@@ -37,7 +37,7 @@ prompts gate every call regardless.
37 ```json 37 ```json
38 { 38 {
39 "server_url": "http://127.0.0.1:8080", 39 "server_url": "http://127.0.0.1:8080",
40 "admin_token_file": "~/eitri-deploy/admin-token", 40 "token_file": "~/eitri-deploy/eitri-mcp-token",
41 "gate": "127.0.0.1:2223", 41 "gate": "127.0.0.1:2223",
42 "vm_user": "ubuntu" 42 "vm_user": "ubuntu"
43 } 43 }
@@ -45,9 +45,9 @@ prompts gate every call regardless.
45 45
46 Fields (see `internal/mcpserver/config.go`): 46 Fields (see `internal/mcpserver/config.go`):
47 - `server_url` — required, the eitri API base URL. 47 - `server_url` — required, the eitri API base URL.
48 - `admin_token_file` — required, path to a file holding the bearer token 48 - `token_file` — required, path to a file holding a personal access token
49 (read at startup, held in memory, never surfaced in a tool result or 49 (mint one in the console Settings page; read at startup, held in memory,
50 error). 50 never surfaced in a tool result or error).
51 - `gate` — the SSH-CA jump gate address, `<gate-domain>:<port>`; the MCP 51 - `gate` — the SSH-CA jump gate address, `<gate-domain>:<port>`; the MCP
52 reaches all VMs by name through it. The host part must match the 52 reaches all VMs by name through it. The host part must match the
53 gate's host certificate principal (the server's `ssh_gate_domain`, 53 gate's host certificate principal (the server's `ssh_gate_domain`,
@@ -69,7 +69,7 @@ eitri-mcp reaches VMs by name through eitri's SSH-CA jump gate — there is no
69 injected key and no TOFU. On demand, it generates an ephemeral SSH keypair 69 injected key and no TOFU. On demand, it generates an ephemeral SSH keypair
70 in memory (never written to disk) and mints a short-lived user certificate 70 in memory (never written to disk) and mints a short-lived user certificate
71 for it (principal `ubuntu`, ~10-30 min TTL) by calling 71 for it (principal `ubuntu`, ~10-30 min TTL) by calling
72 `POST /api/v1/ssh-certs` with the admin bearer token; the cert is 72 `POST /api/v1/ssh-certs` with its personal access token; the cert is
73 auto-refreshed as it nears expiry. It also fetches the eitri SSH CA's public 73 auto-refreshed as it nears expiry. It also fetches the eitri SSH CA's public
74 key once via `GET /api/v1/ssh-ca` and caches it. To reach a VM, it dials the 74 key once via `GET /api/v1/ssh-ca` and caches it. To reach a VM, it dials the
75 gate (the configured `gate` address), authenticates with the user 75 gate (the configured `gate` address), authenticates with the user
@@ -80,9 +80,9 @@ CA: the gate's host certificate must carry its configured domain as
80 principal, and each VM's host certificate must carry the VM's name. The 80 principal, and each VM's host certificate must carry the VM's name. The
81 guest trusts the CA-signed user certificate via `TrustedUserCAKeys` 81 guest trusts the CA-signed user certificate via `TrustedUserCAKeys`
82 (provisioned server-side when the gate is enabled), so no per-VM 82 (provisioned server-side when the gate is enabled), so no per-VM
83 `authorized_key` injection is needed. The admin token is only ever used to 83 `authorized_key` injection is needed. The PAT is only ever used to mint
84 mint certificates — actual SSH traffic uses the certificate, and the token 84 certificates — actual SSH traffic uses the certificate, and the token itself
85 itself is never surfaced in a tool result or error. 85 is never surfaced in a tool result or error.
86 86
87 ## Semantics 87 ## Semantics
88 88
docs/openapi.json
Old New
@@ -1,6 +1,37 @@
1 { 1 {
2 "components": { 2 "components": {
3 "schemas": { 3 "schemas": {
4 "APIToken": {
5 "properties": {
6 "created_at": {
7 "type": "string"
8 },
9 "expires_at": {
10 "type": "string"
11 },
12 "id": {
13 "type": "string"
14 },
15 "last_used_at": {
16 "type": "string"
17 },
18 "name": {
19 "type": "string"
20 },
21 "revoked_at": {
22 "type": "string"
23 }
24 },
25 "required": [
26 "created_at",
27 "expires_at",
28 "id",
29 "last_used_at",
30 "name",
31 "revoked_at"
32 ],
33 "type": "object"
34 },
4 "AuditEvent": { 35 "AuditEvent": {
5 "properties": { 36 "properties": {
6 "action": { 37 "action": {
@@ -38,6 +69,40 @@
38 ], 69 ],
39 "type": "object" 70 "type": "object"
40 }, 71 },
72 "CreateAPITokenRequest": {
73 "properties": {
74 "name": {
75 "type": "string"
76 },
77 "ttl_seconds": {
78 "type": "integer"
79 }
80 },
81 "type": "object"
82 },
83 "CreateAPITokenResponse": {
84 "properties": {
85 "expires_at": {
86 "type": "string"
87 },
88 "id": {
89 "type": "string"
90 },
91 "name": {
92 "type": "string"
93 },
94 "token": {
95 "type": "string"
96 }
97 },
98 "required": [
99 "expires_at",
100 "id",
101 "name",
102 "token"
103 ],
104 "type": "object"
105 },
41 "CreateVMRequest": { 106 "CreateVMRequest": {
42 "properties": { 107 "properties": {
43 "cloud_init": { 108 "cloud_init": {
@@ -264,6 +329,21 @@
264 ], 329 ],
265 "type": "object" 330 "type": "object"
266 }, 331 },
332 "Me": {
333 "properties": {
334 "email": {
335 "type": "string"
336 },
337 "tenant": {
338 "type": "string"
339 }
340 },
341 "required": [
342 "email",
343 "tenant"
344 ],
345 "type": "object"
346 },
267 "Metrics": { 347 "Metrics": {
268 "properties": { 348 "properties": {
269 "disk_free_gb": { 349 "disk_free_gb": {
@@ -522,7 +602,7 @@
522 } 602 }
523 }, 603 },
524 "securitySchemes": { 604 "securitySchemes": {
525 "adminToken": { 605 "patToken": {
526 "scheme": "bearer", 606 "scheme": "bearer",
527 "type": "http" 607 "type": "http"
528 } 608 }
@@ -574,7 +654,7 @@
574 }, 654 },
575 "security": [ 655 "security": [
576 { 656 {
577 "adminToken": [] 657 "patToken": []
578 } 658 }
579 ], 659 ],
580 "summary": "Newest audit log rows." 660 "summary": "Newest audit log rows."
@@ -643,7 +723,7 @@
643 }, 723 },
644 "security": [ 724 "security": [
645 { 725 {
646 "adminToken": [] 726 "patToken": []
647 } 727 }
648 ], 728 ],
649 "summary": "Mint a one-time host enrollment token plus the join blob agents consume." 729 "summary": "Mint a one-time host enrollment token plus the join blob agents consume."
@@ -716,7 +796,7 @@
716 }, 796 },
717 "security": [ 797 "security": [
718 { 798 {
719 "adminToken": [] 799 "patToken": []
720 } 800 }
721 ], 801 ],
722 "summary": "List fleet hosts: durable rows merged with live agent state and allocation." 802 "summary": "List fleet hosts: durable rows merged with live agent state and allocation."
@@ -760,7 +840,7 @@
760 }, 840 },
761 "security": [ 841 "security": [
762 { 842 {
763 "adminToken": [] 843 "patToken": []
764 } 844 }
765 ], 845 ],
766 "summary": "Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200." 846 "summary": "Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200."
@@ -795,7 +875,7 @@
795 }, 875 },
796 "security": [ 876 "security": [
797 { 877 {
798 "adminToken": [] 878 "patToken": []
799 } 879 }
800 ], 880 ],
801 "summary": "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled." 881 "summary": "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled."
@@ -830,12 +910,44 @@
830 }, 910 },
831 "security": [ 911 "security": [
832 { 912 {
833 "adminToken": [] 913 "patToken": []
834 } 914 }
835 ], 915 ],
836 "summary": "Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout)." 916 "summary": "Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout)."
837 } 917 }
838 }, 918 },
919 "/api/v1/me": {
920 "get": {
921 "responses": {
922 "200": {
923 "content": {
924 "application/json": {
925 "schema": {
926 "$ref": "#/components/schemas/Me"
927 }
928 }
929 },
930 "description": "success"
931 },
932 "default": {
933 "content": {
934 "text/plain": {
935 "schema": {
936 "type": "string"
937 }
938 }
939 },
940 "description": "error (plain text)"
941 }
942 },
943 "security": [
944 {
945 "patToken": []
946 }
947 ],
948 "summary": "The signed-in identity: the caller's tenant handle and bound email."
949 }
950 },
839 "/api/v1/ssh-ca": { 951 "/api/v1/ssh-ca": {
840 "get": { 952 "get": {
841 "responses": { 953 "responses": {
@@ -892,7 +1004,7 @@
892 }, 1004 },
893 "security": [ 1005 "security": [
894 { 1006 {
895 "adminToken": [] 1007 "patToken": []
896 } 1008 }
897 ], 1009 ],
898 "summary": "Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent." 1010 "summary": "Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent."
@@ -927,7 +1039,7 @@
927 }, 1039 },
928 "security": [ 1040 "security": [
929 { 1041 {
930 "adminToken": [] 1042 "patToken": []
931 } 1043 }
932 ], 1044 ],
933 "summary": "List revoked SSH user certificate serials (with reason and time), newest first." 1045 "summary": "List revoked SSH user certificate serials (with reason and time), newest first."
@@ -959,7 +1071,7 @@
959 }, 1071 },
960 "security": [ 1072 "security": [
961 { 1073 {
962 "adminToken": [] 1074 "patToken": []
963 } 1075 }
964 ], 1076 ],
965 "summary": "Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL." 1077 "summary": "Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL."
@@ -1004,7 +1116,7 @@
1004 }, 1116 },
1005 "security": [ 1117 "security": [
1006 { 1118 {
1007 "adminToken": [] 1119 "patToken": []
1008 } 1120 }
1009 ], 1121 ],
1010 "summary": "Remove a registered SSH user CA by its public_key line." 1122 "summary": "Remove a registered SSH user CA by its public_key line."
@@ -1047,7 +1159,7 @@
1047 }, 1159 },
1048 "security": [ 1160 "security": [
1049 { 1161 {
1050 "adminToken": [] 1162 "patToken": []
1051 } 1163 }
1052 ], 1164 ],
1053 "summary": "List the tenant's registered SSH user CAs (pubkey, label, fingerprint)." 1165 "summary": "List the tenant's registered SSH user CAs (pubkey, label, fingerprint)."
@@ -1097,12 +1209,122 @@
1097 }, 1209 },
1098 "security": [ 1210 "security": [
1099 { 1211 {
1100 "adminToken": [] 1212 "patToken": []
1101 } 1213 }
1102 ], 1214 ],
1103 "summary": "Register a BYO SSH user CA public key for the tenant; eitri never holds a user signing key." 1215 "summary": "Register a BYO SSH user CA public key for the tenant; eitri never holds a user signing key."
1104 } 1216 }
1105 }, 1217 },
1218 "/api/v1/tokens": {
1219 "get": {
1220 "responses": {
1221 "200": {
1222 "content": {
1223 "application/json": {
1224 "schema": {
1225 "items": {
1226 "$ref": "#/components/schemas/APIToken"
1227 },
1228 "type": "array"
1229 }
1230 }
1231 },
1232 "description": "success"
1233 },
1234 "default": {
1235 "content": {
1236 "text/plain": {
1237 "schema": {
1238 "type": "string"
1239 }
1240 }
1241 },
1242 "description": "error (plain text)"
1243 }
1244 },
1245 "security": [
1246 {
1247 "patToken": []
1248 }
1249 ],
1250 "summary": "List the tenant's personal access tokens (metadata only — never the secret), newest first."
1251 },
1252 "post": {
1253 "requestBody": {
1254 "content": {
1255 "application/json": {
1256 "schema": {
1257 "$ref": "#/components/schemas/CreateAPITokenRequest"
1258 }
1259 }
1260 },
1261 "required": true
1262 },
1263 "responses": {
1264 "201": {
1265 "content": {
1266 "application/json": {
1267 "schema": {
1268 "$ref": "#/components/schemas/CreateAPITokenResponse"
1269 }
1270 }
1271 },
1272 "description": "success"
1273 },
1274 "default": {
1275 "content": {
1276 "text/plain": {
1277 "schema": {
1278 "type": "string"
1279 }
1280 }
1281 },
1282 "description": "error (plain text)"
1283 }
1284 },
1285 "security": [
1286 {
1287 "patToken": []
1288 }
1289 ],
1290 "summary": "Mint a personal access token; the secret is returned exactly once. An optional TTL sets expiry (0 = non-expiring)."
1291 }
1292 },
1293 "/api/v1/tokens/{id}": {
1294 "delete": {
1295 "parameters": [
1296 {
1297 "in": "path",
1298 "name": "id",
1299 "required": true,
1300 "schema": {
1301 "type": "string"
1302 }
1303 }
1304 ],
1305 "responses": {
1306 "204": {
1307 "description": "success"
1308 },
1309 "default": {
1310 "content": {
1311 "text/plain": {
1312 "schema": {
1313 "type": "string"
1314 }
1315 }
1316 },
1317 "description": "error (plain text)"
1318 }
1319 },
1320 "security": [
1321 {
1322 "patToken": []
1323 }
1324 ],
1325 "summary": "Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak)."
1326 }
1327 },
1106 "/api/v1/vms": { 1328 "/api/v1/vms": {
1107 "get": { 1329 "get": {
1108 "responses": { 1330 "responses": {
@@ -1132,7 +1354,7 @@
1132 }, 1354 },
1133 "security": [ 1355 "security": [
1134 { 1356 {
1135 "adminToken": [] 1357 "patToken": []
1136 } 1358 }
1137 ], 1359 ],
1138 "summary": "List VMs: durable rows merged with live agent-reported actual state." 1360 "summary": "List VMs: durable rows merged with live agent-reported actual state."
@@ -1172,7 +1394,7 @@
1172 }, 1394 },
1173 "security": [ 1395 "security": [
1174 { 1396 {
1175 "adminToken": [] 1397 "patToken": []
1176 } 1398 }
1177 ], 1399 ],
1178 "summary": "Create a VM on a host. Omitted fields get one-click defaults; the tenant must have a registered SSH user CA first." 1400 "summary": "Create a VM on a host. Omitted fields get one-click defaults; the tenant must have a registered SSH user CA first."
@@ -1207,7 +1429,7 @@
1207 }, 1429 },
1208 "security": [ 1430 "security": [
1209 { 1431 {
1210 "adminToken": [] 1432 "patToken": []
1211 } 1433 }
1212 ], 1434 ],
1213 "summary": "Tombstone a VM for teardown; restorable within the grace window via restore." 1435 "summary": "Tombstone a VM for teardown; restorable within the grace window via restore."
@@ -1250,7 +1472,7 @@
1250 }, 1472 },
1251 "security": [ 1473 "security": [
1252 { 1474 {
1253 "adminToken": [] 1475 "patToken": []
1254 } 1476 }
1255 ], 1477 ],
1256 "summary": "Set a VM's desired power state (running or stopped)." 1478 "summary": "Set a VM's desired power state (running or stopped)."
@@ -1343,7 +1565,7 @@
1343 }, 1565 },
1344 "security": [ 1566 "security": [
1345 { 1567 {
1346 "adminToken": [] 1568 "patToken": []
1347 } 1569 }
1348 ], 1570 ],
1349 "summary": "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped." 1571 "summary": "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped."
@@ -1378,7 +1600,7 @@
1378 }, 1600 },
1379 "security": [ 1601 "security": [
1380 { 1602 {
1381 "adminToken": [] 1603 "patToken": []
1382 } 1604 }
1383 ], 1605 ],
1384 "summary": "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest." 1606 "summary": "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest."
docs/quickstart.md
Old New
@@ -15,7 +15,9 @@ manage them by hand instead, disable it in `/etc/default/eitri-agent`:
15 15
16 Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle 16 Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle
17 (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and 17 (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and
18 the agent's systemd unit. The client bundle 18 the agent's systemd unit. The issuer bundle
19 (`eitri-oidc_<version>_linux_amd64.tar.gz`) has `eitri-oidc` — the bundled
20 sign-in provider — and its unit. The client bundle
19 (`eitri-cli_<version>_<os>_<arch>.tar.gz`) is the single `eitri` binary for 21 (`eitri-cli_<version>_<os>_<arch>.tar.gz`) is the single `eitri` binary for
20 your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle. 22 your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle.
21 23
@@ -33,7 +35,6 @@ Set `SERVER_ADDR`, paste the rest:
33 SERVER_ADDR=192.0.2.10 35 SERVER_ADDR=192.0.2.10
34 IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current 36 IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current
35 IMAGE_FILE=resolute-server-cloudimg-amd64.img 37 IMAGE_FILE=resolute-server-cloudimg-amd64.img
36 ADMIN_TOKEN=$(openssl rand -hex 32)
37 HOST_SECRET=$(openssl rand -hex 32) 38 HOST_SECRET=$(openssl rand -hex 32)
38 IMAGE_SHA256=$(curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="$IMAGE_FILE" '$2 == "*" f {print $1}') 39 IMAGE_SHA256=$(curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="$IMAGE_FILE" '$2 == "*" f {print $1}')
39 40
@@ -45,7 +46,11 @@ sudo tee /etc/eitri/server.json >/dev/null <<EOF
45 "advertise_quic": "$SERVER_ADDR:8443", 46 "advertise_quic": "$SERVER_ADDR:8443",
46 "db_path": "/var/lib/eitri/eitri.db", 47 "db_path": "/var/lib/eitri/eitri.db",
47 "cidr_pool": "10.100.0.0/16", 48 "cidr_pool": "10.100.0.0/16",
48 "admin_token": "$ADMIN_TOKEN", 49 "oidc": {
50 "issuer": "http://127.0.0.1:9111",
51 "client_id": "eitri-console",
52 "public_url": "http://$SERVER_ADDR:8080"
53 },
49 "host_secret": "$HOST_SECRET", 54 "host_secret": "$HOST_SECRET",
50 "default_image_url": "$IMAGE_DIR/$IMAGE_FILE", 55 "default_image_url": "$IMAGE_DIR/$IMAGE_FILE",
51 "default_image_sha256": "$IMAGE_SHA256", 56 "default_image_sha256": "$IMAGE_SHA256",
@@ -55,15 +60,49 @@ sudo tee /etc/eitri/server.json >/dev/null <<EOF
55 "ssh_host_key": "/var/lib/eitri/ssh_host_key" 60 "ssh_host_key": "/var/lib/eitri/ssh_host_key"
56 } 61 }
57 EOF 62 EOF
58
59 echo "console login token: $ADMIN_TOKEN" # keep this
60 ``` 63 ```
61 64
62 `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any 65 `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any
63 cloud-init disk image works as the default image; the Ubuntu one boots out of 66 cloud-init disk image works as the default image; the Ubuntu one boots out of
64 the box. 67 the box.
65 68
66 Run it: 69 The `oidc` block points the console's sign-in at the bundled issuer you start
70 next. `public_url` is where browsers reach the console (the callback lands at
71 `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing
72 your own IdP instead of the bundled issuer: see [byo-idp.md](byo-idp.md).
73
74 ## Sign-in
75
76 The console always signs in through OIDC. The bundled `eitri-oidc` issuer runs
77 next to the server on loopback. Install it, write its config, and add yourself:
78
79 ```sh
80 tar xzf eitri-oidc_*_linux_amd64.tar.gz && cd eitri-oidc_*_linux_amd64
81 sudo install -m 0755 eitri-oidc /usr/local/bin/eitri-oidc
82 sudo install -m 0644 eitri-oidc.service /etc/systemd/system/eitri-oidc.service
83
84 sudo tee /etc/eitri/eitri-oidc.json >/dev/null <<EOF
85 {
86 "listen": "127.0.0.1:9111",
87 "issuer": "http://127.0.0.1:9111",
88 "users_file": "/etc/eitri/oidc-users.json",
89 "signing_key": "/etc/eitri/oidc-signing.key",
90 "clients": [
91 {"id": "eitri-console", "redirect_url": "http://$SERVER_ADDR:8080/auth/callback"}
92 ]
93 }
94 EOF
95
96 sudo systemctl daemon-reload
97 sudo systemctl enable --now eitri-oidc
98 sudo eitri-oidc user add you@example.com # prompts for a password
99 ```
100
101 `redirect_url` must equal the server's `oidc.public_url` + `/auth/callback`. On
102 this single box the loopback `issuer`/`public_url` work because your browser is
103 on the same machine; anything multi-machine needs a routable issuer.
104
105 Now run the server:
67 106
68 ```sh 107 ```sh
69 sudo eitri-server --config /etc/eitri/server.json 108 sudo eitri-server --config /etc/eitri/server.json
@@ -71,9 +110,11 @@ sudo eitri-server --config /etc/eitri/server.json
71 110
72 It runs in the foreground. nohup, tmux, or write a unit. It speaks plain 111 It runs in the foreground. nohup, tmux, or write a unit. It speaks plain
73 HTTP, so keep it on your LAN or put TLS in front. Open `8080/tcp` (console, 112 HTTP, so keep it on your LAN or put TLS in front. Open `8080/tcp` (console,
74 enroll), `8443/udp` (sync), `2222/tcp` (SSH gate). 113 enroll), `8443/udp` (sync), `2222/tcp` (SSH gate). The issuer stays on
114 loopback.
75 115
76 Log in at `http://192.0.2.10:8080` with the token echoed above. 116 Sign in at `http://192.0.2.10:8080` with the user you added. Your first
117 sign-in creates your tenant.
77 118
78 ## Join a host 119 ## Join a host
79 120
@@ -103,12 +144,15 @@ tar xzf eitri-cli_*_$(uname -s | tr A-Z a-z)_*.tar.gz
103 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri 144 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri
104 145
105 export EITRI_URL=http://192.0.2.10:8080 146 export EITRI_URL=http://192.0.2.10:8080
106 export EITRI_TOKEN=<console-login-token> 147 export EITRI_TOKEN=<pat> # mint one in the console → Settings
107 148
108 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" 149 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
109 eitri ca upload default ~/.ssh/eitri_user_ca.pub 150 eitri ca upload <your-tenant> ~/.ssh/eitri_user_ca.pub
110 ``` 151 ```
111 152
153 `<your-tenant>` is the handle shown on the Settings page — derived from your
154 email on first sign-in.
155
112 **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the 156 **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the
113 default image. Status reads `creating` while the image downloads and the 157 default image. Status reads `creating` while the image downloads and the
114 guest boots, then `ready`. Power reads `running`, an IP appears, you're on. 158 guest boots, then `ready`. Power reads `running`, an IP appears, you're on.
@@ -117,6 +161,7 @@ guest boots, then `ready`. Power reads `running`, an IP appears, you're on.
117 161
118 ```sh 162 ```sh
119 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain 163 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain
164 export EITRI_TENANT=<your-tenant> # same handle you uploaded the CA to
120 165
121 eitri ssh <vm-name> 166 eitri ssh <vm-name>
122 eitri ssh <vm-name> uptime 167 eitri ssh <vm-name> uptime
@@ -124,8 +169,8 @@ eitri ssh <vm-name> uptime
124 169
125 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with 170 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with
126 your CA, pins eitri's host CA, and jumps the gate to 171 your CA, pins eitri's host CA, and jumps the gate to
127 `ubuntu@default.<vm-name>`. No token. [ssh-access.md](ssh-access.md) shows it 172 `ubuntu@<your-tenant>.<vm-name>`. No token. [ssh-access.md](ssh-access.md)
128 done by hand. 173 shows it done by hand.
129 174
130 ## More 175 ## More
131 176
docs/shape.html
Old New
@@ -101,6 +101,15 @@
101 ] 101 ]
102 }, 102 },
103 { 103 {
104 "importPath": "cmd/eitri-oidc",
105 "plane": "binaries",
106 "synopsis": "eitri-oidc: the bundled OIDC issuer.",
107 "imports": [
108 "internal/oidcprovider",
109 "internal/version"
110 ]
111 },
112 {
104 "importPath": "cmd/eitri-server", 113 "importPath": "cmd/eitri-server",
105 "plane": "binaries", 114 "plane": "binaries",
106 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 115 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
@@ -323,6 +332,12 @@
323 "imports": [] 332 "imports": []
324 }, 333 },
325 { 334 {
335 "importPath": "internal/oidcprovider",
336 "plane": "tooling",
337 "synopsis": "Package oidcprovider is a minimal, spec-compliant OIDC issuer: discovery, authorization-code + PKCE, token, and JWKS, with users in a flat file.",
338 "imports": []
339 },
340 {
326 "importPath": "internal/pb", 341 "importPath": "internal/pb",
327 "plane": "wire", 342 "plane": "wire",
328 "synopsis": "", 343 "synopsis": "",
docs/shape.json
Old New
@@ -50,6 +50,15 @@
50 ] 50 ]
51 }, 51 },
52 { 52 {
53 "importPath": "cmd/eitri-oidc",
54 "plane": "binaries",
55 "synopsis": "eitri-oidc: the bundled OIDC issuer.",
56 "imports": [
57 "internal/oidcprovider",
58 "internal/version"
59 ]
60 },
61 {
53 "importPath": "cmd/eitri-server", 62 "importPath": "cmd/eitri-server",
54 "plane": "binaries", 63 "plane": "binaries",
55 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 64 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
@@ -272,6 +281,12 @@
272 "imports": [] 281 "imports": []
273 }, 282 },
274 { 283 {
284 "importPath": "internal/oidcprovider",
285 "plane": "tooling",
286 "synopsis": "Package oidcprovider is a minimal, spec-compliant OIDC issuer: discovery, authorization-code + PKCE, token, and JWKS, with users in a flat file.",
287 "imports": []
288 },
289 {
275 "importPath": "internal/pb", 290 "importPath": "internal/pb",
276 "plane": "wire", 291 "plane": "wire",
277 "synopsis": "", 292 "synopsis": "",
docs/ssh-access.md
Old New
@@ -21,14 +21,15 @@ a server compromise cannot mint user credentials.
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
24 (admin-authed, `POST /api/v1/tenants/<tenant>/user-cas`): 24 (`POST /api/v1/tenants/<tenant>/user-cas`, authenticated by a personal access
25 token):
25 26
26 ```sh 27 ```sh
27 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA" 28 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA"
28 29
29 export EITRI_URL=https://eitri.example.com 30 export EITRI_URL=https://eitri.example.com
30 export EITRI_TOKEN=<admin-bearer-token> 31 export EITRI_TOKEN=<personal-access-token> # mint one in the console → Settings
31 eitri ca upload default ~/.ssh/eitri_user_ca.pub 32 eitri ca upload <your-tenant> ~/.ssh/eitri_user_ca.pub
32 ``` 33 ```
33 34
34 The CA's private key never leaves your machine; the server stores only the 35 The CA's private key never leaves your machine; the server stores only the
go.mod
Old New
@@ -4,6 +4,7 @@ go 1.26.4
4 4
5 require ( 5 require (
6 github.com/coder/websocket v1.8.15 6 github.com/coder/websocket v1.8.15
7 github.com/coreos/go-oidc/v3 v3.20.0
7 github.com/diskfs/go-diskfs v1.9.3 8 github.com/diskfs/go-diskfs v1.9.3
8 github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82 9 github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
9 github.com/modelcontextprotocol/go-sdk v1.6.1 10 github.com/modelcontextprotocol/go-sdk v1.6.1
@@ -12,6 +13,7 @@ require (
12 github.com/stretchr/testify v1.11.1 13 github.com/stretchr/testify v1.11.1
13 github.com/yuin/goldmark v1.8.4 14 github.com/yuin/goldmark v1.8.4
14 golang.org/x/crypto v0.54.0 15 golang.org/x/crypto v0.54.0
16 golang.org/x/oauth2 v0.36.0
15 golang.org/x/sync v0.20.0 17 golang.org/x/sync v0.20.0
16 golang.org/x/term v0.45.0 18 golang.org/x/term v0.45.0
17 google.golang.org/protobuf v1.36.11 19 google.golang.org/protobuf v1.36.11
@@ -25,6 +27,7 @@ require (
25 github.com/djherbis/times v1.6.0 // indirect 27 github.com/djherbis/times v1.6.0 // indirect
26 github.com/dustin/go-humanize v1.0.1 // indirect 28 github.com/dustin/go-humanize v1.0.1 // indirect
27 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect 29 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
30 github.com/go-jose/go-jose/v4 v4.1.4 // indirect
28 github.com/go-logr/logr v1.4.3 // indirect 31 github.com/go-logr/logr v1.4.3 // indirect
29 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 32 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
30 github.com/golang/protobuf v1.5.4 // indirect 33 github.com/golang/protobuf v1.5.4 // indirect
@@ -51,7 +54,6 @@ require (
51 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 54 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
52 golang.org/x/mod v0.33.0 // indirect 55 golang.org/x/mod v0.33.0 // indirect
53 golang.org/x/net v0.56.0 // indirect 56 golang.org/x/net v0.56.0 // indirect
54 golang.org/x/oauth2 v0.35.0 // indirect
55 golang.org/x/sys v0.47.0 // indirect 57 golang.org/x/sys v0.47.0 // indirect
56 golang.org/x/tools v0.42.0 // indirect 58 golang.org/x/tools v0.42.0 // indirect
57 modernc.org/libc v1.72.3 // indirect 59 modernc.org/libc v1.72.3 // indirect
go.sum
Old New
@@ -2,6 +2,8 @@ github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
2 github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= 2 github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
3 github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= 3 github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
4 github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= 4 github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
5 github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
6 github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
5 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -13,6 +15,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
13 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 15 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
14 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg= 16 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
15 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= 17 github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
18 github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
19 github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
16 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 20 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
17 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
18 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 22 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
@@ -97,8 +101,8 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
97 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 101 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
98 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= 102 golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
99 golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 103 golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
100 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= 104 golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
101 golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 105 golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
102 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 106 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
103 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 107 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
104 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 108 golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
internal/agent/syncclient/client_test.go
Old New
@@ -158,11 +158,23 @@ type serverHarness struct {
158 svc *syncsvc.Service 158 svc *syncsvc.Service
159 } 159 }
160 160
161 // testTenant is the tenant the harness provisions — through the real JIT
162 // path, the only way tenants are born. The name has no significance.
163 const testTenant = "default"
164
165 // seedTestTenant JIT-provisions testTenant on a fresh store.
166 func seedTestTenant(t *testing.T, st *store.Store) {
167 t.Helper()
168 _, err := st.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
169 require.NoError(t, err)
170 }
171
161 func newServerHarness(t *testing.T) *serverHarness { 172 func newServerHarness(t *testing.T) *serverHarness {
162 t.Helper() 173 t.Helper()
163 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 174 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
164 require.NoError(t, err) 175 require.NoError(t, err)
165 t.Cleanup(func() { st.Close() }) 176 t.Cleanup(func() { st.Close() })
177 seedTestTenant(t, st)
166 certPEM, keyPEM, fp := genTestCert(t) 178 certPEM, keyPEM, fp := genTestCert(t)
167 h := &serverHarness{ 179 h := &serverHarness{
168 t: t, st: st, reg: registry.New(time.Now), hub: hub.New(), 180 t: t, st: st, reg: registry.New(time.Now), hub: hub.New(),
@@ -193,7 +205,7 @@ func (h *serverHarness) stop() {
193 205
194 // enroll creates a host and returns a valid credential for it. 206 // enroll creates a host and returns a valid credential for it.
195 func (h *serverHarness) enroll() (hostID, cred string) { 207 func (h *serverHarness) enroll() (hostID, cred string) {
196 tok, _ := h.st.CreateEnrollmentToken(store.DefaultTenant) 208 tok, _ := h.st.CreateEnrollmentToken(testTenant)
197 host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 209 host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
198 require.NoError(h.t, err) 210 require.NoError(h.t, err)
199 return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now()) 211 return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now())
@@ -376,6 +388,7 @@ func newCountingServer(t *testing.T, onAccept func(int64) int64) *serverHarness
376 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 388 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
377 require.NoError(t, err) 389 require.NoError(t, err)
378 t.Cleanup(func() { st.Close() }) 390 t.Cleanup(func() { st.Close() })
391 seedTestTenant(t, st)
379 certPEM, keyPEM, fp := genTestCert(t) 392 certPEM, keyPEM, fp := genTestCert(t)
380 h := &serverHarness{ 393 h := &serverHarness{
381 t: t, st: st, reg: registry.New(time.Now), hub: hub.New(), 394 t: t, st: st, reg: registry.New(time.Now), hub: hub.New(),
internal/arch/arch_test.go
Old New
@@ -319,6 +319,74 @@ func TestAPITypesIsALeaf(t *testing.T) {
319 } 319 }
320 } 320 }
321 321
322 // R13: the issuer/relying-party split is Eitri's OIDC security boundary.
323 // eitri-server is a pure relying party; the bundled issuer lives in the
324 // separate cmd/eitri-oidc binary over internal/oidcprovider. Two edges keep
325 // the halves apart:
326 //
327 // (a) internal/oidcprovider may be imported only by cmd/eitri-oidc. A server
328 // (or any other) import would relink the issuer into the relying party and
329 // silently rebuild the embedded-IdP coupling this split exists to prevent.
330 // Test files anywhere may still import it as an in-process IdP
331 // (server/api's auth_test, eitri-smoke's login_test); those edges are
332 // test-only and never enter the production graph go list reports here.
333 // (b) the go-oidc verifier module belongs to internal/server/api, the relying
334 // party, alone. It is the client half of the protocol — anywhere else it
335 // appears, a second relying party is being hand-rolled. (oidcprovider
336 // verifies against go-oidc too, but only in its round-trip tests, so that
337 // edge is likewise absent from the production graph.)
338 //
339 // Mirrors R8 (a package that is a leaf of its own binary) and R10 (a module
340 // pinned to one owner), applied to the two sides of the OIDC boundary. See the
341 // issuer/relying-party rationale in the console multi-tenancy design (spec §2).
342 func TestIssuerAndRelyingPartyAreSeparate(t *testing.T) {
343 // (a) internal/oidcprovider is a leaf of the eitri-oidc binary.
344 g := internalImports(t)
345 const issuer = module + "/internal/oidcprovider"
346 const issuerBinary = module + "/cmd/eitri-oidc"
347 importedBy := false
348 for pkg, deps := range g {
349 if pkg == issuer {
350 continue
351 }
352 for _, d := range deps {
353 if d != issuer {
354 continue
355 }
356 importedBy = true
357 if pkg != issuerBinary {
358 t.Errorf("package %s must not import %s — only %s may (the issuer must never link into the relying party)", short(pkg), short(issuer), short(issuerBinary))
359 }
360 }
361 }
362 if !importedBy {
363 // A rename of the issuer package would silently pass; guard it.
364 t.Errorf("sweep found no importer of %s at all — did the issuer package move?", short(issuer))
365 }
366
367 // (b) the go-oidc verifier is the relying party's alone. The imported path
368 // is the /oidc subpackage, so match the module prefix (mirrors R10's
369 // single-owner pin, prefix-matched because the module ships one package).
370 gd := directImports(t)
371 const goOIDC = "github.com/coreos/go-oidc/v3"
372 const relyingParty = module + "/internal/server/api"
373 found := false
374 for pkg, deps := range gd {
375 for _, d := range deps {
376 if !strings.HasPrefix(d, goOIDC) {
377 continue
378 }
379 found = true
380 if pkg != relyingParty {
381 t.Errorf("package %s imports %s, but only the relying party %s may", short(pkg), d, short(relyingParty))
382 }
383 }
384 }
385 if !found {
386 t.Errorf("sweep found no importer of %s at all — did the module move or the relying party change?", goOIDC)
387 }
388 }
389
322 // assertNotImported fails if pkg directly imports any path in forbidden. 390 // assertNotImported fails if pkg directly imports any path in forbidden.
323 func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) { 391 func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) {
324 t.Helper() 392 t.Helper()
internal/cli/env.go
Old New
@@ -22,7 +22,9 @@ type Env struct {
22 } 22 }
23 23
24 // FromEnv resolves the env contract shared with the former scripts: 24 // FromEnv resolves the env contract shared with the former scripts:
25 // EITRI_URL and EITRI_GATE are required; the rest default under $HOME/.ssh. 25 // EITRI_URL, EITRI_GATE and EITRI_TENANT are required (there is no implicit
26 // tenant — connect names are <tenant>.<vm> and guessing one would fail with an
27 // opaque gate refusal); the path fields default under $HOME/.ssh.
26 func FromEnv() (Env, error) { 28 func FromEnv() (Env, error) {
27 e := Env{ 29 e := Env{
28 URL: os.Getenv("EITRI_URL"), 30 URL: os.Getenv("EITRI_URL"),
@@ -35,6 +37,9 @@ func FromEnv() (Env, error) {
35 if e.URL == "" || e.Gate == "" { 37 if e.URL == "" || e.Gate == "" {
36 return Env{}, fmt.Errorf("set EITRI_URL (server base URL) and EITRI_GATE (gate host:port)") 38 return Env{}, fmt.Errorf("set EITRI_URL (server base URL) and EITRI_GATE (gate host:port)")
37 } 39 }
40 if e.Tenant == "" {
41 return Env{}, fmt.Errorf("set EITRI_TENANT (your tenant handle — shown on the console's Settings page)")
42 }
38 home, err := os.UserHomeDir() 43 home, err := os.UserHomeDir()
39 if err != nil { 44 if err != nil {
40 return Env{}, err 45 return Env{}, err
@@ -47,8 +52,5 @@ func FromEnv() (Env, error) {
47 def(&e.CA, "eitri_user_ca") 52 def(&e.CA, "eitri_user_ca")
48 def(&e.Key, "id_ed25519") 53 def(&e.Key, "id_ed25519")
49 def(&e.KnownHosts, "eitri_known_hosts") 54 def(&e.KnownHosts, "eitri_known_hosts")
50 if e.Tenant == "" {
51 e.Tenant = "default"
52 }
53 return e, nil 55 return e, nil
54 } 56 }
internal/cli/env_test.go
Old New
@@ -2,13 +2,15 @@ package cli
2 2
3 import ( 3 import (
4 "path/filepath" 4 "path/filepath"
5 "strings"
5 "testing" 6 "testing"
6 ) 7 )
7 8
8 func TestFromEnvDefaults(t *testing.T) { 9 func TestFromEnvDefaults(t *testing.T) {
9 t.Setenv("EITRI_URL", "http://192.0.2.10:8080") 10 t.Setenv("EITRI_URL", "http://192.0.2.10:8080")
10 t.Setenv("EITRI_GATE", "192.0.2.10:2222") 11 t.Setenv("EITRI_GATE", "192.0.2.10:2222")
11 for _, v := range []string{"EITRI_CA", "EITRI_TENANT", "EITRI_KEY", "EITRI_KNOWN_HOSTS"} { 12 t.Setenv("EITRI_TENANT", "acme")
13 for _, v := range []string{"EITRI_CA", "EITRI_KEY", "EITRI_KNOWN_HOSTS"} {
12 t.Setenv(v, "") 14 t.Setenv(v, "")
13 } 15 }
14 t.Setenv("HOME", "/home/u") 16 t.Setenv("HOME", "/home/u")
@@ -17,12 +19,11 @@ func TestFromEnvDefaults(t *testing.T) {
17 if err != nil { 19 if err != nil {
18 t.Fatal(err) 20 t.Fatal(err)
19 } 21 }
20 if e.URL != "http://192.0.2.10:8080" || e.Gate != "192.0.2.10:2222" { 22 if e.URL != "http://192.0.2.10:8080" || e.Gate != "192.0.2.10:2222" || e.Tenant != "acme" {
21 t.Errorf("required fields: %+v", e) 23 t.Errorf("required fields: %+v", e)
22 } 24 }
23 for got, want := range map[string]string{ 25 for got, want := range map[string]string{
24 e.CA: filepath.Join("/home/u", ".ssh", "eitri_user_ca"), 26 e.CA: filepath.Join("/home/u", ".ssh", "eitri_user_ca"),
25 e.Tenant: "default",
26 e.Key: filepath.Join("/home/u", ".ssh", "id_ed25519"), 27 e.Key: filepath.Join("/home/u", ".ssh", "id_ed25519"),
27 e.KnownHosts: filepath.Join("/home/u", ".ssh", "eitri_known_hosts"), 28 e.KnownHosts: filepath.Join("/home/u", ".ssh", "eitri_known_hosts"),
28 } { 29 } {
@@ -39,3 +40,19 @@ func TestFromEnvRequiresURLAndGate(t *testing.T) {
39 t.Fatal("want error without EITRI_URL/EITRI_GATE") 40 t.Fatal("want error without EITRI_URL/EITRI_GATE")
40 } 41 }
41 } 42 }
43
44 func TestFromEnvRequiresTenant(t *testing.T) {
45 // There is no implicit tenant: connect names are <tenant>.<vm>, and a
46 // guessed tenant would surface as an opaque gate refusal instead of this
47 // actionable error.
48 t.Setenv("EITRI_URL", "http://192.0.2.10:8080")
49 t.Setenv("EITRI_GATE", "192.0.2.10:2222")
50 t.Setenv("EITRI_TENANT", "")
51 _, err := FromEnv()
52 if err == nil {
53 t.Fatal("want error without EITRI_TENANT")
54 }
55 if !strings.Contains(err.Error(), "EITRI_TENANT") {
56 t.Errorf("error must name the missing variable: %v", err)
57 }
58 }
internal/mcpserver/config.go
Old New
@@ -2,7 +2,7 @@
2 // model create, control (SSH exec/files), and destroy eitri VMs. It is an API 2 // model create, control (SSH exec/files), and destroy eitri VMs. It is an API
3 // CLIENT of the control plane — it speaks to it only through the shared API 3 // CLIENT of the control plane — it speaks to it only through the shared API
4 // client (internal/server/api/client), never any other server internals, and 4 // client (internal/server/api/client), never any other server internals, and
5 // the admin token it holds must never appear in tool results or errors. 5 // the PAT it holds must never appear in tool results or errors.
6 package mcpserver 6 package mcpserver
7 7
8 import ( 8 import (
@@ -15,17 +15,17 @@ import (
15 15
16 // Config is eitri-mcp's on-disk configuration. 16 // Config is eitri-mcp's on-disk configuration.
17 type Config struct { 17 type Config struct {
18 ServerURL string `json:"server_url"` // eitri API base, e.g. http://127.0.0.1:8080 18 ServerURL string `json:"server_url"` // eitri API base, e.g. http://127.0.0.1:8080
19 AdminTokenFile string `json:"admin_token_file"` // file holding the bearer token 19 TokenFile string `json:"token_file"` // file holding the PAT (mint one in console Settings)
20 Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>" 20 Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>"
21 VMUser string `json:"vm_user"` // guest user (default "ubuntu") 21 VMUser string `json:"vm_user"` // guest user (default "ubuntu")
22 CAKeyPath string `json:"ca_key_path"` // this client's own user CA (load-or-create; default next to config) 22 CAKeyPath string `json:"ca_key_path"` // this client's own user CA (load-or-create; default next to config)
23 Tenant string `json:"tenant"` // this client's tenant (default "default") 23 Tenant string `json:"tenant"` // this client's tenant (default "default")
24 24
25 AdminToken string `json:"-"` // loaded from AdminTokenFile; never serialized 25 Token string `json:"-"` // loaded from TokenFile; never serialized
26 } 26 }
27 27
28 // LoadConfig reads and validates the config file and loads the admin token. 28 // LoadConfig reads and validates the config file and loads the PAT.
29 func LoadConfig(path string) (*Config, error) { 29 func LoadConfig(path string) (*Config, error) {
30 path = expandTilde(path) 30 path = expandTilde(path)
31 // Resolve to an absolute path for stable error messages regardless of the 31 // Resolve to an absolute path for stable error messages regardless of the
@@ -58,16 +58,16 @@ func LoadConfig(path string) (*Config, error) {
58 if cfg.ServerURL == "" { 58 if cfg.ServerURL == "" {
59 return nil, fmt.Errorf("config %s: server_url is required", path) 59 return nil, fmt.Errorf("config %s: server_url is required", path)
60 } 60 }
61 if cfg.AdminTokenFile == "" { 61 if cfg.TokenFile == "" {
62 return nil, fmt.Errorf("config %s: admin_token_file is required", path) 62 return nil, fmt.Errorf("config %s: token_file is required", path)
63 } 63 }
64 tok, err := os.ReadFile(expandTilde(cfg.AdminTokenFile)) 64 tok, err := os.ReadFile(expandTilde(cfg.TokenFile))
65 if err != nil { 65 if err != nil {
66 return nil, fmt.Errorf("read admin token: %w", err) 66 return nil, fmt.Errorf("read token: %w", err)
67 } 67 }
68 cfg.AdminToken = strings.TrimSpace(string(tok)) 68 cfg.Token = strings.TrimSpace(string(tok))
69 if cfg.AdminToken == "" { 69 if cfg.Token == "" {
70 return nil, fmt.Errorf("admin token file %s is empty", cfg.AdminTokenFile) 70 return nil, fmt.Errorf("token file %s is empty", cfg.TokenFile)
71 } 71 }
72 return cfg, nil 72 return cfg, nil
73 } 73 }
internal/mcpserver/config_test.go
Old New
@@ -16,7 +16,7 @@ func TestLoadConfigDefaultsAndExpansion(t *testing.T) {
16 cfgPath := filepath.Join(dir, "config.json") 16 cfgPath := filepath.Join(dir, "config.json")
17 require.NoError(t, os.WriteFile(cfgPath, []byte(`{ 17 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
18 "server_url": "http://127.0.0.1:9999", 18 "server_url": "http://127.0.0.1:9999",
19 "admin_token_file": "`+tok+`", 19 "token_file": "`+tok+`",
20 "gate": "gate.example.com:2222" 20 "gate": "gate.example.com:2222"
21 }`), 0o600)) 21 }`), 0o600))
22 22
@@ -24,8 +24,8 @@ func TestLoadConfigDefaultsAndExpansion(t *testing.T) {
24 require.NoError(t, err) 24 require.NoError(t, err)
25 assert.Equal(t, "http://127.0.0.1:9999", cfg.ServerURL) 25 assert.Equal(t, "http://127.0.0.1:9999", cfg.ServerURL)
26 assert.Equal(t, "gate.example.com:2222", cfg.Gate) 26 assert.Equal(t, "gate.example.com:2222", cfg.Gate)
27 assert.Equal(t, "ubuntu", cfg.VMUser) // default 27 assert.Equal(t, "ubuntu", cfg.VMUser) // default
28 assert.Equal(t, "sekret", cfg.AdminToken) // trimmed, loaded from file 28 assert.Equal(t, "sekret", cfg.Token) // trimmed, loaded from file
29 } 29 }
30 30
31 func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) { 31 func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) {
@@ -35,7 +35,7 @@ func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) {
35 cfgPath := filepath.Join(dir, "config.json") 35 cfgPath := filepath.Join(dir, "config.json")
36 require.NoError(t, os.WriteFile(cfgPath, []byte(`{ 36 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
37 "server_url": "http://127.0.0.1:9999", 37 "server_url": "http://127.0.0.1:9999",
38 "admin_token_file": "`+tok+`", 38 "token_file": "`+tok+`",
39 "vm_user": "" 39 "vm_user": ""
40 }`), 0o600)) 40 }`), 0o600))
41 41
@@ -52,22 +52,22 @@ func TestLoadConfigMissingRequired(t *testing.T) {
52 assert.ErrorContains(t, err, "server_url") 52 assert.ErrorContains(t, err, "server_url")
53 } 53 }
54 54
55 func TestLoadConfigMissingAdminTokenFile(t *testing.T) { 55 func TestLoadConfigMissingTokenFile(t *testing.T) {
56 dir := t.TempDir() 56 dir := t.TempDir()
57 cfgPath := filepath.Join(dir, "config.json") 57 cfgPath := filepath.Join(dir, "config.json")
58 require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": "http://127.0.0.1:9999"}`), 0o600)) 58 require.NoError(t, os.WriteFile(cfgPath, []byte(`{"server_url": "http://127.0.0.1:9999"}`), 0o600))
59 _, err := LoadConfig(cfgPath) 59 _, err := LoadConfig(cfgPath)
60 assert.ErrorContains(t, err, "admin_token_file") 60 assert.ErrorContains(t, err, "token_file")
61 } 61 }
62 62
63 func TestLoadConfigEmptyAdminTokenFile(t *testing.T) { 63 func TestLoadConfigEmptyTokenFile(t *testing.T) {
64 dir := t.TempDir() 64 dir := t.TempDir()
65 tok := filepath.Join(dir, "token") 65 tok := filepath.Join(dir, "token")
66 require.NoError(t, os.WriteFile(tok, []byte(" \n"), 0o600)) 66 require.NoError(t, os.WriteFile(tok, []byte(" \n"), 0o600))
67 cfgPath := filepath.Join(dir, "config.json") 67 cfgPath := filepath.Join(dir, "config.json")
68 require.NoError(t, os.WriteFile(cfgPath, []byte(`{ 68 require.NoError(t, os.WriteFile(cfgPath, []byte(`{
69 "server_url": "http://127.0.0.1:9999", 69 "server_url": "http://127.0.0.1:9999",
70 "admin_token_file": "`+tok+`" 70 "token_file": "`+tok+`"
71 }`), 0o600)) 71 }`), 0o600))
72 _, err := LoadConfig(cfgPath) 72 _, err := LoadConfig(cfgPath)
73 assert.ErrorContains(t, err, "empty") 73 assert.ErrorContains(t, err, "empty")
internal/oidcprovider/cli.go
Old New
@@ -0,0 +1,286 @@
1 // cli.go is the eitri-oidc command line: `eitri-oidc [-config path]` serves
2 // the issuer; `eitri-oidc user add|list|rm` manages the flat user file and
3 // works whether or not the daemon is running (the daemon re-reads the file per
4 // auth attempt). It lives here rather than in cmd/eitri-oidc so it is testable
5 // and coverage-gated (arch R14: main packages are wiring only).
6
7 package oidcprovider
8
9 import (
10 "context"
11 "encoding/json"
12 "errors"
13 "flag"
14 "fmt"
15 "io"
16 "log/slog"
17 "net/http"
18 "os"
19 "os/signal"
20 "strings"
21 "syscall"
22 "time"
23
24 "golang.org/x/term"
25 )
26
27 // DefaultConfigPath is where the binary looks for its config when -config is
28 // not given; the quickstart writes this file.
29 const DefaultConfigPath = "/etc/eitri/eitri-oidc.json"
30
31 // CLIConfig is the on-disk JSON schema (spec §2.1). The serve path needs every
32 // field; the `user` subcommands need only users_file.
33 type CLIConfig struct {
34 Listen string `json:"listen"`
35 Issuer string `json:"issuer"`
36 UsersFile string `json:"users_file"`
37 SigningKey string `json:"signing_key"`
38 Clients []Client `json:"clients"`
39 }
40
41 // RunCLI dispatches the eitri-oidc command line (everything after the binary
42 // name, --version excluded — that stays in main). stdout carries command
43 // output (user list); stderr carries confirmations and prompts.
44 func RunCLI(args []string, stdout, stderr io.Writer) error {
45 // Subcommands dispatch before flags: `eitri-oidc user ...` edits the flat
46 // user file and never starts the daemon.
47 if len(args) > 0 && args[0] == "user" {
48 return runUser(args[1:], stdout, stderr)
49 }
50 fs := flag.NewFlagSet("eitri-oidc", flag.ContinueOnError)
51 fs.SetOutput(stderr)
52 cfgPath := fs.String("config", DefaultConfigPath, "config file")
53 if err := fs.Parse(args); err != nil {
54 return err
55 }
56 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
57 defer stop()
58 return Serve(ctx, *cfgPath)
59 }
60
61 // Serve loads and validates the config, then runs the issuer until ctx is
62 // cancelled (graceful shutdown, mirroring eitri-server) or the listener fails.
63 func Serve(ctx context.Context, cfgPath string) error {
64 cfg, err := loadCLIConfig(cfgPath)
65 if err != nil {
66 return err
67 }
68 if err := validateCLIConfig(cfg); err != nil {
69 return fmt.Errorf("%s: %w", cfgPath, err)
70 }
71
72 p, err := New(Config{
73 UsersFile: cfg.UsersFile,
74 SigningKey: cfg.SigningKey,
75 Clients: cfg.Clients,
76 })
77 if err != nil {
78 return fmt.Errorf("init provider: %w", err)
79 }
80 // The issuer must equal the URL browsers and eitri-server reach it at, or
81 // discovery verification fails — set it before the handler serves.
82 p.SetIssuer(cfg.Issuer)
83
84 srv := &http.Server{Addr: cfg.Listen, Handler: p.Handler()}
85 errCh := make(chan error, 1)
86 go func() {
87 slog.Info("oidc listening", "addr", cfg.Listen, "issuer", cfg.Issuer)
88 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
89 errCh <- err
90 }
91 }()
92
93 select {
94 case err := <-errCh:
95 return fmt.Errorf("http serve: %w", err)
96 case <-ctx.Done():
97 }
98 slog.Info("shutting down")
99 shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
100 defer cancel()
101 if err := srv.Shutdown(shutdownCtx); err != nil {
102 slog.Warn("http graceful shutdown", "err", err)
103 }
104 return nil
105 }
106
107 // validateCLIConfig names every missing key in one error rather than failing
108 // one restart at a time.
109 func validateCLIConfig(cfg CLIConfig) error {
110 var missing []string
111 if cfg.Listen == "" {
112 missing = append(missing, "listen")
113 }
114 if cfg.Issuer == "" {
115 missing = append(missing, "issuer")
116 }
117 if cfg.UsersFile == "" {
118 missing = append(missing, "users_file")
119 }
120 if cfg.SigningKey == "" {
121 missing = append(missing, "signing_key")
122 }
123 if len(cfg.Clients) == 0 {
124 missing = append(missing, "clients (at least one)")
125 }
126 for i, c := range cfg.Clients {
127 if c.ID == "" {
128 missing = append(missing, fmt.Sprintf("clients[%d].id", i))
129 }
130 if c.RedirectURL == "" {
131 missing = append(missing, fmt.Sprintf("clients[%d].redirect_url", i))
132 }
133 }
134 if len(missing) > 0 {
135 return fmt.Errorf("config incomplete: missing %s", strings.Join(missing, ", "))
136 }
137 return nil
138 }
139
140 // runUser dispatches the flat-file user subcommands.
141 func runUser(args []string, stdout, stderr io.Writer) error {
142 if len(args) < 1 {
143 return errors.New("usage: eitri-oidc user <add|list|rm> [args]")
144 }
145 switch args[0] {
146 case "add":
147 return userAdd(args[1:], stderr)
148 case "list":
149 return userList(args[1:], stdout, stderr)
150 case "rm":
151 return userRemove(args[1:], stderr)
152 default:
153 return fmt.Errorf("unknown user subcommand %q (want add, list, rm)", args[0])
154 }
155 }
156
157 func userAdd(args []string, stderr io.Writer) error {
158 fs := flag.NewFlagSet("user add", flag.ContinueOnError)
159 fs.SetOutput(stderr)
160 cfgPath := fs.String("config", DefaultConfigPath, "config file")
161 pwFile := fs.String("password-file", "", "read the password from this file instead of prompting")
162 if err := fs.Parse(args); err != nil {
163 return err
164 }
165 rest := fs.Args()
166 if len(rest) != 1 {
167 return errors.New("usage: eitri-oidc user add [--password-file <path>] <email>")
168 }
169 usersFile, err := usersFileFrom(*cfgPath)
170 if err != nil {
171 return err
172 }
173 password, err := readPassword(*pwFile, stderr)
174 if err != nil {
175 return err
176 }
177 if err := AddUser(usersFile, rest[0], password); err != nil {
178 return err
179 }
180 fmt.Fprintf(stderr, "added %s\n", rest[0])
181 return nil
182 }
183
184 func userList(args []string, stdout, stderr io.Writer) error {
185 fs := flag.NewFlagSet("user list", flag.ContinueOnError)
186 fs.SetOutput(stderr)
187 cfgPath := fs.String("config", DefaultConfigPath, "config file")
188 if err := fs.Parse(args); err != nil {
189 return err
190 }
191 usersFile, err := usersFileFrom(*cfgPath)
192 if err != nil {
193 return err
194 }
195 for _, u := range ListUsers(usersFile) {
196 fmt.Fprintln(stdout, u.Email)
197 }
198 return nil
199 }
200
201 func userRemove(args []string, stderr io.Writer) error {
202 fs := flag.NewFlagSet("user rm", flag.ContinueOnError)
203 fs.SetOutput(stderr)
204 cfgPath := fs.String("config", DefaultConfigPath, "config file")
205 if err := fs.Parse(args); err != nil {
206 return err
207 }
208 rest := fs.Args()
209 if len(rest) != 1 {
210 return errors.New("usage: eitri-oidc user rm <email>")
211 }
212 usersFile, err := usersFileFrom(*cfgPath)
213 if err != nil {
214 return err
215 }
216 if err := RemoveUser(usersFile, rest[0]); err != nil {
217 return err
218 }
219 fmt.Fprintf(stderr, "removed %s\n", rest[0])
220 return nil
221 }
222
223 // usersFileFrom resolves the users_file path from the config. A missing config
224 // file (or an unset users_file) is a clear, fatal error for the caller.
225 func usersFileFrom(cfgPath string) (string, error) {
226 cfg, err := loadCLIConfig(cfgPath)
227 if err != nil {
228 return "", err
229 }
230 if cfg.UsersFile == "" {
231 return "", fmt.Errorf("users_file not set in %s", cfgPath)
232 }
233 return cfg.UsersFile, nil
234 }
235
236 // readPassword returns the new password either from pwFile (scripts) or an
237 // interactive double prompt (humans). The file form trims exactly one trailing
238 // newline so an `echo`-written file round-trips while internal whitespace is
239 // preserved; both forms reject an empty password.
240 func readPassword(pwFile string, stderr io.Writer) (string, error) {
241 if pwFile != "" {
242 b, err := os.ReadFile(pwFile)
243 if err != nil {
244 return "", err
245 }
246 pw := strings.TrimRight(string(b), "\r\n")
247 if pw == "" {
248 return "", fmt.Errorf("password file %s is empty", pwFile)
249 }
250 return pw, nil
251 }
252 if !term.IsTerminal(int(os.Stdin.Fd())) {
253 return "", errors.New("no terminal for the password prompt; pass --password-file for scripts")
254 }
255 fmt.Fprint(stderr, "password: ")
256 first, err := term.ReadPassword(int(os.Stdin.Fd()))
257 fmt.Fprintln(stderr)
258 if err != nil {
259 return "", err
260 }
261 fmt.Fprint(stderr, "confirm password: ")
262 second, err := term.ReadPassword(int(os.Stdin.Fd()))
263 fmt.Fprintln(stderr)
264 if err != nil {
265 return "", err
266 }
267 if string(first) != string(second) {
268 return "", errors.New("passwords do not match")
269 }
270 if len(first) == 0 {
271 return "", errors.New("password must not be empty")
272 }
273 return string(first), nil
274 }
275
276 func loadCLIConfig(path string) (CLIConfig, error) {
277 raw, err := os.ReadFile(path)
278 if err != nil {
279 return CLIConfig{}, err
280 }
281 var cfg CLIConfig
282 if err := json.Unmarshal(raw, &cfg); err != nil {
283 return CLIConfig{}, fmt.Errorf("parse %s: %w", path, err)
284 }
285 return cfg, nil
286 }
internal/oidcprovider/cli_test.go
Old New
@@ -0,0 +1,226 @@
1 package oidcprovider
2
3 import (
4 "bytes"
5 "context"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11 )
12
13 // writeCLIConfig writes a complete, valid config to dir and returns its path.
14 func writeCLIConfig(t *testing.T, dir string) string {
15 t.Helper()
16 p := filepath.Join(dir, "eitri-oidc.json")
17 cfg := `{
18 "listen": "127.0.0.1:0",
19 "issuer": "http://127.0.0.1:9111",
20 "users_file": "` + filepath.Join(dir, "users.json") + `",
21 "signing_key": "` + filepath.Join(dir, "signing.key") + `",
22 "clients": [{"id": "eitri-console", "redirect_url": "http://127.0.0.1:8080/auth/callback"}]
23 }`
24 if err := os.WriteFile(p, []byte(cfg), 0o600); err != nil {
25 t.Fatal(err)
26 }
27 return p
28 }
29
30 // writePasswordFile writes content to a temp file and returns its path.
31 func writePasswordFile(t *testing.T, dir, content string) string {
32 t.Helper()
33 p := filepath.Join(dir, "pw")
34 if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
35 t.Fatal(err)
36 }
37 return p
38 }
39
40 func TestValidateCLIConfig(t *testing.T) {
41 valid := CLIConfig{
42 Listen: ":9111", Issuer: "http://x", UsersFile: "u", SigningKey: "k",
43 Clients: []Client{{ID: "c", RedirectURL: "http://cb"}},
44 }
45 if err := validateCLIConfig(valid); err != nil {
46 t.Fatalf("valid config rejected: %v", err)
47 }
48
49 // Every missing key is named in ONE error, not one restart per key.
50 err := validateCLIConfig(CLIConfig{Clients: []Client{{}}})
51 if err == nil {
52 t.Fatal("empty config accepted")
53 }
54 for _, want := range []string{"listen", "issuer", "users_file", "signing_key", "clients[0].id", "clients[0].redirect_url"} {
55 if !strings.Contains(err.Error(), want) {
56 t.Errorf("error %q must name %q", err, want)
57 }
58 }
59
60 // No clients at all is its own named gap.
61 err = validateCLIConfig(CLIConfig{Listen: ":1", Issuer: "i", UsersFile: "u", SigningKey: "k"})
62 if err == nil || !strings.Contains(err.Error(), "clients (at least one)") {
63 t.Errorf("clientless config: got %v", err)
64 }
65 }
66
67 func TestLoadCLIConfigErrors(t *testing.T) {
68 if _, err := loadCLIConfig(filepath.Join(t.TempDir(), "absent.json")); err == nil {
69 t.Error("missing file must error")
70 }
71 p := filepath.Join(t.TempDir(), "bad.json")
72 os.WriteFile(p, []byte("{nope"), 0o600)
73 if _, err := loadCLIConfig(p); err == nil || !strings.Contains(err.Error(), "parse") {
74 t.Errorf("malformed json: got %v", err)
75 }
76 }
77
78 // TestUserLifecycleViaCLI drives add → list → rm through RunCLI exactly as the
79 // deploy script does (config flag + password file), pinning the whole surface.
80 func TestUserLifecycleViaCLI(t *testing.T) {
81 dir := t.TempDir()
82 cfgPath := writeCLIConfig(t, dir)
83 pwPath := writePasswordFile(t, dir, "hunter2hunter2\n")
84 var stdout, stderr bytes.Buffer
85
86 err := RunCLI([]string{"user", "add", "--config", cfgPath, "--password-file", pwPath, "a@x.com"}, &stdout, &stderr)
87 if err != nil {
88 t.Fatalf("user add: %v", err)
89 }
90 if !strings.Contains(stderr.String(), "added a@x.com") {
91 t.Errorf("add confirmation missing: %q", stderr.String())
92 }
93
94 stdout.Reset()
95 if err := RunCLI([]string{"user", "list", "--config", cfgPath}, &stdout, &stderr); err != nil {
96 t.Fatalf("user list: %v", err)
97 }
98 if got := strings.TrimSpace(stdout.String()); got != "a@x.com" {
99 t.Errorf("list = %q, want a@x.com", got)
100 }
101
102 if err := RunCLI([]string{"user", "rm", "--config", cfgPath, "a@x.com"}, &stdout, &stderr); err != nil {
103 t.Fatalf("user rm: %v", err)
104 }
105 stdout.Reset()
106 if err := RunCLI([]string{"user", "list", "--config", cfgPath}, &stdout, &stderr); err != nil {
107 t.Fatal(err)
108 }
109 if got := strings.TrimSpace(stdout.String()); got != "" {
110 t.Errorf("list after rm = %q, want empty", got)
111 }
112 }
113
114 // TestUserAddFlagsMustPrecedeEmail pins Go flag semantics the deploy script
115 // depends on: parsing stops at the first positional, so trailing flags are
116 // swallowed as positionals and the command fails loudly instead of silently
117 // prompting.
118 func TestUserAddFlagsMustPrecedeEmail(t *testing.T) {
119 dir := t.TempDir()
120 cfgPath := writeCLIConfig(t, dir)
121 pwPath := writePasswordFile(t, dir, "pw12345678\n")
122 var out bytes.Buffer
123
124 err := RunCLI([]string{"user", "add", "a@x.com", "--config", cfgPath, "--password-file", pwPath}, &out, &out)
125 if err == nil || !strings.Contains(err.Error(), "usage:") {
126 t.Errorf("flags after the positional must fail with usage, got %v", err)
127 }
128 }
129
130 func TestUserCLIErrors(t *testing.T) {
131 var out bytes.Buffer
132 if err := RunCLI([]string{"user"}, &out, &out); err == nil {
133 t.Error("bare `user` must error with usage")
134 }
135 if err := RunCLI([]string{"user", "frobnicate"}, &out, &out); err == nil || !strings.Contains(err.Error(), "unknown user subcommand") {
136 t.Errorf("unknown subcommand: got %v", err)
137 }
138 // users_file unset in the config is a named error, not a nil-path write.
139 dir := t.TempDir()
140 p := filepath.Join(dir, "cfg.json")
141 os.WriteFile(p, []byte(`{"listen": ":1"}`), 0o600)
142 if err := RunCLI([]string{"user", "list", "--config", p}, &out, &out); err == nil || !strings.Contains(err.Error(), "users_file not set") {
143 t.Errorf("unset users_file: got %v", err)
144 }
145 }
146
147 func TestReadPasswordFile(t *testing.T) {
148 dir := t.TempDir()
149 var stderr bytes.Buffer
150
151 // Exactly one trailing newline (or CRLF) is trimmed; inner space survives.
152 for raw, want := range map[string]string{
153 "secret pass\n": "secret pass",
154 "secret\r\n": "secret",
155 "secret": "secret",
156 "secret\n\r\n\r ": "secret\n\r\n\r ",
157 } {
158 got, err := readPassword(writePasswordFile(t, t.TempDir(), raw), &stderr)
159 if err != nil {
160 t.Errorf("readPassword(%q): %v", raw, err)
161 continue
162 }
163 if got != want {
164 t.Errorf("readPassword(%q) = %q, want %q", raw, got, want)
165 }
166 }
167
168 // An empty (or newline-only) file is rejected, not accepted as "".
169 if _, err := readPassword(writePasswordFile(t, dir, "\n"), &stderr); err == nil {
170 t.Error("newline-only password file must error")
171 }
172 if _, err := readPassword(filepath.Join(dir, "absent"), &stderr); err == nil {
173 t.Error("missing password file must error")
174 }
175 }
176
177 // TestServeStartsAndStops boots the real issuer on an ephemeral port and shuts
178 // it down via context cancel — the full serve path minus signals.
179 func TestServeStartsAndStops(t *testing.T) {
180 cfgPath := writeCLIConfig(t, t.TempDir())
181 ctx, cancel := context.WithCancel(context.Background())
182 done := make(chan error, 1)
183 go func() { done <- Serve(ctx, cfgPath) }()
184 // Give the listener a moment to bind, then cancel; Serve must return nil.
185 time.Sleep(100 * time.Millisecond)
186 cancel()
187 select {
188 case err := <-done:
189 if err != nil {
190 t.Fatalf("Serve returned %v on cancel, want nil", err)
191 }
192 case <-time.After(10 * time.Second):
193 t.Fatal("Serve did not return after cancel")
194 }
195 }
196
197 func TestServeRejectsIncompleteConfig(t *testing.T) {
198 p := filepath.Join(t.TempDir(), "cfg.json")
199 os.WriteFile(p, []byte(`{"listen": ":0"}`), 0o600)
200 if err := Serve(context.Background(), p); err == nil || !strings.Contains(err.Error(), "config incomplete") {
201 t.Errorf("incomplete config: got %v", err)
202 }
203 }
204
205 func TestRunCLIFlagAndInitErrors(t *testing.T) {
206 var out bytes.Buffer
207 // An unknown flag fails parse rather than silently serving.
208 if err := RunCLI([]string{"-frobnicate"}, &out, &out); err == nil {
209 t.Error("unknown flag must error")
210 }
211 // A signing_key path that cannot be created (a directory) fails provider
212 // init with context, not a panic deeper in.
213 dir := t.TempDir()
214 p := filepath.Join(dir, "cfg.json")
215 cfg := `{
216 "listen": "127.0.0.1:0",
217 "issuer": "http://127.0.0.1:9111",
218 "users_file": "` + filepath.Join(dir, "users.json") + `",
219 "signing_key": "` + dir + `",
220 "clients": [{"id": "c", "redirect_url": "http://cb"}]
221 }`
222 os.WriteFile(p, []byte(cfg), 0o600)
223 if err := Serve(context.Background(), p); err == nil || !strings.Contains(err.Error(), "init provider") {
224 t.Errorf("directory signing_key: got %v", err)
225 }
226 }
internal/oidcprovider/jwt.go
Old New
@@ -0,0 +1,40 @@
1 package oidcprovider
2
3 import (
4 "crypto"
5 "crypto/rsa"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/json"
9 "time"
10 )
11
12 // signIDToken builds a compact JWS: base64url(header).base64url(claims).base64url(sig)
13 // with RSASSA-PKCS1-v1_5 over SHA-256. go-oidc on the other end verifies it
14 // against /jwks.json, which keeps this honest — no JWT library. Our tokens
15 // always carry email and email_verified:true, satisfying the spec's claims
16 // contract — the email is the account a password authenticates, verified by
17 // definition.
18 func (p *Provider) signIDToken(sub, email, audience string, now time.Time) (string, error) {
19 header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": p.kid}
20 claims := map[string]any{
21 "iss": p.issuer,
22 "sub": sub,
23 "aud": audience,
24 "iat": now.Unix(),
25 "exp": now.Add(5 * time.Minute).Unix(),
26 "email": email,
27 "email_verified": true,
28 }
29 seg := func(v any) string {
30 b, _ := json.Marshal(v)
31 return base64.RawURLEncoding.EncodeToString(b)
32 }
33 signing := seg(header) + "." + seg(claims)
34 h := sha256.Sum256([]byte(signing))
35 sig, err := rsa.SignPKCS1v15(nil, p.key, crypto.SHA256, h[:])
36 if err != nil {
37 return "", err
38 }
39 return signing + "." + base64.RawURLEncoding.EncodeToString(sig), nil
40 }
internal/oidcprovider/login.html
Old New
@@ -0,0 +1,32 @@
1 <!doctype html>
2 <meta charset="utf-8">
3 <meta name="viewport" content="width=device-width, initial-scale=1">
4 <title>Sign in</title>
5 <style>
6 body { font: 15px/1.5 system-ui, sans-serif; background: #f6f7f9; color: #1a1a1a;
7 display: flex; min-height: 100vh; margin: 0; align-items: center; justify-content: center; }
8 form { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,.1);
9 width: 20rem; box-sizing: border-box; }
10 h1 { font-size: 1.1rem; margin: 0 0 1rem; }
11 label { display: block; margin: .75rem 0 .25rem; font-size: .85rem; color: #555; }
12 input { width: 100%; padding: .5rem; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; }
13 button { margin-top: 1.25rem; width: 100%; padding: .55rem; border: 0; border-radius: 4px;
14 background: #1a1a1a; color: #fff; font-size: .95rem; cursor: pointer; }
15 .error { margin-top: .75rem; color: #b00020; font-size: .85rem; }
16 </style>
17 <!--
18 Plain HTML, no JavaScript: a cookie-jar HTTP client drives the same form as a
19 human. The action posts back to the exact URL that served this page (the
20 authorize endpoint with the original raw query) so a headless CI client only
21 needs to POST email+password to the URL it just fetched — load-bearing for CI
22 (spec §2.1). Field names email/password are a stable contract.
23 -->
24 <form method="post" action="{{.Action}}">
25 <h1>Sign in to eitri</h1>
26 <label for="email">Email</label>
27 <input id="email" name="email" type="email" autocomplete="username" autofocus>
28 <label for="password">Password</label>
29 <input id="password" name="password" type="password" autocomplete="current-password">
30 {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
31 <button type="submit">Sign in</button>
32 </form>
internal/oidcprovider/provider.go
Old New
@@ -0,0 +1,348 @@
1 package oidcprovider
2
3 import (
4 "crypto/rand"
5 "crypto/rsa"
6 "crypto/sha256"
7 "crypto/subtle"
8 "crypto/x509"
9 "encoding/base64"
10 "encoding/json"
11 "encoding/pem"
12 _ "embed"
13 "errors"
14 "html/template"
15 "net/http"
16 "net/url"
17 "os"
18 "sync"
19 "time"
20 )
21
22 //go:embed login.html
23 var loginHTML string
24
25 var loginTmpl = template.Must(template.New("login").Parse(loginHTML))
26
27 var errBadKey = errors.New("oidcprovider: malformed signing key PEM")
28
29 // Config is the provider's static wiring: where users and the signing key live,
30 // and the set of statically-registered clients (no dynamic registration).
31 type Config struct {
32 UsersFile string
33 SigningKey string // path; RSA-2048 PEM, generated if absent (0600)
34 Clients []Client // static registration
35 }
36
37 // Client is one registered relying party: its id and exact redirect URL. The
38 // JSON tags are the eitri-oidc config wire contract (spec §2.1: {"id",
39 // "redirect_url"}); without them the snake_case redirect_url would silently
40 // decode to empty and every login would 400 on the exact-match client check.
41 type Client struct {
42 ID string `json:"id"`
43 RedirectURL string `json:"redirect_url"`
44 }
45
46 // authCode is a minted, single-use authorization code held in memory.
47 type authCode struct {
48 sub string
49 email string
50 clientID string
51 redirectURL string
52 challenge string // PKCE S256 code_challenge
53 expires time.Time
54 }
55
56 // Provider is a minimal OIDC issuer. Zero refresh tokens, zero userinfo.
57 type Provider struct {
58 cfg Config
59 key *rsa.PrivateKey
60 kid string // stable hash of the public key
61 issuer string
62
63 now func() time.Time // injectable clock (tests)
64
65 mu sync.Mutex
66 codes map[string]authCode
67 }
68
69 // New loads or generates the RSA-2048 signing key (PEM at cfg.SigningKey, 0600)
70 // and returns a provider. SetIssuer must be called before Handler serves — the
71 // issuer must equal the URL clients reach it at for discovery to verify.
72 func New(cfg Config) (*Provider, error) {
73 key, err := loadOrGenerateKey(cfg.SigningKey)
74 if err != nil {
75 return nil, err
76 }
77 return &Provider{
78 cfg: cfg,
79 key: key,
80 kid: keyID(&key.PublicKey),
81 now: time.Now,
82 codes: make(map[string]authCode),
83 }, nil
84 }
85
86 // SetIssuer sets the canonical issuer URL advertised in discovery and stamped
87 // into id_tokens. It must equal the URL browsers and eitri-server reach.
88 // Not synchronized: call once, before the handler starts serving.
89 func (p *Provider) SetIssuer(u string) { p.issuer = u }
90
91 // Handler returns the mux with the four endpoints at its root; the binary
92 // decides where to mount it.
93 func (p *Provider) Handler() http.Handler {
94 mux := http.NewServeMux()
95 mux.HandleFunc("/.well-known/openid-configuration", p.handleDiscovery)
96 mux.HandleFunc("/authorize", p.handleAuthorize)
97 mux.HandleFunc("/token", p.handleToken)
98 mux.HandleFunc("/jwks.json", p.handleJWKS)
99 return mux
100 }
101
102 func (p *Provider) handleDiscovery(w http.ResponseWriter, r *http.Request) {
103 writeJSON(w, map[string]any{
104 "issuer": p.issuer,
105 "authorization_endpoint": p.issuer + "/authorize",
106 "token_endpoint": p.issuer + "/token",
107 "jwks_uri": p.issuer + "/jwks.json",
108 "response_types_supported": []string{"code"},
109 "grant_types_supported": []string{"authorization_code"},
110 "code_challenge_methods_supported": []string{"S256"},
111 "id_token_signing_alg_values_supported": []string{"RS256"},
112 "scopes_supported": []string{"openid", "email"},
113 "subject_types_supported": []string{"public"}, // go-oidc requires this field
114 })
115 }
116
117 // handleAuthorize serves the login form (GET) and processes credentials (POST).
118 func (p *Provider) handleAuthorize(w http.ResponseWriter, r *http.Request) {
119 q := r.URL.Query()
120 clientID := q.Get("client_id")
121 redirectURI := q.Get("redirect_uri")
122
123 // Validate client_id + redirect_uri against static config BEFORE rendering
124 // anything — an attacker must not be able to bounce a code to an arbitrary
125 // URL, so a mismatch is a 400, never a redirect.
126 client, ok := p.clientFor(clientID, redirectURI)
127 if !ok {
128 http.Error(w, "unknown client_id or redirect_uri", http.StatusBadRequest)
129 return
130 }
131 if q.Get("response_type") != "code" {
132 http.Error(w, "response_type must be code", http.StatusBadRequest)
133 return
134 }
135 challenge := q.Get("code_challenge")
136 if q.Get("code_challenge_method") != "S256" || challenge == "" {
137 http.Error(w, "code_challenge_method must be S256 with a challenge", http.StatusBadRequest)
138 return
139 }
140
141 if r.Method == http.MethodGet {
142 p.renderLogin(w, r.URL.RawQuery, "")
143 return
144 }
145
146 // POST: authenticate the submitted credentials. A login form never needs
147 // more than a few KB; cap the body well below ParseForm's default.
148 r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
149 user, ok := Authenticate(p.cfg.UsersFile, r.FormValue("email"), r.FormValue("password"))
150 if !ok {
151 // Fixed delay on failure — a crude brute-force brake; real rate limiting
152 // is out of scope (spec §2.1). Re-render the form with an error (200).
153 time.Sleep(1 * time.Second)
154 p.renderLogin(w, r.URL.RawQuery, "Invalid email or password.")
155 return
156 }
157
158 code := random16() + random16()
159 p.mu.Lock()
160 p.codes[code] = authCode{
161 sub: user.Sub,
162 email: user.Email,
163 clientID: client.ID,
164 redirectURL: client.RedirectURL,
165 challenge: challenge,
166 expires: p.now().Add(2 * time.Minute),
167 }
168 p.mu.Unlock()
169
170 redirect, _ := url.Parse(client.RedirectURL)
171 rq := redirect.Query()
172 rq.Set("code", code)
173 rq.Set("state", q.Get("state")) // passed through verbatim
174 redirect.RawQuery = rq.Encode()
175 http.Redirect(w, r, redirect.String(), http.StatusFound)
176 }
177
178 // renderLogin renders the embedded form. Action posts back to /authorize with
179 // the original raw query so a headless client POSTs to the URL it just fetched
180 // (spec §2.1); rawQuery is trusted here (it came off the wire and we only echo
181 // it into a same-origin action).
182 func (p *Provider) renderLogin(w http.ResponseWriter, rawQuery, errMsg string) {
183 action := "/authorize"
184 if rawQuery != "" {
185 action += "?" + rawQuery
186 }
187 w.Header().Set("Content-Type", "text/html; charset=utf-8")
188 // html/template escapes both the action attribute and the error text.
189 _ = loginTmpl.Execute(w, map[string]any{
190 "Action": template.URL(action),
191 "Error": errMsg,
192 })
193 }
194
195 // handleToken exchanges a single-use code for an id_token (authorization-code
196 // grant only, PKCE verified).
197 func (p *Provider) handleToken(w http.ResponseWriter, r *http.Request) {
198 if r.Method != http.MethodPost {
199 http.Error(w, "POST required", http.StatusMethodNotAllowed)
200 return
201 }
202 if r.FormValue("grant_type") != "authorization_code" {
203 http.Error(w, "unsupported grant_type", http.StatusBadRequest)
204 return
205 }
206 code := r.FormValue("code")
207 clientID := r.FormValue("client_id")
208 if clientID == "" {
209 // RFC 6749 §2.3.1: clients may authenticate with HTTP Basic instead of
210 // form params (x/oauth2's AuthStyleAutoDetect probes Basic first).
211 clientID = basicClientID(r)
212 }
213
214 // Validate the caller BEFORE consuming the code: a wrong-client request
215 // must not burn it, or a legitimate RP's Basic-vs-params autodetect retry
216 // would find its own code already gone. Once the client checks out, the
217 // code is consumed — every later outcome (expiry, PKCE failure, success)
218 // is a real exchange attempt and single-use must hold.
219 p.mu.Lock()
220 ac, ok := p.codes[code]
221 consumed := ok && clientID == ac.clientID && r.FormValue("redirect_uri") == ac.redirectURL
222 if consumed {
223 delete(p.codes, code)
224 }
225 p.mu.Unlock()
226 if !ok {
227 http.Error(w, "invalid code", http.StatusBadRequest)
228 return
229 }
230 if !consumed {
231 http.Error(w, "client_id or redirect_uri mismatch", http.StatusBadRequest)
232 return
233 }
234 if p.now().After(ac.expires) {
235 http.Error(w, "expired code", http.StatusBadRequest)
236 return
237 }
238 // Verify PKCE: S256(code_verifier) must equal the stored challenge.
239 sum := sha256.Sum256([]byte(r.FormValue("code_verifier")))
240 got := base64.RawURLEncoding.EncodeToString(sum[:])
241 if subtle.ConstantTimeCompare([]byte(got), []byte(ac.challenge)) != 1 {
242 http.Error(w, "PKCE verification failed", http.StatusBadRequest)
243 return
244 }
245
246 idToken, err := p.signIDToken(ac.sub, ac.email, ac.clientID, p.now())
247 if err != nil {
248 http.Error(w, "signing failed", http.StatusInternalServerError)
249 return
250 }
251 writeJSON(w, map[string]any{
252 "access_token": random16() + random16(),
253 "token_type": "Bearer",
254 "id_token": idToken,
255 "expires_in": 300,
256 })
257 }
258
259 // handleJWKS publishes the public key as a one-key JWK set (RFC 7517).
260 func (p *Provider) handleJWKS(w http.ResponseWriter, r *http.Request) {
261 pub := p.key.PublicKey
262 n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
263 e := base64.RawURLEncoding.EncodeToString(bigEndianExp(pub.E))
264 writeJSON(w, map[string]any{
265 "keys": []map[string]any{{
266 "kty": "RSA",
267 "alg": "RS256",
268 "use": "sig",
269 "kid": p.kid,
270 "n": n,
271 "e": e,
272 }},
273 })
274 }
275
276 // basicClientID extracts the client id from HTTP Basic credentials, in which
277 // RFC 6749 §2.3.1 says both halves are form-urlencoded. Public clients send an
278 // empty secret; only the username matters here.
279 func basicClientID(r *http.Request) string {
280 user, _, ok := r.BasicAuth()
281 if !ok {
282 return ""
283 }
284 id, err := url.QueryUnescape(user)
285 if err != nil {
286 return ""
287 }
288 return id
289 }
290
291 func (p *Provider) clientFor(id, redirectURI string) (Client, bool) {
292 for _, c := range p.cfg.Clients {
293 if c.ID == id && c.RedirectURL == redirectURI {
294 return c, true
295 }
296 }
297 return Client{}, false
298 }
299
300 func writeJSON(w http.ResponseWriter, v any) {
301 w.Header().Set("Content-Type", "application/json")
302 _ = json.NewEncoder(w).Encode(v)
303 }
304
305 // bigEndianExp encodes an RSA public exponent as minimal big-endian bytes.
306 func bigEndianExp(e int) []byte {
307 b := []byte{byte(e >> 16), byte(e >> 8), byte(e)}
308 for len(b) > 1 && b[0] == 0 {
309 b = b[1:]
310 }
311 return b
312 }
313
314 // keyID is a stable identifier for the public key: base64url of a SHA-256 over
315 // its PKIX DER. Same key in, same kid out, across restarts.
316 func keyID(pub *rsa.PublicKey) string {
317 der, _ := x509.MarshalPKIXPublicKey(pub)
318 sum := sha256.Sum256(der)
319 return base64.RawURLEncoding.EncodeToString(sum[:])
320 }
321
322 // loadOrGenerateKey reads a PKCS#1 PEM key from path, or generates and persists
323 // an RSA-2048 one (0600) if the file is absent.
324 func loadOrGenerateKey(path string) (*rsa.PrivateKey, error) {
325 b, err := os.ReadFile(path)
326 if err == nil {
327 block, _ := pem.Decode(b)
328 if block == nil {
329 return nil, errBadKey
330 }
331 return x509.ParsePKCS1PrivateKey(block.Bytes)
332 }
333 if !os.IsNotExist(err) {
334 return nil, err
335 }
336 key, err := rsa.GenerateKey(rand.Reader, 2048)
337 if err != nil {
338 return nil, err
339 }
340 pemBytes := pem.EncodeToMemory(&pem.Block{
341 Type: "RSA PRIVATE KEY",
342 Bytes: x509.MarshalPKCS1PrivateKey(key),
343 })
344 if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
345 return nil, err
346 }
347 return key, nil
348 }
internal/oidcprovider/provider_test.go
Old New
@@ -0,0 +1,344 @@
1 package oidcprovider
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 oidc "github.com/coreos/go-oidc/v3/oidc"
17 )
18
19 // newTestProvider stands up a provider with one ci user and returns it plus its
20 // live httptest server (issuer already set to the reachable URL).
21 func newTestProvider(t *testing.T) (*Provider, *httptest.Server) {
22 t.Helper()
23 dir := t.TempDir()
24 usersPath := filepath.Join(dir, "users.json")
25 if err := AddUser(usersPath, "ci@example.com", "hunter2hunter2"); err != nil {
26 t.Fatal(err)
27 }
28 p, err := New(Config{
29 UsersFile: usersPath,
30 SigningKey: filepath.Join(dir, "signing.key"),
31 Clients: []Client{{ID: "eitri-console", RedirectURL: "http://client.example/auth/callback"}},
32 })
33 if err != nil {
34 t.Fatal(err)
35 }
36 srv := httptest.NewServer(p.Handler())
37 t.Cleanup(srv.Close)
38 p.SetIssuer(srv.URL)
39 return p, srv
40 }
41
42 func pkcePair() (verifier, challenge string) {
43 verifier = strings.Repeat("v", 43) // any 43-128 char unreserved string
44 sum := sha256.Sum256([]byte(verifier))
45 return verifier, base64.RawURLEncoding.EncodeToString(sum[:])
46 }
47
48 func authorizeURL(base, challenge string) string {
49 return base + "/authorize?" + url.Values{
50 "response_type": {"code"},
51 "client_id": {"eitri-console"},
52 "redirect_uri": {"http://client.example/auth/callback"},
53 "state": {"st4te"},
54 "scope": {"openid email"},
55 "code_challenge": {challenge},
56 "code_challenge_method": {"S256"},
57 }.Encode()
58 }
59
60 func noRedirectClient() *http.Client {
61 return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
62 return http.ErrUseLastResponse
63 }}
64 }
65
66 // getCode drives GET+POST /authorize with good credentials and returns the code.
67 func getCode(t *testing.T, srv *httptest.Server, challenge string) string {
68 t.Helper()
69 c := noRedirectClient()
70 authURL := authorizeURL(srv.URL, challenge)
71 r, err := c.Get(authURL)
72 if err != nil || r.StatusCode != 200 {
73 t.Fatalf("authorize GET: %v status=%d", err, r.StatusCode)
74 }
75 r, err = c.PostForm(authURL, url.Values{
76 "email": {"ci@example.com"}, "password": {"hunter2hunter2"},
77 })
78 if err != nil || r.StatusCode != http.StatusFound {
79 t.Fatalf("authorize POST: %v status=%d", err, r.StatusCode)
80 }
81 loc, _ := url.Parse(r.Header.Get("Location"))
82 if got := loc.Query().Get("state"); got != "st4te" {
83 t.Fatalf("state = %q", got)
84 }
85 return loc.Query().Get("code")
86 }
87
88 // TestCodeFlowAgainstGoOIDC drives the full authorization-code+PKCE flow
89 // through the real go-oidc verifier — the exact client eitri-server uses.
90 func TestCodeFlowAgainstGoOIDC(t *testing.T) {
91 _, srv := newTestProvider(t)
92
93 ctx := context.Background()
94 prov, err := oidc.NewProvider(ctx, srv.URL)
95 if err != nil {
96 t.Fatalf("go-oidc discovery: %v", err)
97 }
98 verifier := prov.Verifier(&oidc.Config{ClientID: "eitri-console"})
99
100 pkceVerifier, challenge := pkcePair()
101 code := getCode(t, srv, challenge)
102
103 tr, err := http.PostForm(srv.URL+"/token", url.Values{
104 "grant_type": {"authorization_code"},
105 "code": {code},
106 "code_verifier": {pkceVerifier},
107 "client_id": {"eitri-console"},
108 "redirect_uri": {"http://client.example/auth/callback"},
109 })
110 if err != nil || tr.StatusCode != 200 {
111 t.Fatalf("token: %v status=%d", err, tr.StatusCode)
112 }
113 var tokResp struct {
114 IDToken string `json:"id_token"`
115 }
116 if err := json.NewDecoder(tr.Body).Decode(&tokResp); err != nil {
117 t.Fatal(err)
118 }
119
120 idToken, err := verifier.Verify(ctx, tokResp.IDToken)
121 if err != nil {
122 t.Fatalf("verify: %v", err)
123 }
124 var claims struct {
125 Email string `json:"email"`
126 EmailVerified bool `json:"email_verified"`
127 }
128 if err := idToken.Claims(&claims); err != nil || claims.Email != "ci@example.com" {
129 t.Fatalf("claims: %v %+v", err, claims)
130 }
131 if !claims.EmailVerified {
132 t.Fatalf("email_verified = false, want true")
133 }
134 }
135
136 func TestWrongPasswordReRendersFormAfterDelay(t *testing.T) {
137 _, srv := newTestProvider(t)
138 _, challenge := pkcePair()
139 c := noRedirectClient()
140 authURL := authorizeURL(srv.URL, challenge)
141
142 start := time.Now()
143 r, err := c.PostForm(authURL, url.Values{
144 "email": {"ci@example.com"}, "password": {"wrong"},
145 })
146 if err != nil {
147 t.Fatal(err)
148 }
149 if r.StatusCode != http.StatusOK {
150 t.Fatalf("wrong password status = %d, want 200", r.StatusCode)
151 }
152 if loc := r.Header.Get("Location"); loc != "" {
153 t.Fatalf("wrong password issued redirect: %q", loc)
154 }
155 if elapsed := time.Since(start); elapsed < time.Second {
156 t.Fatalf("no brute-force brake: elapsed %v < 1s", elapsed)
157 }
158 }
159
160 func TestPKCEMismatchRejected(t *testing.T) {
161 _, srv := newTestProvider(t)
162 _, challenge := pkcePair()
163 code := getCode(t, srv, challenge)
164
165 tr, _ := http.PostForm(srv.URL+"/token", url.Values{
166 "grant_type": {"authorization_code"},
167 "code": {code},
168 "code_verifier": {strings.Repeat("x", 43)}, // wrong verifier
169 "client_id": {"eitri-console"},
170 "redirect_uri": {"http://client.example/auth/callback"},
171 })
172 if tr.StatusCode != http.StatusBadRequest {
173 t.Fatalf("pkce mismatch token status = %d, want 400", tr.StatusCode)
174 }
175 }
176
177 func TestUnknownClientRejectedBeforeForm(t *testing.T) {
178 _, srv := newTestProvider(t)
179 _, challenge := pkcePair()
180 u := srv.URL + "/authorize?" + url.Values{
181 "response_type": {"code"},
182 "client_id": {"nope"},
183 "redirect_uri": {"http://client.example/auth/callback"},
184 "code_challenge": {challenge},
185 "code_challenge_method": {"S256"},
186 }.Encode()
187 r, _ := noRedirectClient().Get(u)
188 if r.StatusCode != http.StatusBadRequest {
189 t.Fatalf("unknown client status = %d, want 400", r.StatusCode)
190 }
191 }
192
193 func TestMismatchedRedirectURIRejected(t *testing.T) {
194 _, srv := newTestProvider(t)
195 _, challenge := pkcePair()
196 u := srv.URL + "/authorize?" + url.Values{
197 "response_type": {"code"},
198 "client_id": {"eitri-console"},
199 "redirect_uri": {"http://evil.example/steal"},
200 "code_challenge": {challenge},
201 "code_challenge_method": {"S256"},
202 }.Encode()
203 r, _ := noRedirectClient().Get(u)
204 if r.StatusCode != http.StatusBadRequest {
205 t.Fatalf("mismatched redirect status = %d, want 400", r.StatusCode)
206 }
207 }
208
209 func TestCodeSingleUse(t *testing.T) {
210 _, srv := newTestProvider(t)
211 verifier, challenge := pkcePair()
212 code := getCode(t, srv, challenge)
213
214 form := url.Values{
215 "grant_type": {"authorization_code"},
216 "code": {code},
217 "code_verifier": {verifier},
218 "client_id": {"eitri-console"},
219 "redirect_uri": {"http://client.example/auth/callback"},
220 }
221 if r, _ := http.PostForm(srv.URL+"/token", form); r.StatusCode != 200 {
222 t.Fatalf("first exchange status = %d", r.StatusCode)
223 }
224 if r, _ := http.PostForm(srv.URL+"/token", form); r.StatusCode != http.StatusBadRequest {
225 t.Fatalf("code reuse status = %d, want 400", r.StatusCode)
226 }
227 }
228
229 func TestTokenBasicAuthClientID(t *testing.T) {
230 // x/oauth2's AuthStyleAutoDetect sends client credentials as HTTP Basic
231 // first; the exchange must succeed on that very request.
232 _, srv := newTestProvider(t)
233 verifier, challenge := pkcePair()
234 code := getCode(t, srv, challenge)
235
236 form := url.Values{
237 "grant_type": {"authorization_code"},
238 "code": {code},
239 "code_verifier": {verifier},
240 "redirect_uri": {"http://client.example/auth/callback"},
241 }
242 req, err := http.NewRequest(http.MethodPost, srv.URL+"/token", strings.NewReader(form.Encode()))
243 if err != nil {
244 t.Fatal(err)
245 }
246 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
247 req.SetBasicAuth("eitri-console", "")
248 r, err := http.DefaultClient.Do(req)
249 if err != nil {
250 t.Fatal(err)
251 }
252 if r.StatusCode != 200 {
253 t.Fatalf("basic-auth exchange status = %d, want 200", r.StatusCode)
254 }
255 }
256
257 func TestWrongClientProbeDoesNotBurnCode(t *testing.T) {
258 _, srv := newTestProvider(t)
259 verifier, challenge := pkcePair()
260 code := getCode(t, srv, challenge)
261
262 form := url.Values{
263 "grant_type": {"authorization_code"},
264 "code": {code},
265 "code_verifier": {verifier},
266 "client_id": {"not-the-console"},
267 "redirect_uri": {"http://client.example/auth/callback"},
268 }
269 if r, _ := http.PostForm(srv.URL+"/token", form); r.StatusCode != http.StatusBadRequest {
270 t.Fatalf("wrong-client exchange status = %d, want 400", r.StatusCode)
271 }
272 // The mismatched attempt must not have consumed the code.
273 form.Set("client_id", "eitri-console")
274 if r, _ := http.PostForm(srv.URL+"/token", form); r.StatusCode != 200 {
275 t.Fatalf("retry after wrong-client probe status = %d, want 200", r.StatusCode)
276 }
277 }
278
279 func TestPKCEFailureBurnsCode(t *testing.T) {
280 // A failed verifier is a real exchange attempt: single-use must hold, or
281 // an attacker could brute-force verifiers against one stolen code.
282 _, srv := newTestProvider(t)
283 _, challenge := pkcePair()
284 code := getCode(t, srv, challenge)
285
286 form := url.Values{
287 "grant_type": {"authorization_code"},
288 "code": {code},
289 "code_verifier": {strings.Repeat("x", 43)},
290 "client_id": {"eitri-console"},
291 "redirect_uri": {"http://client.example/auth/callback"},
292 }
293 if r, _ := http.PostForm(srv.URL+"/token", form); r.StatusCode != http.StatusBadRequest {
294 t.Fatalf("bad-verifier exchange status = %d, want 400", r.StatusCode)
295 }
296 if r, _ := http.PostForm(srv.URL+"/token", form); r.StatusCode != http.StatusBadRequest {
297 t.Fatalf("reuse after PKCE failure status = %d, want 400 invalid code", r.StatusCode)
298 }
299 }
300
301 func TestExpiredCodeRejected(t *testing.T) {
302 p, srv := newTestProvider(t)
303 // Freeze the clock 3 minutes in the past when minting so the code (2m TTL)
304 // is already stale by exchange time.
305 p.now = func() time.Time { return time.Now().Add(-3 * time.Minute) }
306 verifier, challenge := pkcePair()
307 code := getCode(t, srv, challenge)
308 p.now = time.Now
309
310 r, _ := http.PostForm(srv.URL+"/token", url.Values{
311 "grant_type": {"authorization_code"},
312 "code": {code},
313 "code_verifier": {verifier},
314 "client_id": {"eitri-console"},
315 "redirect_uri": {"http://client.example/auth/callback"},
316 })
317 if r.StatusCode != http.StatusBadRequest {
318 t.Fatalf("expired code status = %d, want 400", r.StatusCode)
319 }
320 }
321
322 func TestDiscoveryDocument(t *testing.T) {
323 _, srv := newTestProvider(t)
324 r, err := http.Get(srv.URL + "/.well-known/openid-configuration")
325 if err != nil || r.StatusCode != 200 {
326 t.Fatalf("discovery: %v status=%d", err, r.StatusCode)
327 }
328 var doc map[string]any
329 if err := json.NewDecoder(r.Body).Decode(&doc); err != nil {
330 t.Fatal(err)
331 }
332 if doc["issuer"] != srv.URL {
333 t.Fatalf("issuer = %v", doc["issuer"])
334 }
335 if doc["authorization_endpoint"] != srv.URL+"/authorize" {
336 t.Fatalf("authorization_endpoint = %v", doc["authorization_endpoint"])
337 }
338 if doc["token_endpoint"] != srv.URL+"/token" {
339 t.Fatalf("token_endpoint = %v", doc["token_endpoint"])
340 }
341 if doc["jwks_uri"] != srv.URL+"/jwks.json" {
342 t.Fatalf("jwks_uri = %v", doc["jwks_uri"])
343 }
344 }
internal/oidcprovider/users.go
Old New
@@ -0,0 +1,208 @@
1 // Package oidcprovider is a minimal, spec-compliant OIDC issuer: discovery,
2 // authorization-code + PKCE, token, and JWKS, with users in a flat file. It is
3 // the library behind the eitri-oidc binary and is driven through go-oidc in
4 // tests so it can't drift from the standard client eitri-server uses. A
5 // standalone leaf: it imports nothing from the server or agent.
6 package oidcprovider
7
8 import (
9 "crypto/rand"
10 "crypto/subtle"
11 "encoding/base64"
12 "encoding/hex"
13 "encoding/json"
14 "fmt"
15 "os"
16 "path/filepath"
17 "strings"
18
19 "golang.org/x/crypto/argon2"
20 )
21
22 // argon2id parameters: 64 MiB, 1 pass, 4 lanes, 16-byte salt, 32-byte key.
23 const (
24 argonMemory = 64 * 1024
25 argonTime = 1
26 argonThreads = 4
27 argonSaltLen = 16
28 argonKeyLen = 32
29 )
30
31 // User is one flat-file identity. Hash is omitted from the auth-facing form.
32 type User struct {
33 Email string `json:"email"`
34 Sub string `json:"sub"`
35 Hash string `json:"hash"`
36 }
37
38 // Users is the on-disk file shape: {"users": [...]}.
39 type Users struct {
40 Users []User `json:"users"`
41 }
42
43 // LoadUsers reads the users file. A missing file is an empty set, not an error,
44 // so `user add` works before the daemon has ever run.
45 func LoadUsers(path string) (Users, error) {
46 b, err := os.ReadFile(path)
47 if os.IsNotExist(err) {
48 return Users{}, nil
49 }
50 if err != nil {
51 return Users{}, err
52 }
53 var us Users
54 if err := json.Unmarshal(b, &us); err != nil {
55 return Users{}, err
56 }
57 return us, nil
58 }
59
60 // AddUser appends or replaces email's entry with a fresh argon2id hash of
61 // password, atomically. Re-adding an existing email replaces its hash but keeps
62 // its sub — a password change must not rebind the tenant. A new user gets a
63 // random 16-byte-hex sub.
64 func AddUser(path, email, password string) error {
65 us, err := LoadUsers(path)
66 if err != nil {
67 return err
68 }
69 hash, err := hashPassword(password)
70 if err != nil {
71 return err
72 }
73 for i := range us.Users {
74 if us.Users[i].Email == email {
75 us.Users[i].Hash = hash
76 return writeUsers(path, us)
77 }
78 }
79 us.Users = append(us.Users, User{Email: email, Sub: random16(), Hash: hash})
80 return writeUsers(path, us)
81 }
82
83 // RemoveUser drops email's entry. Absent email is a no-op.
84 func RemoveUser(path, email string) error {
85 us, err := LoadUsers(path)
86 if err != nil {
87 return err
88 }
89 out := us.Users[:0]
90 for _, u := range us.Users {
91 if u.Email != email {
92 out = append(out, u)
93 }
94 }
95 us.Users = out
96 return writeUsers(path, us)
97 }
98
99 // ListUsers returns the file's entries (empty if the file is missing).
100 func ListUsers(path string) []User {
101 us, _ := LoadUsers(path)
102 return us.Users
103 }
104
105 // Authenticate re-reads the file per call — the daemon holds no cached copy, so
106 // `user add`/`rm` take effect whether or not it is running — and reports whether
107 // password matches email. The returned User carries no hash.
108 func Authenticate(path, email, password string) (User, bool) {
109 us, err := LoadUsers(path)
110 if err != nil {
111 return User{}, false
112 }
113 for _, u := range us.Users {
114 if u.Email == email && verifyPassword(password, u.Hash) {
115 return User{Email: u.Email, Sub: u.Sub}, true
116 }
117 }
118 // Unknown email burns the same argon2 work as a wrong password, so the
119 // miss path is not a user-enumeration timing oracle.
120 verifyPassword(password, dummyHash)
121 return User{}, false
122 }
123
124 // dummyHash is a throwaway argon2id hash (of an unguessable random string)
125 // used to equalize Authenticate's timing on the unknown-email path.
126 var dummyHash = func() string {
127 h, err := hashPassword(random16())
128 if err != nil {
129 // hashPassword only fails if crypto/rand does; unreachable in practice.
130 panic(err)
131 }
132 return h
133 }()
134
135 func random16() string {
136 b := make([]byte, 16)
137 rand.Read(b) //nolint:errcheck // crypto/rand.Read never returns an error
138 return hex.EncodeToString(b)
139 }
140
141 // hashPassword encodes as $argon2id$v=19$m=,t=,p=$<b64 salt>$<b64 key>.
142 func hashPassword(password string) (string, error) {
143 salt := make([]byte, argonSaltLen)
144 if _, err := rand.Read(salt); err != nil {
145 return "", err
146 }
147 key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
148 return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
149 argon2.Version, argonMemory, argonTime, argonThreads,
150 base64.RawStdEncoding.EncodeToString(salt),
151 base64.RawStdEncoding.EncodeToString(key)), nil
152 }
153
154 // verifyPassword recomputes the hash with the encoded parameters and compares
155 // in constant time.
156 func verifyPassword(password, encoded string) bool {
157 parts := strings.Split(encoded, "$")
158 // ["", "argon2id", "v=19", "m=..,t=..,p=..", "<salt>", "<key>"]
159 if len(parts) != 6 || parts[1] != "argon2id" {
160 return false
161 }
162 var version int
163 if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
164 return false
165 }
166 var memory, time, threads uint32
167 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
168 return false
169 }
170 salt, err := base64.RawStdEncoding.DecodeString(parts[4])
171 if err != nil {
172 return false
173 }
174 want, err := base64.RawStdEncoding.DecodeString(parts[5])
175 if err != nil {
176 return false
177 }
178 got := argon2.IDKey([]byte(password), salt, time, memory, uint8(threads), uint32(len(want)))
179 return subtle.ConstantTimeCompare(got, want) == 1
180 }
181
182 // writeUsers serializes atomically: write a temp file in the same dir, then
183 // rename over the target.
184 func writeUsers(path string, us Users) error {
185 b, err := json.MarshalIndent(us, "", " ")
186 if err != nil {
187 return err
188 }
189 dir := filepath.Dir(path)
190 tmp, err := os.CreateTemp(dir, ".users-*.tmp")
191 if err != nil {
192 return err
193 }
194 tmpName := tmp.Name()
195 defer os.Remove(tmpName)
196 if _, err := tmp.Write(b); err != nil {
197 tmp.Close()
198 return err
199 }
200 if err := tmp.Chmod(0o600); err != nil {
201 tmp.Close()
202 return err
203 }
204 if err := tmp.Close(); err != nil {
205 return err
206 }
207 return os.Rename(tmpName, path)
208 }
internal/oidcprovider/users_test.go
Old New
@@ -0,0 +1,98 @@
1 package oidcprovider
2
3 import (
4 "path/filepath"
5 "testing"
6 )
7
8 func TestLoadUsersMissingFile(t *testing.T) {
9 // a missing file is an empty set, not an error: user add before first serve.
10 us, err := LoadUsers(filepath.Join(t.TempDir(), "nope.json"))
11 if err != nil {
12 t.Fatalf("LoadUsers missing: %v", err)
13 }
14 if len(us.Users) != 0 {
15 t.Fatalf("want empty, got %d", len(us.Users))
16 }
17 }
18
19 func TestAddUserAndAuthenticate(t *testing.T) {
20 path := filepath.Join(t.TempDir(), "users.json")
21 if err := AddUser(path, "alex@emery.xyz", "hunter2hunter2"); err != nil {
22 t.Fatal(err)
23 }
24 u, ok := Authenticate(path, "alex@emery.xyz", "hunter2hunter2")
25 if !ok {
26 t.Fatal("authenticate good password: ok=false")
27 }
28 if u.Email != "alex@emery.xyz" || u.Sub == "" {
29 t.Fatalf("bad user %+v", u)
30 }
31 if _, ok := Authenticate(path, "alex@emery.xyz", "wrong"); ok {
32 t.Fatal("authenticate wrong password: ok=true")
33 }
34 if _, ok := Authenticate(path, "nobody@emery.xyz", "hunter2hunter2"); ok {
35 t.Fatal("authenticate unknown email: ok=true")
36 }
37 }
38
39 func TestAddUserReplaceKeepsSub(t *testing.T) {
40 // a password change must not rebind the tenant: sub is stable across re-add.
41 path := filepath.Join(t.TempDir(), "users.json")
42 if err := AddUser(path, "a@b.c", "firstpassword"); err != nil {
43 t.Fatal(err)
44 }
45 before, ok := Authenticate(path, "a@b.c", "firstpassword")
46 if !ok {
47 t.Fatal("first auth failed")
48 }
49 if err := AddUser(path, "a@b.c", "secondpassword"); err != nil {
50 t.Fatal(err)
51 }
52 if _, ok := Authenticate(path, "a@b.c", "firstpassword"); ok {
53 t.Fatal("old password still authenticates after re-add")
54 }
55 after, ok := Authenticate(path, "a@b.c", "secondpassword")
56 if !ok {
57 t.Fatal("new password does not authenticate")
58 }
59 if before.Sub != after.Sub {
60 t.Fatalf("sub rebound: %q -> %q", before.Sub, after.Sub)
61 }
62 us, _ := LoadUsers(path)
63 if len(us.Users) != 1 {
64 t.Fatalf("re-add duplicated the row: %d", len(us.Users))
65 }
66 }
67
68 func TestRemoveAndList(t *testing.T) {
69 path := filepath.Join(t.TempDir(), "users.json")
70 for _, e := range []string{"one@x.y", "two@x.y"} {
71 if err := AddUser(path, e, "passwordword"); err != nil {
72 t.Fatal(err)
73 }
74 }
75 if got := ListUsers(path); len(got) != 2 {
76 t.Fatalf("list = %d", len(got))
77 }
78 if err := RemoveUser(path, "one@x.y"); err != nil {
79 t.Fatal(err)
80 }
81 got := ListUsers(path)
82 if len(got) != 1 || got[0].Email != "two@x.y" {
83 t.Fatalf("after remove: %+v", got)
84 }
85 if _, ok := Authenticate(path, "one@x.y", "passwordword"); ok {
86 t.Fatal("removed user still authenticates")
87 }
88 }
89
90 func TestDistinctSubs(t *testing.T) {
91 path := filepath.Join(t.TempDir(), "users.json")
92 _ = AddUser(path, "a@x.y", "passwordword")
93 _ = AddUser(path, "b@x.y", "passwordword")
94 us, _ := LoadUsers(path)
95 if us.Users[0].Sub == us.Users[1].Sub {
96 t.Fatal("subs collide across users")
97 }
98 }
internal/server/api/allocation_api_test.go
Old New
@@ -16,7 +16,7 @@ func TestHostResponseIncludesAllocated(t *testing.T) {
16 hostID := out["host_id"] 16 hostID := out["host_id"]
17 17
18 mk := func(vcpus, mem, disk int) { 18 mk := func(vcpus, mem, disk int) {
19 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{ 19 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
20 "host_id": hostID, "vcpus": vcpus, "mem_mb": mem, "disk_gb": disk, 20 "host_id": hostID, "vcpus": vcpus, "mem_mb": mem, "disk_gb": disk,
21 }) 21 })
22 require.Equal(t, http.StatusCreated, resp.StatusCode) 22 require.Equal(t, http.StatusCreated, resp.StatusCode)
@@ -24,7 +24,7 @@ func TestHostResponseIncludesAllocated(t *testing.T) {
24 mk(2, 2048, 10) 24 mk(2, 2048, 10)
25 mk(1, 1024, 5) 25 mk(1, 1024, 5)
26 26
27 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)) 27 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
28 require.Len(t, hosts, 1) 28 require.Len(t, hosts, 1)
29 alloc, ok := hosts[0]["allocated"].(map[string]any) 29 alloc, ok := hosts[0]["allocated"].(map[string]any)
30 require.True(t, ok, "host response must contain allocated object") 30 require.True(t, ok, "host response must contain allocated object")
internal/server/api/api.go
Old New
@@ -4,7 +4,6 @@ package api
4 import ( 4 import (
5 "context" 5 "context"
6 "crypto/sha256" 6 "crypto/sha256"
7 "crypto/subtle"
8 "database/sql" 7 "database/sql"
9 "encoding/hex" 8 "encoding/hex"
10 "encoding/json" 9 "encoding/json"
@@ -40,12 +39,12 @@ type DefaultImage struct {
40 39
41 // Config holds static configuration for the API server. 40 // Config holds static configuration for the API server.
42 type Config struct { 41 type Config struct {
43 AdminToken string
44 HostSecret []byte 42 HostSecret []byte
45 DefaultImage DefaultImage 43 DefaultImage DefaultImage
46 ServerCertSHA256 string 44 ServerCertSHA256 string
47 AdvertiseHTTP string // HTTP base URL agents use to reach this server (scheme+host+port) 45 AdvertiseHTTP string // HTTP base URL agents use to reach this server (scheme+host+port)
48 AdvertiseQUIC string // QUIC host:port agents use to reach this server 46 AdvertiseQUIC string // QUIC host:port agents use to reach this server
47 OIDC OIDCConfig // console sign-in relying-party settings (see auth.go)
49 } 48 }
50 49
51 // ReleaseSource exposes the latest known release. *release.Client satisfies 50 // ReleaseSource exposes the latest known release. *release.Client satisfies
@@ -80,6 +79,7 @@ type API struct {
80 sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off 79 sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off
81 release ReleaseSource // nil ⇒ release discovery disabled 80 release ReleaseSource // nil ⇒ release discovery disabled
82 upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader) 81 upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader)
82 auth *authFlow // OIDC sign-in relying party (mounted via AuthHandler, outside /api/)
83 } 83 }
84 84
85 // SetReleaseSource wires release discovery (nil leaves it disabled). 85 // SetReleaseSource wires release discovery (nil leaves it disabled).
@@ -106,7 +106,8 @@ func (a *API) latestVersion() string {
106 func New(cfg Config, st *store.Store, reg *registry.Registry, h *hub.Hub) *API { 106 func New(cfg Config, st *store.Store, reg *registry.Registry, h *hub.Hub) *API {
107 a := &API{cfg: cfg, st: st, reg: reg, hub: h, notif: newNotifier(), 107 a := &API{cfg: cfg, st: st, reg: reg, hub: h, notif: newNotifier(),
108 enrolls: newIPLimiter(time.Now), tickets: newTicketStore(time.Now)} 108 enrolls: newIPLimiter(time.Now), tickets: newTicketStore(time.Now)}
109 a.snap = newSnapshotHub(a.marshalSnapshot, a.notif) 109 a.auth = &authFlow{st: st, cfg: cfg.OIDC}
110 a.snap = newSnapshotHub(a.marshalSnapshots, a.notif)
110 go a.snap.run() 111 go a.snap.run()
111 return a 112 return a
112 } 113 }
@@ -127,13 +128,26 @@ func (a *API) Handler() http.Handler {
127 h := rt.handler 128 h := rt.handler
128 hf := func(w http.ResponseWriter, r *http.Request) { h(a, w, r) } 129 hf := func(w http.ResponseWriter, r *http.Request) { h(a, w, r) }
129 pattern := rt.Method + " " + rt.Path 130 pattern := rt.Method + " " + rt.Path
130 if rt.Auth == AuthAdmin { 131 if rt.Auth == AuthUser {
131 admin.HandleFunc(pattern, hf) 132 admin.HandleFunc(pattern, hf)
132 } else { 133 } else {
133 mux.HandleFunc(pattern, hf) 134 mux.HandleFunc(pattern, hf)
134 } 135 }
135 } 136 }
136 mux.Handle("/api/v1/", a.adminAuth(admin)) 137 mux.Handle("/api/v1/", a.userAuth(admin))
138 return mux
139 }
140
141 // AuthHandler returns the browser sign-in endpoints (/auth/login, /auth/callback,
142 // /auth/logout). main.go mounts it on the ROOT mux, OUTSIDE /api/ and its auth
143 // middleware — these establish the session the middleware later checks, so they
144 // cannot themselves require one. They are deliberately absent from the JSON
145 // route table (spec §3): they speak redirects and cookies, not the contract.
146 func (a *API) AuthHandler() http.Handler {
147 mux := http.NewServeMux()
148 mux.HandleFunc("GET /auth/login", a.auth.handleLogin)
149 mux.HandleFunc("GET /auth/callback", a.auth.handleCallback)
150 mux.HandleFunc("POST /auth/logout", a.auth.handleLogout)
137 return mux 151 return mux
138 } 152 }
139 153
@@ -177,39 +191,35 @@ func (a *API) sweepDecommissioned() bool {
177 return removed 191 return removed
178 } 192 }
179 193
180 // adminAuth returns middleware that requires a valid admin bearer token. 194 // userAuth resolves a request to a tenant principal: PAT bearer first, then
181 // Both sides are hashed before comparison so that constant-time compare 195 // the eitri_session console cookie. There are no other credentials and no
182 // genuinely prevents both value and length leaks. 196 // per-route exceptions (spec §3); a request with neither gets 401 and the SPA
183 func (a *API) adminAuth(next http.Handler) http.Handler { 197 // redirects to /auth/login. Unknown/expired/revoked PATs and sessions are all
198 // indistinguishable 401s.
199 func (a *API) userAuth(next http.Handler) http.Handler {
184 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 200 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185 // Reject immediately when no token is configured — avoids accepting 201 // PAT bearer first. The eitri_pat_ prefix disambiguates a PAT from any
186 // every request against an unconfigured server. 202 // other Bearer value; a prefixed-but-invalid token is a hard 401 rather
187 if a.cfg.AdminToken == "" { 203 // than falling through to the cookie path.
188 http.Error(w, "unauthorized", http.StatusUnauthorized) 204 if tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && strings.HasPrefix(tok, "eitri_pat_") {
205 if tenant, ok, err := a.st.TenantForAPIToken(tok); err == nil && ok {
206 next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), Principal{Tenant: tenant})))
207 return
208 }
209 http.Error(w, "invalid token", http.StatusUnauthorized)
189 return 210 return
190 } 211 }
191 if !constantTimeTokenMatch(r.Header.Get("Authorization"), "Bearer "+a.cfg.AdminToken) { 212 // Console session cookie next.
192 http.Error(w, "unauthorized", http.StatusUnauthorized) 213 if c, err := r.Cookie("eitri_session"); err == nil {
193 return 214 if tenant, ok, err := a.st.SessionTenant(c.Value); err == nil && ok {
215 next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), Principal{Tenant: tenant})))
216 return
217 }
194 } 218 }
195 // The token maps to the bootstrap principal: the default tenant's 219 http.Error(w, "sign in required", http.StatusUnauthorized)
196 // operator, with the fleet bit. Multi-user auth replaces this constant
197 // with a real token→principal lookup; nothing downstream changes.
198 p := Principal{Tenant: store.DefaultTenant, Fleet: true}
199 next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p)))
200 }) 220 })
201 } 221 }
202 222
203 // constantTimeTokenMatch reports whether got equals want without leaking the
204 // value or length via timing — it compares fixed-width SHA-256 digests. Callers
205 // pass the fully-built strings (the wire formats differ: a "Bearer "-prefixed
206 // header vs. a bare query token).
207 func constantTimeTokenMatch(got, want string) bool {
208 gotSum := sha256.Sum256([]byte(got))
209 wantSum := sha256.Sum256([]byte(want))
210 return subtle.ConstantTimeCompare(gotSum[:], wantSum[:]) == 1
211 }
212
213 // writeJSON encodes v as JSON with the correct Content-Type header and status. 223 // writeJSON encodes v as JSON with the correct Content-Type header and status.
214 func writeJSON(w http.ResponseWriter, status int, v any) { 224 func writeJSON(w http.ResponseWriter, status int, v any) {
215 w.Header().Set("Content-Type", "application/json") 225 w.Header().Set("Content-Type", "application/json")
@@ -235,9 +245,9 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
235 // BEST-EFFORT: rows written here can be lost on a crash between the audited 245 // BEST-EFFORT: rows written here can be lost on a crash between the audited
236 // operation and this insert. The one row that must be durable — host.enroll — 246 // operation and this insert. The one row that must be durable — host.enroll —
237 // is written inside the redeem transaction by the store, not here. 247 // is written inside the redeem transaction by the store, not here.
238 func (a *API) audit(action string, detail map[string]string) { 248 func (a *API) audit(tenant, action string, detail map[string]string) {
239 raw, _ := json.Marshal(detail) 249 raw, _ := json.Marshal(detail)
240 if err := a.st.AppendAudit(action, string(raw)); err != nil { 250 if err := a.st.AppendAudit(tenant, action, string(raw)); err != nil {
241 slog.Warn("audit append failed", "action", action, "err", err) 251 slog.Warn("audit append failed", "action", action, "err", err)
242 } 252 }
243 } 253 }
@@ -272,7 +282,9 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) {
272 } 282 }
273 host, err := a.st.RedeemEnrollmentToken(req.Token, req.Name, req.OS, req.Arch, req.Provisioner, clientIP(r)) 283 host, err := a.st.RedeemEnrollmentToken(req.Token, req.Name, req.OS, req.Arch, req.Provisioner, clientIP(r))
274 if err != nil { 284 if err != nil {
275 a.audit("host.enroll.denied", map[string]string{ 285 // Unauthenticated enroll attempt: no tenant is resolvable (the token was
286 // rejected), so the denied row is filed under the system audit scope.
287 a.audit(store.SystemTenant, "host.enroll.denied", map[string]string{
276 "remote": clientIP(r), "name": truncate(req.Name, 64), 288 "remote": clientIP(r), "name": truncate(req.Name, 64),
277 "token_hash_prefix": tokenHashPrefix(req.Token)}) 289 "token_hash_prefix": tokenHashPrefix(req.Token)})
278 http.Error(w, "forbidden", http.StatusForbidden) 290 http.Error(w, "forbidden", http.StatusForbidden)
@@ -289,15 +301,12 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) {
289 } 301 }
290 302
291 func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) { 303 func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) {
292 if !a.requireFleet(w, r) {
293 return
294 }
295 tok, err := a.st.CreateEnrollmentToken(principalFromContext(r).Tenant) 304 tok, err := a.st.CreateEnrollmentToken(principalFromContext(r).Tenant)
296 if err != nil { 305 if err != nil {
297 http.Error(w, "internal error", http.StatusInternalServerError) 306 http.Error(w, "internal error", http.StatusInternalServerError)
298 return 307 return
299 } 308 }
300 a.audit("enroll-token.mint", map[string]string{ 309 a.audit(principalFromContext(r).Tenant, "enroll-token.mint", map[string]string{
301 "remote": clientIP(r), "token_hash_prefix": tokenHashPrefix(tok)}) 310 "remote": clientIP(r), "token_hash_prefix": tokenHashPrefix(tok)})
302 join, err := joinblob.Encode(a.cfg.AdvertiseHTTP, a.cfg.AdvertiseQUIC, tok, a.cfg.ServerCertSHA256) 311 join, err := joinblob.Encode(a.cfg.AdvertiseHTTP, a.cfg.AdvertiseQUIC, tok, a.cfg.ServerCertSHA256)
303 if err != nil { 312 if err != nil {
@@ -740,7 +749,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
740 return 749 return
741 } 750 }
742 751
743 a.audit("vm.create", map[string]string{"vm_id": id, "name": req.Name, "host_id": req.HostID}) 752 a.audit(host.Tenant, "vm.create", map[string]string{"vm_id": id, "name": req.Name, "host_id": req.HostID})
744 a.hub.Poke(req.HostID) 753 a.hub.Poke(req.HostID)
745 a.notif.notify() 754 a.notif.notify()
746 writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name}) 755 writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name})
@@ -782,7 +791,7 @@ func (a *API) mutateVM(w http.ResponseWriter, r *http.Request, id string, mutate
782 http.Error(w, "internal error", http.StatusInternalServerError) 791 http.Error(w, "internal error", http.StatusInternalServerError)
783 return 792 return
784 } 793 }
785 a.audit(auditAction, auditDetail(vm)) 794 a.audit(vm.Tenant, auditAction, auditDetail(vm))
786 a.hub.Poke(vm.HostID) 795 a.hub.Poke(vm.HostID)
787 a.notif.notify() 796 a.notif.notify()
788 w.WriteHeader(http.StatusNoContent) 797 w.WriteHeader(http.StatusNoContent)
internal/server/api/api_test.go
Old New
@@ -35,12 +35,12 @@ func TestResponseJSONKeysAreSnakeCase(t *testing.T) {
35 out := enroll(t, ts) 35 out := enroll(t, ts)
36 36
37 // Create a VM so the list is non-empty. 37 // Create a VM so the list is non-empty.
38 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 38 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
39 map[string]any{"host_id": out["host_id"], "name": "test-vm"}) 39 map[string]any{"host_id": out["host_id"], "name": "test-vm"})
40 require.Equal(t, 201, resp.StatusCode) 40 require.Equal(t, 201, resp.StatusCode)
41 41
42 t.Run("hosts", func(t *testing.T) { 42 t.Run("hosts", func(t *testing.T) {
43 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil) 43 resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
44 require.Equal(t, 200, resp.StatusCode) 44 require.Equal(t, 200, resp.StatusCode)
45 items := decodeJSONKeys(t, resp) 45 items := decodeJSONKeys(t, resp)
46 require.Len(t, items, 1) 46 require.Len(t, items, 1)
@@ -67,7 +67,7 @@ func TestResponseJSONKeysAreSnakeCase(t *testing.T) {
67 }) 67 })
68 68
69 t.Run("vms", func(t *testing.T) { 69 t.Run("vms", func(t *testing.T) {
70 resp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) 70 resp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
71 require.Equal(t, 200, resp.StatusCode) 71 require.Equal(t, 200, resp.StatusCode)
72 items := decodeJSONKeys(t, resp) 72 items := decodeJSONKeys(t, resp)
73 require.Len(t, items, 1) 73 require.Len(t, items, 1)
@@ -109,11 +109,11 @@ func TestCreateVMDuplicateNameReturns409(t *testing.T) {
109 ts, _, _ := testServer(t) 109 ts, _, _ := testServer(t)
110 out := enroll(t, ts) 110 out := enroll(t, ts)
111 111
112 resp1 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 112 resp1 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
113 map[string]any{"host_id": out["host_id"], "name": "clash"}) 113 map[string]any{"host_id": out["host_id"], "name": "clash"})
114 require.Equal(t, 201, resp1.StatusCode) 114 require.Equal(t, 201, resp1.StatusCode)
115 115
116 resp2 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 116 resp2 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
117 map[string]any{"host_id": out["host_id"], "name": "clash"}) 117 map[string]any{"host_id": out["host_id"], "name": "clash"})
118 assert.Equal(t, 409, resp2.StatusCode) 118 assert.Equal(t, 409, resp2.StatusCode)
119 119
@@ -127,7 +127,7 @@ func TestCreateVMDuplicateNameReturns409(t *testing.T) {
127 // TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400. 127 // TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400.
128 func TestCreateVMUnknownHostReturns400(t *testing.T) { 128 func TestCreateVMUnknownHostReturns400(t *testing.T) {
129 ts, _, _ := testServer(t) 129 ts, _, _ := testServer(t)
130 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 130 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
131 map[string]any{"host_id": "deadbeef00000000000000000000000000000000", "name": "vm-orphan"}) 131 map[string]any{"host_id": "deadbeef00000000000000000000000000000000", "name": "vm-orphan"})
132 assert.Equal(t, 400, resp.StatusCode) 132 assert.Equal(t, 400, resp.StatusCode)
133 } 133 }
@@ -178,6 +178,33 @@ func TestHostResponseSurfacesSyncHealth(t *testing.T) {
178 }) 178 })
179 } 179 }
180 180
181 // testPAT is the default-tenant personal access token the harness mints for the
182 // current server; do() and enroll() pass it as the Bearer credential. It is
183 // package-global because it flows through nearly every test's do() call without
184 // threading it through every signature — safe because these tests never run in
185 // parallel (no t.Parallel anywhere), so newServer/apiServer set it before any
186 // request reads it.
187 var testPAT string
188
189 // testTenant is the tenant the shared builders provision — through the real
190 // JIT path, the only way tenants are born. The name has no significance.
191 const testTenant = "default"
192
193 // seedTestTenant JIT-provisions testTenant on a fresh store.
194 func seedTestTenant(t *testing.T, st *store.Store) {
195 t.Helper()
196 _, err := st.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
197 require.NoError(t, err)
198 }
199
200 // mintTestPAT mints the test-tenant PAT for st and records it in testPAT.
201 func mintTestPAT(t *testing.T, st *store.Store) {
202 t.Helper()
203 secret, _, err := st.CreateAPIToken(testTenant, "test", 0)
204 require.NoError(t, err)
205 testPAT = secret
206 }
207
181 // newServer is the shared builder. It also returns the *API itself for tests 208 // newServer is the shared builder. It also returns the *API itself for tests
182 // that need post-construction wiring (SetConsoleDialer, SetCertMinter). 209 // that need post-construction wiring (SetConsoleDialer, SetCertMinter).
183 func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registry.Registry, *API) { 210 func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registry.Registry, *API) {
@@ -185,10 +212,10 @@ func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registr
185 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 212 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
186 require.NoError(t, err) 213 require.NoError(t, err)
187 t.Cleanup(func() { st.Close() }) 214 t.Cleanup(func() { st.Close() })
215 seedTestTenant(t, st)
188 h := hub.New() 216 h := hub.New()
189 reg := registry.New(time.Now) 217 reg := registry.New(time.Now)
190 a := New(Config{ 218 a := New(Config{
191 AdminToken: "admintok",
192 HostSecret: []byte("hostsecret"), 219 HostSecret: []byte("hostsecret"),
193 DefaultImage: DefaultImage{ 220 DefaultImage: DefaultImage{
194 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", 221 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
@@ -200,14 +227,40 @@ func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registr
200 // BYO-CA precondition: VM create now requires the tenant to have ≥1 227 // BYO-CA precondition: VM create now requires the tenant to have ≥1
201 // registered SSH user CA. Seed the default tenant with a throwaway CA line 228 // registered SSH user CA. Seed the default tenant with a throwaway CA line
202 // so existing VM-create tests exercise the create path, not the precondition. 229 // so existing VM-create tests exercise the create path, not the precondition.
203 require.NoError(t, st.AddTenantUserCA(store.DefaultTenant, 230 require.NoError(t, st.AddTenantUserCA(testTenant,
204 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test")) 231 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test"))
232 mintTestPAT(t, st)
205 ts := httptest.NewServer(a.Handler()) 233 ts := httptest.NewServer(a.Handler())
206 t.Cleanup(ts.Close) 234 t.Cleanup(ts.Close)
207 t.Cleanup(a.Close) // stop the snapshot hub goroutine 235 t.Cleanup(a.Close) // stop the snapshot hub goroutine
208 return ts, st, h, reg, a 236 return ts, st, h, reg, a
209 } 237 }
210 238
239 // sessionFor mints a console session row for tenant and returns its id — the
240 // eitri_session cookie value the middleware's cookie path consumes.
241 func sessionFor(t *testing.T, st *store.Store, tenant string) string {
242 t.Helper()
243 id, err := st.CreateSession(tenant, time.Hour)
244 require.NoError(t, err)
245 return id
246 }
247
248 // doCookie issues a request authenticated by an eitri_session cookie rather than
249 // a Bearer token — the console (browser) auth path.
250 func doCookie(t *testing.T, method, url, session string, body any) *http.Response {
251 t.Helper()
252 var buf bytes.Buffer
253 if body != nil {
254 require.NoError(t, json.NewEncoder(&buf).Encode(body))
255 }
256 req, _ := http.NewRequest(method, url, &buf)
257 req.AddCookie(&http.Cookie{Name: "eitri_session", Value: session})
258 resp, err := http.DefaultClient.Do(req)
259 require.NoError(t, err)
260 t.Cleanup(func() { resp.Body.Close() })
261 return resp
262 }
263
211 func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) { 264 func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) {
212 t.Helper() 265 t.Helper()
213 ts, st, h, _, _ := newServer(t) 266 ts, st, h, _, _ := newServer(t)
@@ -232,7 +285,7 @@ func do(t *testing.T, method, url, token string, body any) *http.Response {
232 285
233 func enroll(t *testing.T, ts *httptest.Server) map[string]string { 286 func enroll(t *testing.T, ts *httptest.Server) map[string]string {
234 t.Helper() 287 t.Helper()
235 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil) 288 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
236 require.Equal(t, 201, resp.StatusCode) 289 require.Equal(t, 201, resp.StatusCode)
237 var tok map[string]string 290 var tok map[string]string
238 json.NewDecoder(resp.Body).Decode(&tok) 291 json.NewDecoder(resp.Body).Decode(&tok)
@@ -244,11 +297,81 @@ func enroll(t *testing.T, ts *httptest.Server) map[string]string {
244 return out // host_id, credential, bridge_cidr 297 return out // host_id, credential, bridge_cidr
245 } 298 }
246 299
247 func TestAdminAuthRequired(t *testing.T) { 300 // TestUserAuthMiddleware pins the PAT/session middleware: a valid PAT or session
248 ts, _, _ := testServer(t) 301 // cookie authenticates and scopes to its tenant; every unknown, expired, revoked,
249 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "", nil).StatusCode) 302 // malformed, or absent credential is an indistinguishable 401 (spec §3).
250 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "wrong", nil).StatusCode) 303 func TestUserAuthMiddleware(t *testing.T) {
251 assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil).StatusCode) 304 ts, st, _ := testServer(t)
305
306 t.Run("valid PAT authenticates", func(t *testing.T) {
307 assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil).StatusCode)
308 })
309
310 t.Run("absent credential rejected", func(t *testing.T) {
311 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "", nil).StatusCode)
312 })
313
314 t.Run("garbage bearer rejected", func(t *testing.T) {
315 // No eitri_pat_ prefix ⇒ falls through to the cookie path ⇒ no cookie ⇒ 401.
316 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "wrong", nil).StatusCode)
317 })
318
319 t.Run("prefixed but unknown PAT rejected", func(t *testing.T) {
320 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "eitri_pat_"+strings.Repeat("0", 64), nil).StatusCode)
321 })
322
323 t.Run("expired PAT rejected", func(t *testing.T) {
324 // A negative TTL mints an already-expired token (expires_at in the past).
325 expired, _, err := st.CreateAPIToken(testTenant, "expired", -time.Hour)
326 require.NoError(t, err)
327 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", expired, nil).StatusCode)
328 })
329
330 t.Run("revoked PAT rejected", func(t *testing.T) {
331 secret, id, err := st.CreateAPIToken(testTenant, "doomed", 0)
332 require.NoError(t, err)
333 require.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", secret, nil).StatusCode)
334 require.NoError(t, st.RevokeAPIToken(testTenant, id))
335 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", secret, nil).StatusCode)
336 })
337
338 t.Run("session cookie authenticates", func(t *testing.T) {
339 sess := sessionFor(t, st, testTenant)
340 assert.Equal(t, 200, doCookie(t, "GET", ts.URL+"/api/v1/vms", sess, nil).StatusCode)
341 })
342
343 t.Run("unknown session cookie rejected", func(t *testing.T) {
344 assert.Equal(t, 401, doCookie(t, "GET", ts.URL+"/api/v1/vms", "not-a-session", nil).StatusCode)
345 })
346 }
347
348 // TestUserAuthResolvesCredentialTenant proves the middleware threads each
349 // credential's OWN tenant onto the principal: an enroll token minted with a
350 // second tenant's PAT produces a host owned by that tenant, not the default.
351 // (List-level and stream cross-tenant scoping is proven separately in
352 // isolation_test.go; every credential is now scoped to exactly its own tenant.)
353 func TestUserAuthResolvesCredentialTenant(t *testing.T) {
354 ts, st, _ := testServer(t)
355
356 beta, err := st.CreateTenantForIdentity("https://issuer.example", "sub-beta", "beta@example.com")
357 require.NoError(t, err)
358 betaPAT, _, err := st.CreateAPIToken(beta.ID, "beta", 0)
359 require.NoError(t, err)
360
361 // Mint an enroll token as beta, then redeem it: the host must belong to beta.
362 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", betaPAT, nil)
363 require.Equal(t, 201, resp.StatusCode)
364 var tok map[string]string
365 require.NoError(t, json.NewDecoder(resp.Body).Decode(&tok))
366 resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
367 "token": tok["token"], "name": "beta-host", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
368 require.Equal(t, 201, resp.StatusCode)
369 var out map[string]string
370 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
371
372 host, err := st.GetHost(out["host_id"])
373 require.NoError(t, err)
374 assert.Equal(t, beta.ID, host.Tenant, "host must be owned by the tenant whose PAT minted the token")
252 } 375 }
253 376
254 func TestEnrollIssuesCredentialAndCIDR(t *testing.T) { 377 func TestEnrollIssuesCredentialAndCIDR(t *testing.T) {
@@ -265,7 +388,7 @@ func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) {
265 poked, cancel := h.Subscribe(out["host_id"]) 388 poked, cancel := h.Subscribe(out["host_id"])
266 defer cancel() 389 defer cancel()
267 390
268 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 391 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
269 map[string]any{"host_id": out["host_id"]}) // one-click: everything else defaulted 392 map[string]any{"host_id": out["host_id"]}) // one-click: everything else defaulted
270 require.Equal(t, 201, resp.StatusCode) 393 require.Equal(t, 201, resp.StatusCode)
271 394
@@ -288,9 +411,9 @@ func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) {
288 func TestDeleteTombstones(t *testing.T) { 411 func TestDeleteTombstones(t *testing.T) {
289 ts, st, _ := testServer(t) 412 ts, st, _ := testServer(t)
290 out := enroll(t, ts) 413 out := enroll(t, ts)
291 do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": out["host_id"], "name": "doomed"}) 414 do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": out["host_id"], "name": "doomed"})
292 vms, _ := st.ListVMs() 415 vms, _ := st.ListVMs()
293 resp := do(t, "DELETE", ts.URL+"/api/v1/vms/"+vms[0].ID, "admintok", nil) 416 resp := do(t, "DELETE", ts.URL+"/api/v1/vms/"+vms[0].ID, testPAT, nil)
294 assert.Equal(t, 204, resp.StatusCode) 417 assert.Equal(t, 204, resp.StatusCode)
295 vms, _ = st.ListVMs() 418 vms, _ = st.ListVMs()
296 assert.NotNil(t, vms[0].DeletedAt, "DELETE tombstones; the agent reaps") 419 assert.NotNil(t, vms[0].DeletedAt, "DELETE tombstones; the agent reaps")
@@ -305,23 +428,23 @@ func TestVMEventsTimeline(t *testing.T) {
305 out := enroll(t, ts) 428 out := enroll(t, ts)
306 429
307 // Two VMs so we can assert the timeline is scoped to one. 430 // Two VMs so we can assert the timeline is scoped to one.
308 r1 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 431 r1 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
309 map[string]any{"host_id": out["host_id"], "name": "alpha"}) 432 map[string]any{"host_id": out["host_id"], "name": "alpha"})
310 require.Equal(t, 201, r1.StatusCode) 433 require.Equal(t, 201, r1.StatusCode)
311 var createdA map[string]string 434 var createdA map[string]string
312 json.NewDecoder(r1.Body).Decode(&createdA) 435 json.NewDecoder(r1.Body).Decode(&createdA)
313 idA := createdA["id"] 436 idA := createdA["id"]
314 437
315 r2 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 438 r2 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
316 map[string]any{"host_id": out["host_id"], "name": "bravo"}) 439 map[string]any{"host_id": out["host_id"], "name": "bravo"})
317 require.Equal(t, 201, r2.StatusCode) 440 require.Equal(t, 201, r2.StatusCode)
318 var createdB map[string]string 441 var createdB map[string]string
319 json.NewDecoder(r2.Body).Decode(&createdB) 442 json.NewDecoder(r2.Body).Decode(&createdB)
320 idB := createdB["id"] 443 idB := createdB["id"]
321 444
322 require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+idA, "admintok", nil).StatusCode) 445 require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+idA, testPAT, nil).StatusCode)
323 446
324 resp := do(t, "GET", ts.URL+"/api/v1/vms/"+idA+"/events", "admintok", nil) 447 resp := do(t, "GET", ts.URL+"/api/v1/vms/"+idA+"/events", testPAT, nil)
325 require.Equal(t, 200, resp.StatusCode) 448 require.Equal(t, 200, resp.StatusCode)
326 var events []struct { 449 var events []struct {
327 Action string `json:"action"` 450 Action string `json:"action"`
@@ -359,7 +482,7 @@ func TestCreateVMNameValidation(t *testing.T) {
359 } 482 }
360 for _, tc := range tests { 483 for _, tc := range tests {
361 t.Run(tc.name, func(t *testing.T) { 484 t.Run(tc.name, func(t *testing.T) {
362 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 485 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
363 map[string]any{"host_id": out["host_id"], "name": tc.vmName}) 486 map[string]any{"host_id": out["host_id"], "name": tc.vmName})
364 assert.Equal(t, tc.wantStatus, resp.StatusCode) 487 assert.Equal(t, tc.wantStatus, resp.StatusCode)
365 }) 488 })
@@ -390,7 +513,7 @@ func TestCreateVMResourceValidation(t *testing.T) {
390 t.Run(tc.name, func(t *testing.T) { 513 t.Run(tc.name, func(t *testing.T) {
391 body := map[string]any{"host_id": out["host_id"]} 514 body := map[string]any{"host_id": out["host_id"]}
392 maps.Copy(body, tc.body) 515 maps.Copy(body, tc.body)
393 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", body) 516 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, body)
394 assert.Equal(t, tc.wantStatus, resp.StatusCode) 517 assert.Equal(t, tc.wantStatus, resp.StatusCode)
395 }) 518 })
396 } 519 }
@@ -401,7 +524,7 @@ func TestCreateVMSSHKeyValidation(t *testing.T) {
401 out := enroll(t, ts) 524 out := enroll(t, ts)
402 525
403 t.Run("ssh key with newline is rejected", func(t *testing.T) { 526 t.Run("ssh key with newline is rejected", func(t *testing.T) {
404 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 527 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
405 map[string]any{ 528 map[string]any{
406 "host_id": out["host_id"], 529 "host_id": out["host_id"],
407 "name": "safe-vm", 530 "name": "safe-vm",
@@ -411,7 +534,7 @@ func TestCreateVMSSHKeyValidation(t *testing.T) {
411 }) 534 })
412 535
413 t.Run("ssh key with carriage return is rejected", func(t *testing.T) { 536 t.Run("ssh key with carriage return is rejected", func(t *testing.T) {
414 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 537 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
415 map[string]any{ 538 map[string]any{
416 "host_id": out["host_id"], 539 "host_id": out["host_id"],
417 "name": "safe-vm-2", 540 "name": "safe-vm-2",
@@ -421,7 +544,7 @@ func TestCreateVMSSHKeyValidation(t *testing.T) {
421 }) 544 })
422 545
423 t.Run("valid single-line ssh key is accepted", func(t *testing.T) { 546 t.Run("valid single-line ssh key is accepted", func(t *testing.T) {
424 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 547 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
425 map[string]any{ 548 map[string]any{
426 "host_id": out["host_id"], 549 "host_id": out["host_id"],
427 "name": "valid-vm", 550 "name": "valid-vm",
@@ -440,7 +563,7 @@ func TestCreateVMImageSHAAdmission(t *testing.T) {
440 validURL := "https://example.com/custom.img" 563 validURL := "https://example.com/custom.img"
441 564
442 t.Run("custom url without sha is rejected", func(t *testing.T) { 565 t.Run("custom url without sha is rejected", func(t *testing.T) {
443 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 566 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
444 map[string]any{ 567 map[string]any{
445 "host_id": out["host_id"], 568 "host_id": out["host_id"],
446 "name": "bad-url-no-sha", 569 "name": "bad-url-no-sha",
@@ -450,7 +573,7 @@ func TestCreateVMImageSHAAdmission(t *testing.T) {
450 }) 573 })
451 574
452 t.Run("sha without url is rejected", func(t *testing.T) { 575 t.Run("sha without url is rejected", func(t *testing.T) {
453 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 576 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
454 map[string]any{ 577 map[string]any{
455 "host_id": out["host_id"], 578 "host_id": out["host_id"],
456 "name": "bad-sha-no-url", 579 "name": "bad-sha-no-url",
@@ -460,7 +583,7 @@ func TestCreateVMImageSHAAdmission(t *testing.T) {
460 }) 583 })
461 584
462 t.Run("bad-format sha is rejected", func(t *testing.T) { 585 t.Run("bad-format sha is rejected", func(t *testing.T) {
463 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 586 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
464 map[string]any{ 587 map[string]any{
465 "host_id": out["host_id"], 588 "host_id": out["host_id"],
466 "name": "bad-sha-format", 589 "name": "bad-sha-format",
@@ -471,7 +594,7 @@ func TestCreateVMImageSHAAdmission(t *testing.T) {
471 }) 594 })
472 595
473 t.Run("both custom url and valid sha are accepted", func(t *testing.T) { 596 t.Run("both custom url and valid sha are accepted", func(t *testing.T) {
474 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 597 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
475 map[string]any{ 598 map[string]any{
476 "host_id": out["host_id"], 599 "host_id": out["host_id"],
477 "name": "good-custom-image", 600 "name": "good-custom-image",
@@ -484,7 +607,7 @@ func TestCreateVMImageSHAAdmission(t *testing.T) {
484 607
485 func TestMintEnrollTokenReturnsJoinBlob(t *testing.T) { 608 func TestMintEnrollTokenReturnsJoinBlob(t *testing.T) {
486 ts, _, _ := testServer(t) 609 ts, _, _ := testServer(t)
487 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil) 610 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
488 if resp.StatusCode != http.StatusCreated { 611 if resp.StatusCode != http.StatusCreated {
489 t.Fatalf("mint status = %d", resp.StatusCode) 612 t.Fatalf("mint status = %d", resp.StatusCode)
490 } 613 }
@@ -516,7 +639,7 @@ func TestMintEnrollTokenReturnsJoinBlob(t *testing.T) {
516 func TestEnrollmentIsAudited(t *testing.T) { 639 func TestEnrollmentIsAudited(t *testing.T) {
517 ts, st, _ := testServer(t) 640 ts, st, _ := testServer(t)
518 641
519 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil) 642 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
520 require.Equal(t, 201, resp.StatusCode) 643 require.Equal(t, 201, resp.StatusCode)
521 var tok map[string]string 644 var tok map[string]string
522 json.NewDecoder(resp.Body).Decode(&tok) 645 json.NewDecoder(resp.Body).Decode(&tok)
@@ -529,16 +652,23 @@ func TestEnrollmentIsAudited(t *testing.T) {
529 "token": "bogus", "name": "evil", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"}) 652 "token": "bogus", "name": "evil", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
530 require.Equal(t, 403, resp.StatusCode) 653 require.Equal(t, 403, resp.StatusCode)
531 654
532 rows, err := st.ListAudit(10) 655 rows, err := st.ListAudit(testTenant, 10)
533 require.NoError(t, err) 656 require.NoError(t, err)
534 require.Len(t, rows, 3) 657 require.Len(t, rows, 2)
535 assert.Equal(t, "host.enroll.denied", rows[0].Action) 658 assert.Equal(t, "host.enroll", rows[0].Action)
536 assert.Equal(t, "host.enroll", rows[1].Action) 659 assert.Contains(t, rows[0].Detail, "host-a")
537 assert.Contains(t, rows[1].Detail, "host-a") 660 assert.Equal(t, "enroll-token.mint", rows[1].Action)
538 assert.Equal(t, "enroll-token.mint", rows[2].Action)
539 for _, r := range rows { 661 for _, r := range rows {
540 assert.NotContains(t, r.Detail, tok["token"], "raw token must never reach the audit log") 662 assert.NotContains(t, r.Detail, tok["token"], "raw token must never reach the audit log")
541 } 663 }
664
665 // The denied attempt had no resolvable tenant, so it is filed under the
666 // system audit scope — durable, but invisible to any tenant's audit read.
667 sys, err := st.ListAudit(store.SystemTenant, 10)
668 require.NoError(t, err)
669 require.Len(t, sys, 1)
670 assert.Equal(t, "host.enroll.denied", sys[0].Action)
671 assert.NotContains(t, sys[0].Detail, tok["token"], "raw token must never reach the audit log")
542 } 672 }
543 673
544 // TestEnrollRateLimited pins the per-IP limiter on the unauthenticated enroll 674 // TestEnrollRateLimited pins the per-IP limiter on the unauthenticated enroll
@@ -566,20 +696,20 @@ func TestRevokeCredentialEndpoint(t *testing.T) {
566 ts, st, _ := testServer(t) 696 ts, st, _ := testServer(t)
567 out := enroll(t, ts) 697 out := enroll(t, ts)
568 698
569 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", "admintok", nil) 699 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", testPAT, nil)
570 require.Equal(t, 204, resp.StatusCode) 700 require.Equal(t, 204, resp.StatusCode)
571 701
572 h, err := st.GetHost(out["host_id"]) 702 h, err := st.GetHost(out["host_id"])
573 require.NoError(t, err) 703 require.NoError(t, err)
574 assert.Equal(t, int64(2), h.CredGeneration) 704 assert.Equal(t, int64(2), h.CredGeneration)
575 705
576 rows, err := st.ListAudit(3) 706 rows, err := st.ListAudit(testTenant, 3)
577 require.NoError(t, err) 707 require.NoError(t, err)
578 require.NotEmpty(t, rows) 708 require.NotEmpty(t, rows)
579 assert.Equal(t, "host.credential.revoke", rows[0].Action) 709 assert.Equal(t, "host.credential.revoke", rows[0].Action)
580 assert.Contains(t, rows[0].Detail, out["host_id"]) 710 assert.Contains(t, rows[0].Detail, out["host_id"])
581 711
582 resp = do(t, "POST", ts.URL+"/api/v1/hosts/deadbeef/revoke-credential", "admintok", nil) 712 resp = do(t, "POST", ts.URL+"/api/v1/hosts/deadbeef/revoke-credential", testPAT, nil)
583 assert.Equal(t, 404, resp.StatusCode) 713 assert.Equal(t, 404, resp.StatusCode)
584 714
585 resp = do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", "wrong", nil) 715 resp = do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", "wrong", nil)
@@ -603,7 +733,7 @@ func TestAuditEndpoint(t *testing.T) {
603 ts, _, _ := testServer(t) 733 ts, _, _ := testServer(t)
604 enroll(t, ts) // produces mint + enroll audit rows 734 enroll(t, ts) // produces mint + enroll audit rows
605 735
606 resp := do(t, "GET", ts.URL+"/api/v1/audit", "admintok", nil) 736 resp := do(t, "GET", ts.URL+"/api/v1/audit", testPAT, nil)
607 require.Equal(t, 200, resp.StatusCode) 737 require.Equal(t, 200, resp.StatusCode)
608 var rows []struct { 738 var rows []struct {
609 At time.Time `json:"at"` 739 At time.Time `json:"at"`
@@ -617,7 +747,7 @@ func TestAuditEndpoint(t *testing.T) {
617 assert.False(t, rows[0].At.IsZero()) 747 assert.False(t, rows[0].At.IsZero())
618 assert.Contains(t, string(rows[0].Detail), "host-a") 748 assert.Contains(t, string(rows[0].Detail), "host-a")
619 749
620 resp = do(t, "GET", ts.URL+"/api/v1/audit?limit=1", "admintok", nil) 750 resp = do(t, "GET", ts.URL+"/api/v1/audit?limit=1", testPAT, nil)
621 require.Equal(t, 200, resp.StatusCode) 751 require.Equal(t, 200, resp.StatusCode)
622 rows = nil 752 rows = nil
623 require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows)) 753 require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
@@ -632,7 +762,7 @@ func TestAuditEndpoint(t *testing.T) {
632 func TestAuditEndpointLimitValidation(t *testing.T) { 762 func TestAuditEndpointLimitValidation(t *testing.T) {
633 ts, _, _ := testServer(t) 763 ts, _, _ := testServer(t)
634 for _, bad := range []string{"0", "1001", "-3", "abc"} { 764 for _, bad := range []string{"0", "1001", "-3", "abc"} {
635 resp := do(t, "GET", ts.URL+"/api/v1/audit?limit="+bad, "admintok", nil) 765 resp := do(t, "GET", ts.URL+"/api/v1/audit?limit="+bad, testPAT, nil)
636 assert.Equal(t, 400, resp.StatusCode, "limit=%s must be rejected", bad) 766 assert.Equal(t, 400, resp.StatusCode, "limit=%s must be rejected", bad)
637 } 767 }
638 } 768 }
@@ -645,7 +775,7 @@ func TestCreateVMMergesSSHKeyIntoCloudInit(t *testing.T) {
645 out := enroll(t, ts) 775 out := enroll(t, ts)
646 776
647 const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host" 777 const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host"
648 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{ 778 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
649 "host_id": out["host_id"], 779 "host_id": out["host_id"],
650 "name": "web", 780 "name": "web",
651 "cloud_init": "#cloud-config\npackages:\n - htop\n", 781 "cloud_init": "#cloud-config\npackages:\n - htop\n",
@@ -670,7 +800,7 @@ func TestCreateVMWrapsShellScriptUserDataWithKey(t *testing.T) {
670 out := enroll(t, ts) 800 out := enroll(t, ts)
671 801
672 const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host" 802 const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host"
673 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{ 803 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
674 "host_id": out["host_id"], 804 "host_id": out["host_id"],
675 "name": "web", 805 "name": "web",
676 "cloud_init": "#!/bin/bash\necho hi\n", 806 "cloud_init": "#!/bin/bash\necho hi\n",
@@ -693,7 +823,7 @@ func TestCreateVMRejectsUnhandleableCloudInit(t *testing.T) {
693 ts, st, _ := testServer(t) 823 ts, st, _ := testServer(t)
694 out := enroll(t, ts) 824 out := enroll(t, ts)
695 825
696 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{ 826 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
697 "host_id": out["host_id"], 827 "host_id": out["host_id"],
698 "name": "web", 828 "name": "web",
699 "cloud_init": "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n", 829 "cloud_init": "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n",
@@ -748,7 +878,7 @@ func TestDeriveLifecycle(t *testing.T) {
748 func TestRestoreVMUnTombstonesWithinGrace(t *testing.T) { 878 func TestRestoreVMUnTombstonesWithinGrace(t *testing.T) {
749 ts, st, _ := testServer(t) 879 ts, st, _ := testServer(t)
750 out := enroll(t, ts) 880 out := enroll(t, ts)
751 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 881 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
752 map[string]any{"host_id": out["host_id"], "name": "web"}) 882 map[string]any{"host_id": out["host_id"], "name": "web"})
753 require.Equal(t, 201, resp.StatusCode) 883 require.Equal(t, 201, resp.StatusCode)
754 884
@@ -758,19 +888,19 @@ func TestRestoreVMUnTombstonesWithinGrace(t *testing.T) {
758 id := vms[0].ID 888 id := vms[0].ID
759 889
760 // Delete → tombstoned: deleted=true. 890 // Delete → tombstoned: deleted=true.
761 require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+id, "admintok", nil).StatusCode) 891 require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+id, testPAT, nil).StatusCode)
762 items := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil)) 892 items := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil))
763 require.Len(t, items, 1) 893 require.Len(t, items, 1)
764 assert.Equal(t, true, items[0]["deleted"]) 894 assert.Equal(t, true, items[0]["deleted"])
765 895
766 // Restore → un-tombstoned: deleted=false. 896 // Restore → un-tombstoned: deleted=false.
767 require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+id+"/restore", "admintok", nil).StatusCode) 897 require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+id+"/restore", testPAT, nil).StatusCode)
768 items = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil)) 898 items = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil))
769 require.Len(t, items, 1) 899 require.Len(t, items, 1)
770 assert.Equal(t, false, items[0]["deleted"]) 900 assert.Equal(t, false, items[0]["deleted"])
771 901
772 // The restore lands on the per-VM timeline. 902 // The restore lands on the per-VM timeline.
773 resp = do(t, "GET", ts.URL+"/api/v1/vms/"+id+"/events", "admintok", nil) 903 resp = do(t, "GET", ts.URL+"/api/v1/vms/"+id+"/events", testPAT, nil)
774 require.Equal(t, 200, resp.StatusCode) 904 require.Equal(t, 200, resp.StatusCode)
775 var events []struct { 905 var events []struct {
776 Action string `json:"action"` 906 Action string `json:"action"`
@@ -789,18 +919,18 @@ func TestRestoreVMUnTombstonesWithinGrace(t *testing.T) {
789 func TestRestoreVMNotRestorableIs409(t *testing.T) { 919 func TestRestoreVMNotRestorableIs409(t *testing.T) {
790 ts, _, _ := testServer(t) 920 ts, _, _ := testServer(t)
791 out := enroll(t, ts) 921 out := enroll(t, ts)
792 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 922 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
793 map[string]any{"host_id": out["host_id"], "name": "web"}) 923 map[string]any{"host_id": out["host_id"], "name": "web"})
794 require.Equal(t, 201, resp.StatusCode) 924 require.Equal(t, 201, resp.StatusCode)
795 var created map[string]string 925 var created map[string]string
796 json.NewDecoder(resp.Body).Decode(&created) 926 json.NewDecoder(resp.Body).Decode(&created)
797 927
798 // Never deleted → not restorable. 928 // Never deleted → not restorable.
799 resp = do(t, "POST", ts.URL+"/api/v1/vms/"+created["id"]+"/restore", "admintok", nil) 929 resp = do(t, "POST", ts.URL+"/api/v1/vms/"+created["id"]+"/restore", testPAT, nil)
800 assert.Equal(t, 409, resp.StatusCode) 930 assert.Equal(t, 409, resp.StatusCode)
801 931
802 // Non-existent id → same "not restorable". 932 // Non-existent id → same "not restorable".
803 resp = do(t, "POST", ts.URL+"/api/v1/vms/does-not-exist/restore", "admintok", nil) 933 resp = do(t, "POST", ts.URL+"/api/v1/vms/does-not-exist/restore", testPAT, nil)
804 assert.Equal(t, 409, resp.StatusCode) 934 assert.Equal(t, 409, resp.StatusCode)
805 } 935 }
806 936
@@ -814,7 +944,7 @@ func TestVMResponseSurfacesTeardownDestroyDeadline(t *testing.T) {
814 944
815 // Two VMs on the host: one will be quarantined for teardown, one stays live. 945 // Two VMs on the host: one will be quarantined for teardown, one stays live.
816 for _, name := range []string{"doomed", "healthy"} { 946 for _, name := range []string{"doomed", "healthy"} {
817 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 947 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
818 map[string]any{"host_id": hostID, "name": name}) 948 map[string]any{"host_id": hostID, "name": name})
819 require.Equal(t, 201, resp.StatusCode) 949 require.Equal(t, 201, resp.StatusCode)
820 } 950 }
@@ -839,7 +969,7 @@ func TestVMResponseSurfacesTeardownDestroyDeadline(t *testing.T) {
839 Quarantined: []registry.QuarantinedVM{{VMID: doomedID, DestroyAtUnix: deadline}}, 969 Quarantined: []registry.QuarantinedVM{{VMID: doomedID, DestroyAtUnix: deadline}},
840 }) 970 })
841 971
842 resp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) 972 resp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
843 require.Equal(t, 200, resp.StatusCode) 973 require.Equal(t, 200, resp.StatusCode)
844 items := decodeJSONKeys(t, resp) 974 items := decodeJSONKeys(t, resp)
845 require.Len(t, items, 2) 975 require.Len(t, items, 2)
internal/server/api/auth.go
Old New
@@ -0,0 +1,321 @@
1 package api
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "log/slog"
9 "net/http"
10 "strings"
11 "sync"
12 "time"
13
14 oidc "github.com/coreos/go-oidc/v3/oidc"
15 "golang.org/x/oauth2"
16
17 "github.com/a73x/eitri/internal/server/store"
18 )
19
20 // OIDCConfig mirrors config.OIDC into the api package so this package need not
21 // import internal/server/config — matching how AdminToken and the advertise
22 // addresses are passed as plain Config fields. main.go copies the config.OIDC
23 // block into this shape.
24 type OIDCConfig struct {
25 Issuer string
26 ClientID string
27 ClientSecret string // external confidential clients only; empty for a PKCE public client
28 PublicURL string
29 AllowedDomains []string // optional signup gate (case-insensitive suffix after '@')
30 AllowedIdentities []string // optional signup gate (case-insensitive full email)
31 }
32
33 // Cookie names and lifetimes for the sign-in flow.
34 const (
35 sessionCookie = "eitri_session"
36 stateCookie = "eitri_oauth_state"
37 verifierCookie = "eitri_oauth_verifier"
38 oauthTempTTL = 10 * time.Minute
39 sessionTTL = 30 * 24 * time.Hour
40 )
41
42 // errSignupNotAllowed is the sentinel resolveTenant returns when the signup gate
43 // turns an identity away — the handler maps it to a 403 with no tenant created.
44 var errSignupNotAllowed = errors.New("signup not allowed")
45
46 // authFlow owns /auth/login, /auth/callback, /auth/logout: the OIDC relying-party
47 // sign-in. These are browser redirect endpoints, deliberately OUTSIDE the JSON
48 // route table (spec §3) and NOT behind the API auth middleware — they are how a
49 // session is established in the first place.
50 //
51 // eitri-server is a pure relying party: it holds no passwords and no identity-
52 // signing key. The issuer is sometimes the bundled eitri-oidc next door and
53 // sometimes an external IdP; go-oidc treats them identically.
54 type authFlow struct {
55 st *store.Store
56 cfg OIDCConfig
57
58 // OIDC discovery is LAZY: the provider is fetched on the first request and
59 // cached. This lets eitri-server start before eitri-oidc — the systemd
60 // ordering does not force the issuer to be reachable at boot. Config *shape*
61 // is validated at boot (cmd/eitri-server); issuer *reachability* is fail-soft
62 // here, so a discovery failure re-tries on the next request rather than
63 // wedging a started server.
64 mu sync.Mutex
65 provider *oidc.Provider
66 verifier *oidc.IDTokenVerifier
67 oauth oauth2.Config
68 }
69
70 // ensureProvider performs OIDC discovery against cfg.Issuer once and caches the
71 // verifier + oauth2 config. Safe under concurrent callers; on failure it leaves
72 // the fields nil so a later request retries (the issuer may not be up yet).
73 func (af *authFlow) ensureProvider(ctx context.Context) error {
74 af.mu.Lock()
75 defer af.mu.Unlock()
76 if af.provider != nil {
77 return nil
78 }
79 if af.cfg.Issuer == "" {
80 return errors.New("oidc issuer not configured")
81 }
82 prov, err := oidc.NewProvider(ctx, af.cfg.Issuer)
83 if err != nil {
84 return err
85 }
86 af.provider = prov
87 af.verifier = prov.Verifier(&oidc.Config{ClientID: af.cfg.ClientID})
88 endpoint := prov.Endpoint()
89 if af.cfg.ClientSecret == "" {
90 // Public PKCE client: there is no client authentication, so the token
91 // request must carry client_id in the body. Pin AuthStyleInParams to stop
92 // oauth2's autodetect from first probing HTTP Basic — the bundled
93 // eitri-oidc answers that probe by consuming the single-use code before
94 // rejecting the empty secret, so the autodetect retry would then fail with
95 // "invalid code". A confidential client (secret set) leaves autodetect on,
96 // since external IdPs vary in the client-auth style they accept.
97 endpoint.AuthStyle = oauth2.AuthStyleInParams
98 }
99 af.oauth = oauth2.Config{
100 ClientID: af.cfg.ClientID,
101 ClientSecret: af.cfg.ClientSecret,
102 Endpoint: endpoint,
103 RedirectURL: strings.TrimRight(af.cfg.PublicURL, "/") + "/auth/callback",
104 Scopes: []string{oidc.ScopeOpenID, "email"},
105 }
106 return nil
107 }
108
109 // secure reports whether cookies should carry the Secure flag: any real
110 // deployment sets an https public_url; plain http is tolerated only for the
111 // single-box quickstart, where the browser and server share localhost.
112 func (af *authFlow) secure() bool { return strings.HasPrefix(af.cfg.PublicURL, "https://") }
113
114 // handleLogin mints a state + PKCE verifier, stashes both in short-lived
115 // HttpOnly cookies, and redirects the browser to the issuer's authorize endpoint.
116 func (af *authFlow) handleLogin(w http.ResponseWriter, r *http.Request) {
117 if err := af.ensureProvider(r.Context()); err != nil {
118 slog.Warn("oidc discovery failed", "err", err)
119 http.Error(w, "sign-in temporarily unavailable", http.StatusServiceUnavailable)
120 return
121 }
122 state, err := randomHex(16) // 32 hex chars
123 if err != nil {
124 http.Error(w, "internal error", http.StatusInternalServerError)
125 return
126 }
127 verifier := oauth2.GenerateVerifier()
128 af.setTempCookie(w, stateCookie, state)
129 af.setTempCookie(w, verifierCookie, verifier)
130 http.Redirect(w, r, af.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound)
131 }
132
133 // handleCallback verifies the state, exchanges the code, verifies the id_token,
134 // resolves the identity to a tenant, mints a session, and redirects to "/".
135 func (af *authFlow) handleCallback(w http.ResponseWriter, r *http.Request) {
136 if err := af.ensureProvider(r.Context()); err != nil {
137 slog.Warn("oidc discovery failed", "err", err)
138 http.Error(w, "sign-in temporarily unavailable", http.StatusServiceUnavailable)
139 return
140 }
141 // Opportunistic session-table hygiene: a cheap DELETE on the sign-in path
142 // keeps the table bounded without a background goroutine (expiry is already
143 // enforced on read, so this is pure hygiene).
144 if _, err := af.st.ReapSessions(); err != nil {
145 slog.Warn("reap sessions failed", "err", err)
146 }
147
148 // State must round-trip through the cookie set at /auth/login (CSRF guard):
149 // a missing or mismatched value is rejected before any token exchange.
150 stateCk, err := r.Cookie(stateCookie)
151 if err != nil || stateCk.Value == "" || r.URL.Query().Get("state") != stateCk.Value {
152 http.Error(w, "invalid oauth state", http.StatusBadRequest)
153 return
154 }
155 verifierCk, err := r.Cookie(verifierCookie)
156 if err != nil || verifierCk.Value == "" {
157 http.Error(w, "invalid oauth state", http.StatusBadRequest)
158 return
159 }
160
161 tok, err := af.oauth.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifierCk.Value))
162 if err != nil {
163 slog.Warn("oauth code exchange failed", "err", err)
164 http.Error(w, "sign-in failed", http.StatusBadGateway)
165 return
166 }
167 rawID, ok := tok.Extra("id_token").(string)
168 if !ok || rawID == "" {
169 http.Error(w, "identity provider returned no id_token", http.StatusBadGateway)
170 return
171 }
172 idToken, err := af.verifier.Verify(r.Context(), rawID)
173 if err != nil {
174 slog.Warn("id_token verification failed", "err", err)
175 http.Error(w, "sign-in failed", http.StatusForbidden)
176 return
177 }
178 var claims struct {
179 Email string `json:"email"`
180 EmailVerified bool `json:"email_verified"`
181 }
182 if err := idToken.Claims(&claims); err != nil {
183 http.Error(w, "internal error", http.StatusInternalServerError)
184 return
185 }
186 if claims.Email == "" {
187 // The spec's claims contract requires an email; an issuer that omits it
188 // cannot be used for sign-in (identity display + allowlist matching).
189 http.Error(w, "identity provider returned no email", http.StatusForbidden)
190 return
191 }
192 if !claims.EmailVerified {
193 // The claims contract requires a VERIFIED email: the address is the
194 // identity key for JIT tenants and the allowlist match, so an issuer
195 // that hasn't verified it (claim false or absent) cannot vouch for it.
196 http.Error(w, "identity provider has not verified this email", http.StatusForbidden)
197 return
198 }
199
200 tenant, err := af.resolveTenant(idToken.Issuer, idToken.Subject, claims.Email)
201 if err != nil {
202 if errors.Is(err, errSignupNotAllowed) {
203 http.Error(w, "not authorized for this server", http.StatusForbidden)
204 return
205 }
206 slog.Error("resolve tenant failed", "err", err)
207 http.Error(w, "internal error", http.StatusInternalServerError)
208 return
209 }
210
211 sid, err := af.st.CreateSession(tenant, sessionTTL)
212 if err != nil {
213 http.Error(w, "internal error", http.StatusInternalServerError)
214 return
215 }
216 http.SetCookie(w, &http.Cookie{
217 Name: sessionCookie,
218 Value: sid,
219 Path: "/",
220 HttpOnly: true,
221 SameSite: http.SameSiteLaxMode,
222 Secure: af.secure(),
223 MaxAge: int(sessionTTL.Seconds()),
224 })
225 af.clearCookie(w, stateCookie)
226 af.clearCookie(w, verifierCookie)
227 http.Redirect(w, r, "/", http.StatusFound)
228 }
229
230 // resolveTenant maps a verified (issuer, subject, email) to a tenant id, in the
231 // exact order the spec mandates.
232 func (af *authFlow) resolveTenant(issuer, subject, email string) (string, error) {
233 // 1. An existing binding wins — a returning user — and bypasses the signup
234 // gate (the identity was admitted once already).
235 if tn, ok, err := af.st.TenantByIdentity(issuer, subject); err != nil {
236 return "", err
237 } else if ok {
238 return tn.ID, nil
239 }
240 // 2. Signup gate: when either allowlist is non-empty the identity must match
241 // one of them, or it is turned away with NO tenant created.
242 if !af.signupAllowed(email) {
243 return "", errSignupNotAllowed
244 }
245 // 3. JIT: a new identity gets a fresh tenant with a handle derived from email.
246 tn, err := af.st.CreateTenantForIdentity(issuer, subject, email)
247 if err != nil {
248 return "", err
249 }
250 return tn.ID, nil
251 }
252
253 // signupAllowed applies the optional signup gate. Both lists empty ⇒ open
254 // signup. An exact identity match (case-insensitive full email) or a domain
255 // match (case-insensitive, the part after '@') admits the identity.
256 func (af *authFlow) signupAllowed(email string) bool {
257 if len(af.cfg.AllowedDomains) == 0 && len(af.cfg.AllowedIdentities) == 0 {
258 return true
259 }
260 for _, id := range af.cfg.AllowedIdentities {
261 if strings.EqualFold(email, id) {
262 return true
263 }
264 }
265 domain := ""
266 if i := strings.LastIndexByte(email, '@'); i >= 0 {
267 domain = email[i+1:]
268 }
269 for _, d := range af.cfg.AllowedDomains {
270 if domain != "" && strings.EqualFold(domain, d) {
271 return true
272 }
273 }
274 return false
275 }
276
277 // handleLogout deletes the session row and clears the cookie. The SPA calls this
278 // via fetch and handles navigation itself, so a bodyless 204 is cleaner than
279 // forcing a redirect on a fetch caller.
280 func (af *authFlow) handleLogout(w http.ResponseWriter, r *http.Request) {
281 if ck, err := r.Cookie(sessionCookie); err == nil && ck.Value != "" {
282 if err := af.st.DeleteSession(ck.Value); err != nil {
283 slog.Warn("delete session failed", "err", err)
284 }
285 }
286 af.clearCookie(w, sessionCookie)
287 w.WriteHeader(http.StatusNoContent)
288 }
289
290 func (af *authFlow) setTempCookie(w http.ResponseWriter, name, value string) {
291 http.SetCookie(w, &http.Cookie{
292 Name: name,
293 Value: value,
294 Path: "/",
295 HttpOnly: true,
296 SameSite: http.SameSiteLaxMode,
297 Secure: af.secure(),
298 MaxAge: int(oauthTempTTL.Seconds()),
299 })
300 }
301
302 func (af *authFlow) clearCookie(w http.ResponseWriter, name string) {
303 http.SetCookie(w, &http.Cookie{
304 Name: name,
305 Value: "",
306 Path: "/",
307 HttpOnly: true,
308 SameSite: http.SameSiteLaxMode,
309 Secure: af.secure(),
310 MaxAge: -1,
311 })
312 }
313
314 // randomHex returns n random bytes as a 2n-char lowercase hex string.
315 func randomHex(n int) (string, error) {
316 b := make([]byte, n)
317 if _, err := rand.Read(b); err != nil {
318 return "", err
319 }
320 return hex.EncodeToString(b), nil
321 }
internal/server/api/auth_test.go
Old New
@@ -0,0 +1,564 @@
1 package api
2
3 import (
4 "crypto"
5 "crypto/rand"
6 "crypto/rsa"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/json"
10 "net/http"
11 "net/http/cookiejar"
12 "net/http/httptest"
13 "net/url"
14 "path/filepath"
15 "sync"
16 "testing"
17 "time"
18
19 "github.com/a73x/eitri/internal/oidcprovider"
20 "github.com/a73x/eitri/internal/server/hub"
21 "github.com/a73x/eitri/internal/server/registry"
22 "github.com/a73x/eitri/internal/server/store"
23 "github.com/stretchr/testify/assert"
24 "github.com/stretchr/testify/require"
25 )
26
27 const testPassword = "hunter2hunter2"
28
29 type testUser struct{ email, password string }
30
31 // authEnv is a live eitri-server /auth stack wired to a real internal/oidcprovider
32 // issuer — the exact IdP the server discovers through go-oidc in production.
33 type authEnv struct {
34 apiURL string
35 oidcURL string
36 client *http.Client // cookie jar; does NOT auto-follow redirects (we step)
37 st *store.Store
38 usersPath string
39 }
40
41 // sub returns the oidcprovider-assigned subject for email (read from the flat
42 // users file), so a test can look a tenant up by its durable identity key.
43 func (e authEnv) sub(t *testing.T, email string) string {
44 t.Helper()
45 us, err := oidcprovider.LoadUsers(e.usersPath)
46 require.NoError(t, err)
47 for _, u := range us.Users {
48 if u.Email == email {
49 return u.Sub
50 }
51 }
52 t.Fatalf("no such user %q", email)
53 return ""
54 }
55
56 // newAuthEnv stands up the store, an internal/oidcprovider issuer seeded with
57 // users, and the server's /auth handler behind an httptest server (TLS when
58 // tls). mutate tweaks the OIDC config (allowlists).
59 func newAuthEnv(t *testing.T, tls bool, mutate func(*OIDCConfig), users ...testUser) authEnv {
60 t.Helper()
61
62 // The httptest listener binds on construction, so we learn the server's
63 // address (hence PublicURL and the OIDC redirect URL) before the two ends
64 // are wired — breaking the issuer↔RP circular dependency.
65 apiSrv := httptest.NewUnstartedServer(nil)
66 addr := apiSrv.Listener.Addr().String()
67 scheme := "http"
68 if tls {
69 scheme = "https"
70 }
71 publicURL := scheme + "://" + addr
72
73 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
74 require.NoError(t, err)
75 t.Cleanup(func() { st.Close() })
76
77 usersPath := filepath.Join(t.TempDir(), "users.json")
78 for _, u := range users {
79 pw := u.password
80 if pw == "" {
81 pw = testPassword
82 }
83 require.NoError(t, oidcprovider.AddUser(usersPath, u.email, pw))
84 }
85 prov, err := oidcprovider.New(oidcprovider.Config{
86 UsersFile: usersPath,
87 SigningKey: filepath.Join(t.TempDir(), "signing.key"),
88 Clients: []oidcprovider.Client{{ID: "eitri-console", RedirectURL: publicURL + "/auth/callback"}},
89 })
90 require.NoError(t, err)
91 oidcSrv := httptest.NewServer(prov.Handler())
92 t.Cleanup(oidcSrv.Close)
93 prov.SetIssuer(oidcSrv.URL)
94
95 cfg := OIDCConfig{Issuer: oidcSrv.URL, ClientID: "eitri-console", PublicURL: publicURL}
96 if mutate != nil {
97 mutate(&cfg)
98 }
99 client := finishAuthServer(t, apiSrv, st, cfg, tls)
100 return authEnv{apiURL: publicURL, oidcURL: oidcSrv.URL, client: client, st: st, usersPath: usersPath}
101 }
102
103 // finishAuthServer builds the API, mounts /auth on the root mux exactly as
104 // main.go does (outside /api/ and its auth middleware), starts the server, and
105 // returns a stepping client (cookie jar, redirects surfaced not followed).
106 func finishAuthServer(t *testing.T, apiSrv *httptest.Server, st *store.Store, cfg OIDCConfig, tls bool) *http.Client {
107 t.Helper()
108 a := New(Config{HostSecret: []byte("hostsecret"), OIDC: cfg}, st, registry.New(time.Now), hub.New())
109 t.Cleanup(a.Close)
110 root := http.NewServeMux()
111 root.Handle("/auth/", a.AuthHandler())
112 apiSrv.Config.Handler = root
113 if tls {
114 apiSrv.StartTLS()
115 } else {
116 apiSrv.Start()
117 }
118 t.Cleanup(apiSrv.Close)
119
120 jar, err := cookiejar.New(nil)
121 require.NoError(t, err)
122 client := &http.Client{}
123 if tls {
124 client = apiSrv.Client()
125 }
126 client.Jar = jar
127 client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
128 return client
129 }
130
131 // signIn drives the whole interactive code+PKCE flow against an
132 // internal/oidcprovider issuer and returns the final /auth/callback response
133 // (302 → "/" on success). Each hop is stepped so callers can read Set-Cookie.
134 func (e authEnv) signIn(t *testing.T, email, password string) *http.Response {
135 t.Helper()
136 if password == "" {
137 password = testPassword
138 }
139 // 1. /auth/login → 302 to the issuer authorize endpoint (state+verifier set).
140 resp := e.get(t, e.apiURL+"/auth/login")
141 require.Equal(t, http.StatusFound, resp.StatusCode, "login should redirect to issuer")
142 authorizeURL := resp.Header.Get("Location")
143 resp.Body.Close()
144
145 // 2. authorize GET → 200 login form.
146 resp = e.get(t, authorizeURL)
147 require.Equal(t, http.StatusOK, resp.StatusCode)
148 resp.Body.Close()
149
150 // 3. authorize POST credentials → 302 back to /auth/callback with code+state.
151 resp = e.postForm(t, authorizeURL, url.Values{"email": {email}, "password": {password}})
152 require.Equal(t, http.StatusFound, resp.StatusCode)
153 callbackURL := resp.Header.Get("Location")
154 resp.Body.Close()
155
156 // 4. /auth/callback → final auth response.
157 return e.get(t, callbackURL)
158 }
159
160 func (e authEnv) get(t *testing.T, u string) *http.Response {
161 t.Helper()
162 resp, err := e.client.Get(u)
163 require.NoError(t, err)
164 return resp
165 }
166
167 func (e authEnv) postForm(t *testing.T, u string, v url.Values) *http.Response {
168 t.Helper()
169 resp, err := e.client.PostForm(u, v)
170 require.NoError(t, err)
171 return resp
172 }
173
174 func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
175 for _, c := range cookies {
176 if c.Name == name {
177 return c
178 }
179 }
180 return nil
181 }
182
183 func TestAuthHappyPathSetsSessionCookie(t *testing.T) {
184 for _, tc := range []struct {
185 name string
186 tls bool
187 }{{"http", false}, {"https", true}} {
188 t.Run(tc.name, func(t *testing.T) {
189 env := newAuthEnv(t, tc.tls, nil, testUser{email: "alex@example.com"})
190 resp := env.signIn(t, "alex@example.com", "")
191 defer resp.Body.Close()
192
193 require.Equal(t, http.StatusFound, resp.StatusCode)
194 assert.Equal(t, "/", resp.Header.Get("Location"))
195
196 sess := cookieByName(resp.Cookies(), "eitri_session")
197 require.NotNil(t, sess, "callback must set eitri_session")
198 assert.True(t, sess.HttpOnly, "session cookie must be HttpOnly")
199 assert.Equal(t, http.SameSiteLaxMode, sess.SameSite)
200 assert.Equal(t, tc.tls, sess.Secure, "Secure must follow the public_url scheme")
201
202 // The temp oauth cookies must be cleared on success.
203 cleared := cookieByName(resp.Cookies(), "eitri_oauth_state")
204 require.NotNil(t, cleared)
205 assert.True(t, cleared.MaxAge < 0, "oauth state cookie must be cleared")
206
207 tenant, ok, err := env.st.SessionTenant(sess.Value)
208 require.NoError(t, err)
209 require.True(t, ok, "session row must resolve to a tenant")
210 assert.Equal(t, "alex", tenant, "JIT handle derives from the email local part")
211 })
212 }
213 }
214
215 func TestAuthJITReuseNoDuplicate(t *testing.T) {
216 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
217
218 first := env.signIn(t, "alex@example.com", "")
219 first.Body.Close()
220 firstTenant, ok, err := env.st.SessionTenant(cookieByName(first.Cookies(), "eitri_session").Value)
221 require.NoError(t, err)
222 require.True(t, ok)
223
224 second := env.signIn(t, "alex@example.com", "")
225 second.Body.Close()
226 secondTenant, ok, err := env.st.SessionTenant(cookieByName(second.Cookies(), "eitri_session").Value)
227 require.NoError(t, err)
228 require.True(t, ok)
229
230 assert.Equal(t, "alex", firstTenant)
231 assert.Equal(t, firstTenant, secondTenant, "second sign-in must reuse the tenant, not create a new one")
232
233 // The identity binding still points at 'alex' — no 'alex-2' was minted.
234 tn, ok, err := env.st.TenantByIdentity(env.oidcURL, env.sub(t, "alex@example.com"))
235 require.NoError(t, err)
236 require.True(t, ok)
237 assert.Equal(t, "alex", tn.ID)
238 }
239
240 func TestAuthHandleCollision(t *testing.T) {
241 env := newAuthEnv(t, false, nil,
242 testUser{email: "alex@example.com"},
243 testUser{email: "alex@other.com"})
244
245 first := env.signIn(t, "alex@example.com", "")
246 first.Body.Close()
247 firstTenant, _, err := env.st.SessionTenant(cookieByName(first.Cookies(), "eitri_session").Value)
248 require.NoError(t, err)
249
250 second := env.signIn(t, "alex@other.com", "")
251 second.Body.Close()
252 secondTenant, _, err := env.st.SessionTenant(cookieByName(second.Cookies(), "eitri_session").Value)
253 require.NoError(t, err)
254
255 assert.Equal(t, "alex", firstTenant)
256 assert.Equal(t, "alex-2", secondTenant, "colliding local part gets a numeric suffix")
257 }
258
259 func TestAuthSignupGateRejectsUnlistedDomain(t *testing.T) {
260 env := newAuthEnv(t, false, func(c *OIDCConfig) {
261 c.AllowedDomains = []string{"example.com"}
262 },
263 testUser{email: "carol@evil.org"},
264 testUser{email: "bob@example.com"})
265
266 // Rejected: 403, no session, and NO tenant row created.
267 rejected := env.signIn(t, "carol@evil.org", "")
268 defer rejected.Body.Close()
269 assert.Equal(t, http.StatusForbidden, rejected.StatusCode)
270 assert.Nil(t, cookieByName(rejected.Cookies(), "eitri_session"))
271 _, ok, err := env.st.TenantByIdentity(env.oidcURL, env.sub(t, "carol@evil.org"))
272 require.NoError(t, err)
273 assert.False(t, ok, "a gated-out identity must not create a tenant")
274
275 // Allowed by domain: succeeds.
276 ok2 := env.signIn(t, "bob@example.com", "")
277 defer ok2.Body.Close()
278 assert.Equal(t, http.StatusFound, ok2.StatusCode)
279 require.NotNil(t, cookieByName(ok2.Cookies(), "eitri_session"))
280 }
281
282 func TestAuthSignupGateAllowsExactIdentity(t *testing.T) {
283 env := newAuthEnv(t, false, func(c *OIDCConfig) {
284 c.AllowedIdentities = []string{"carol@evil.org"}
285 },
286 testUser{email: "carol@evil.org"},
287 testUser{email: "dave@evil.org"})
288
289 // The listed identity is admitted even though its domain is not listed.
290 ok := env.signIn(t, "carol@evil.org", "")
291 defer ok.Body.Close()
292 assert.Equal(t, http.StatusFound, ok.StatusCode)
293 require.NotNil(t, cookieByName(ok.Cookies(), "eitri_session"))
294
295 // A different identity on the same domain is still rejected.
296 rejected := env.signIn(t, "dave@evil.org", "")
297 defer rejected.Body.Close()
298 assert.Equal(t, http.StatusForbidden, rejected.StatusCode)
299 }
300
301 func TestAuthSignupGateOpenWhenUnset(t *testing.T) {
302 env := newAuthEnv(t, false, nil, testUser{email: "anyone@wherever.net"})
303 resp := env.signIn(t, "anyone@wherever.net", "")
304 defer resp.Body.Close()
305 assert.Equal(t, http.StatusFound, resp.StatusCode)
306 require.NotNil(t, cookieByName(resp.Cookies(), "eitri_session"))
307 }
308
309 func TestAuthLogoutRevokesSession(t *testing.T) {
310 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
311 resp := env.signIn(t, "alex@example.com", "")
312 resp.Body.Close()
313 sid := cookieByName(resp.Cookies(), "eitri_session").Value
314
315 // The jar carries the session cookie; logout deletes the row and clears it.
316 out, err := env.client.Post(env.apiURL+"/auth/logout", "", nil)
317 require.NoError(t, err)
318 defer out.Body.Close()
319 assert.Equal(t, http.StatusNoContent, out.StatusCode)
320 cleared := cookieByName(out.Cookies(), "eitri_session")
321 require.NotNil(t, cleared)
322 assert.True(t, cleared.MaxAge < 0)
323
324 _, ok, err := env.st.SessionTenant(sid)
325 require.NoError(t, err)
326 assert.False(t, ok, "session must no longer resolve after logout")
327 }
328
329 func TestAuthCallbackStateMismatch(t *testing.T) {
330 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
331
332 // Prime the state cookie via /auth/login, then hit the callback with a
333 // mismatched state query value.
334 resp := env.get(t, env.apiURL+"/auth/login")
335 resp.Body.Close()
336 require.Equal(t, http.StatusFound, resp.StatusCode)
337
338 bad := env.get(t, env.apiURL+"/auth/callback?state=wrong&code=whatever")
339 defer bad.Body.Close()
340 assert.Equal(t, http.StatusBadRequest, bad.StatusCode)
341 }
342
343 func TestAuthCallbackMissingStateCookie(t *testing.T) {
344 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
345 // Fresh client, no prior /auth/login: there is no state cookie to match.
346 jar, _ := cookiejar.New(nil)
347 env.client.Jar = jar
348 bad := env.get(t, env.apiURL+"/auth/callback?state=x&code=y")
349 defer bad.Body.Close()
350 assert.Equal(t, http.StatusBadRequest, bad.StatusCode)
351 }
352
353 // --- hand-rolled stub issuer: cases internal/oidcprovider can't produce ---
354
355 // stubIssuer is a hand-rolled OIDC issuer that auto-approves authorize (no login
356 // form) and mints a validly-signed id_token. It is parameterized for the two
357 // external-IdP cases oidcprovider cannot cover:
358 // - email == "" mints an id_token WITHOUT an email claim (oidcprovider always
359 // sets one), proving the server rejects an emailless identity.
360 // - requireSecret != "" makes /token demand that client_secret (a confidential
361 // client), proving the secret actually flows on the external-IdP path.
362 type stubIssuer struct {
363 url string
364 key *rsa.PrivateKey
365 kid string
366 email string // included as the email claim when non-empty
367 requireSecret string // when non-empty, /token demands this client_secret
368 unverifiedEmail bool // emit email_verified:false instead of true
369
370 mu sync.Mutex
371 observedSecret string // client_secret the token endpoint actually received
372 }
373
374 // newStubIssuer starts the stub. email is the email claim to mint ("" omits it);
375 // requireSecret makes the token endpoint a confidential client demanding that
376 // secret ("" leaves it a public client).
377 func newStubIssuer(t *testing.T, email, requireSecret string) *stubIssuer {
378 t.Helper()
379 key, err := rsa.GenerateKey(rand.Reader, 2048)
380 require.NoError(t, err)
381 s := &stubIssuer{key: key, kid: "stub", email: email, requireSecret: requireSecret}
382
383 mux := http.NewServeMux()
384 mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
385 writeStubJSON(w, map[string]any{
386 "issuer": s.url,
387 "authorization_endpoint": s.url + "/authorize",
388 "token_endpoint": s.url + "/token",
389 "jwks_uri": s.url + "/jwks.json",
390 "response_types_supported": []string{"code"},
391 "subject_types_supported": []string{"public"},
392 "id_token_signing_alg_values_supported": []string{"RS256"},
393 })
394 })
395 mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
396 q := r.URL.Query()
397 redirect, _ := url.Parse(q.Get("redirect_uri"))
398 rq := redirect.Query()
399 rq.Set("code", "stubcode")
400 rq.Set("state", q.Get("state"))
401 redirect.RawQuery = rq.Encode()
402 http.Redirect(w, r, redirect.String(), http.StatusFound)
403 })
404 mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
405 if s.requireSecret != "" {
406 // x/oauth2's autodetect probes HTTP Basic first (RFC 6749 §2.3.1
407 // client_secret_basic); accept that, and fall back to the form-post
408 // style. A missing/wrong secret is a 401 so the test proves the secret
409 // actually reached the issuer.
410 _, secret, ok := r.BasicAuth()
411 if !ok {
412 secret = r.FormValue("client_secret")
413 }
414 s.mu.Lock()
415 s.observedSecret = secret
416 s.mu.Unlock()
417 if secret != s.requireSecret {
418 http.Error(w, "invalid client", http.StatusUnauthorized)
419 return
420 }
421 }
422 writeStubJSON(w, map[string]any{
423 "access_token": "stub-access",
424 "token_type": "Bearer",
425 "id_token": s.idToken(),
426 "expires_in": 300,
427 })
428 })
429 mux.HandleFunc("/jwks.json", func(w http.ResponseWriter, r *http.Request) {
430 pub := s.key.PublicKey
431 writeStubJSON(w, map[string]any{"keys": []map[string]any{{
432 "kty": "RSA", "alg": "RS256", "use": "sig", "kid": s.kid,
433 "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
434 "e": base64.RawURLEncoding.EncodeToString([]byte{0x01, 0x00, 0x01}), // 65537
435 }}})
436 })
437
438 srv := httptest.NewServer(mux)
439 t.Cleanup(srv.Close)
440 s.url = srv.URL
441 return s
442 }
443
444 // secretSeen returns the client_secret the token endpoint last received.
445 func (s *stubIssuer) secretSeen() string {
446 s.mu.Lock()
447 defer s.mu.Unlock()
448 return s.observedSecret
449 }
450
451 // idToken builds a compact RS256 JWS with iss/sub/aud/iat/exp, adding the email
452 // claim only when the stub was configured with one.
453 func (s *stubIssuer) idToken() string {
454 seg := func(v any) string {
455 b, _ := json.Marshal(v)
456 return base64.RawURLEncoding.EncodeToString(b)
457 }
458 now := time.Now()
459 claims := map[string]any{
460 "iss": s.url, "sub": "stubuser", "aud": "eitri-console",
461 "iat": now.Unix(), "exp": now.Add(5 * time.Minute).Unix(),
462 }
463 claims["email_verified"] = !s.unverifiedEmail
464 if s.email != "" {
465 claims["email"] = s.email
466 }
467 signing := seg(map[string]any{"alg": "RS256", "typ": "JWT", "kid": s.kid}) + "." + seg(claims)
468 h := sha256.Sum256([]byte(signing))
469 sig, _ := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:])
470 return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
471 }
472
473 func writeStubJSON(w http.ResponseWriter, v any) {
474 w.Header().Set("Content-Type", "application/json")
475 _ = json.NewEncoder(w).Encode(v)
476 }
477
478 // newStubEnv wires the server's /auth handler to a stub issuer and returns the
479 // public URL, a stepping client, and the store. clientSecret configures the RP
480 // side (non-empty ⇒ confidential client, so auth.go leaves oauth2 autodetect on).
481 func newStubEnv(t *testing.T, stub *stubIssuer, clientSecret string) (string, *http.Client, *store.Store) {
482 t.Helper()
483 apiSrv := httptest.NewUnstartedServer(nil)
484 publicURL := "http://" + apiSrv.Listener.Addr().String()
485 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
486 require.NoError(t, err)
487 t.Cleanup(func() { st.Close() })
488 client := finishAuthServer(t, apiSrv, st,
489 OIDCConfig{Issuer: stub.url, ClientID: "eitri-console", ClientSecret: clientSecret, PublicURL: publicURL}, false)
490 return publicURL, client, st
491 }
492
493 // driveStub steps the auto-approving stub flow (login → authorize → callback)
494 // and returns the final /auth/callback response.
495 func driveStub(t *testing.T, client *http.Client, publicURL string) *http.Response {
496 t.Helper()
497 resp, err := client.Get(publicURL + "/auth/login")
498 require.NoError(t, err)
499 authorizeURL := resp.Header.Get("Location")
500 resp.Body.Close()
501 require.Equal(t, http.StatusFound, resp.StatusCode)
502
503 resp, err = client.Get(authorizeURL)
504 require.NoError(t, err)
505 callbackURL := resp.Header.Get("Location")
506 resp.Body.Close()
507 require.Equal(t, http.StatusFound, resp.StatusCode)
508
509 resp, err = client.Get(callbackURL)
510 require.NoError(t, err)
511 return resp
512 }
513
514 func TestAuthCallbackNoEmailClaim(t *testing.T) {
515 stub := newStubIssuer(t, "", "") // no email claim, public client
516 publicURL, client, _ := newStubEnv(t, stub, "")
517
518 resp := driveStub(t, client, publicURL)
519 defer resp.Body.Close()
520 assert.Equal(t, http.StatusForbidden, resp.StatusCode, "an id_token with no email must be rejected")
521 }
522
523 // TestAuthCallbackUnverifiedEmailRejected pins the claims contract: an issuer
524 // that has not verified the address (email_verified false or absent) must not
525 // mint an identity — an unverified Google address could otherwise pass a
526 // domain allowlist it doesn't own.
527 func TestAuthCallbackUnverifiedEmailRejected(t *testing.T) {
528 stub := newStubIssuer(t, "sneak@corp.example", "")
529 stub.unverifiedEmail = true
530 publicURL, client, st := newStubEnv(t, stub, "")
531
532 resp := driveStub(t, client, publicURL)
533 defer resp.Body.Close()
534 assert.Equal(t, http.StatusForbidden, resp.StatusCode, "unverified email must be rejected")
535
536 _, ok, err := st.TenantByIdentity(stub.url, "stubuser")
537 require.NoError(t, err)
538 assert.False(t, ok, "no tenant may be JIT-provisioned for an unverified email")
539 }
540
541 // TestAuthConfidentialClientExternalIdP covers the client_secret branch: against
542 // an external confidential IdP the server keeps oauth2 autodetect on (no
543 // AuthStyleInParams pin) and must present the secret at the token endpoint. The
544 // stub 401s the exchange unless the correct secret arrives, so a minted session
545 // proves the secret actually flowed.
546 func TestAuthConfidentialClientExternalIdP(t *testing.T) {
547 const secret = "s3cr3t-confidential"
548 stub := newStubIssuer(t, "external@corp.example", secret)
549 publicURL, client, st := newStubEnv(t, stub, secret)
550
551 resp := driveStub(t, client, publicURL)
552 defer resp.Body.Close()
553 require.Equal(t, http.StatusFound, resp.StatusCode)
554 assert.Equal(t, "/", resp.Header.Get("Location"))
555
556 sess := cookieByName(resp.Cookies(), "eitri_session")
557 require.NotNil(t, sess, "confidential-client sign-in must mint a session")
558 tenant, ok, err := st.SessionTenant(sess.Value)
559 require.NoError(t, err)
560 require.True(t, ok)
561 assert.Equal(t, "external", tenant, "JIT handle derives from the email local part")
562
563 assert.Equal(t, secret, stub.secretSeen(), "the client_secret must reach the issuer's token endpoint")
564 }
internal/server/api/client/client.go
Old New
@@ -27,10 +27,12 @@ import (
27 27
28 // Wire-contract aliases, so consumers don't import the types package. 28 // Wire-contract aliases, so consumers don't import the types package.
29 type ( 29 type (
30 Host = types.Host 30 Host = types.Host
31 VM = types.VM 31 VM = types.VM
32 CreateVMRequest = types.CreateVMRequest 32 CreateVMRequest = types.CreateVMRequest
33 CreateVMResponse = types.CreateVMResponse 33 CreateVMResponse = types.CreateVMResponse
34 Me = types.Me
35 CreateAPITokenResponse = types.CreateAPITokenResponse
34 ) 36 )
35 37
36 // Client calls the eitri API at BaseURL, authenticating with Token (sent as a 38 // Client calls the eitri API at BaseURL, authenticating with Token (sent as a
@@ -135,6 +137,24 @@ func (c *Client) DeleteVM(ctx context.Context, id string) error {
135 return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil) 137 return c.do(ctx, http.MethodDelete, "/api/v1/vms/"+url.PathEscape(id), nil, nil)
136 } 138 }
137 139
140 // Me returns the signed-in identity (tenant handle + bound email) for the
141 // credential this client carries. It takes no context — the consumers (smoke
142 // gate, CLI) call it as a quick synchronous probe; the do timeout bounds it.
143 func (c *Client) Me() (Me, error) {
144 var out Me
145 return out, c.do(context.Background(), http.MethodGet, "/api/v1/me", nil, &out)
146 }
147
148 // CreateAPIToken mints a personal access token named name with the given TTL
149 // (0 = non-expiring); the returned secret is shown exactly once. The CI gate
150 // signs in with a session and mints a short-lived PAT through this method to
151 // authenticate the rest of its run.
152 func (c *Client) CreateAPIToken(name string, ttl time.Duration) (CreateAPITokenResponse, error) {
153 var out CreateAPITokenResponse
154 req := types.CreateAPITokenRequest{Name: name, TTLSeconds: int64(ttl / time.Second)}
155 return out, c.do(context.Background(), http.MethodPost, "/api/v1/tokens", req, &out)
156 }
157
138 // FetchSSHCALine retrieves the eitri host-CA public key as the VERBATIM 158 // FetchSSHCALine retrieves the eitri host-CA public key as the VERBATIM
139 // authorized_keys line the server serves — trailing comment and all — after 159 // authorized_keys line the server serves — trailing comment and all — after
140 // parse-validating it (never hand back a line ssh can't read). A 404 means 160 // parse-validating it (never hand back a line ssh can't read). A 404 means
internal/server/api/client/client_test.go
Old New
@@ -9,6 +9,7 @@ import (
9 "net/http/httptest" 9 "net/http/httptest"
10 "strings" 10 "strings"
11 "testing" 11 "testing"
12 "time"
12 13
13 "golang.org/x/crypto/ssh" 14 "golang.org/x/crypto/ssh"
14 15
@@ -209,6 +210,71 @@ func TestContextCancellationAborts(t *testing.T) {
209 } 210 }
210 } 211 }
211 212
213 func TestMe(t *testing.T) {
214 var cap capture
215 srv := serve(t, &cap, http.StatusOK, `{"email":"alex@emery.xyz","tenant":"alex"}`)
216 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
217
218 me, err := c.Me()
219 if err != nil {
220 t.Fatalf("Me: %v", err)
221 }
222 if cap.method != http.MethodGet || cap.path != "/api/v1/me" {
223 t.Errorf("request = %s %s, want GET /api/v1/me", cap.method, cap.path)
224 }
225 if cap.auth != "Bearer tok" {
226 t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
227 }
228 if me.Email != "alex@emery.xyz" || me.Tenant != "alex" {
229 t.Errorf("me = %+v, want alex@emery.xyz / alex", me)
230 }
231 }
232
233 func TestCreateAPIToken(t *testing.T) {
234 var cap capture
235 srv := serve(t, &cap, http.StatusCreated,
236 `{"expires_at":"2026-07-27T13:00:00Z","id":"tok-1","name":"boot-gate","token":"eitri_pat_secret"}`)
237 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
238
239 out, err := c.CreateAPIToken("boot-gate", time.Hour)
240 if err != nil {
241 t.Fatalf("CreateAPIToken: %v", err)
242 }
243 if cap.method != http.MethodPost || cap.path != "/api/v1/tokens" {
244 t.Errorf("request = %s %s, want POST /api/v1/tokens", cap.method, cap.path)
245 }
246 if cap.ctype != "application/json" {
247 t.Errorf("Content-Type = %q, want application/json", cap.ctype)
248 }
249 var req types.CreateAPITokenRequest
250 if err := json.Unmarshal(cap.body, &req); err != nil {
251 t.Fatalf("request body did not decode as CreateAPITokenRequest: %v", err)
252 }
253 if req.Name != "boot-gate" || req.TTLSeconds != 3600 {
254 t.Errorf("request body = %+v, want name boot-gate / 3600s", req)
255 }
256 if out.Token != "eitri_pat_secret" || out.ID != "tok-1" || out.ExpiresAt != "2026-07-27T13:00:00Z" {
257 t.Errorf("response = %+v, want the minted secret/id/expiry", out)
258 }
259 }
260
261 func TestCreateAPITokenNonExpiring(t *testing.T) {
262 var cap capture
263 srv := serve(t, &cap, http.StatusCreated, `{"id":"tok-2","name":"perm","token":"eitri_pat_x"}`)
264 c := &client.Client{BaseURL: srv.URL, Token: "tok"}
265
266 if _, err := c.CreateAPIToken("perm", 0); err != nil {
267 t.Fatalf("CreateAPIToken: %v", err)
268 }
269 var req types.CreateAPITokenRequest
270 if err := json.Unmarshal(cap.body, &req); err != nil {
271 t.Fatalf("decode request: %v", err)
272 }
273 if req.TTLSeconds != 0 {
274 t.Errorf("ttl_seconds = %d, want 0 (non-expiring)", req.TTLSeconds)
275 }
276 }
277
212 func TestFetchSSHCALineVerbatim(t *testing.T) { 278 func TestFetchSSHCALineVerbatim(t *testing.T) {
213 var cap capture 279 var cap capture
214 body, _ := json.Marshal(map[string]string{"ca": testCALine}) 280 body, _ := json.Marshal(map[string]string{"ca": testCALine})
internal/server/api/console.go
Old New
@@ -28,13 +28,12 @@ const consoleOpenTimeout = 10 * time.Second
28 // handleConsoleWS bridges a browser WebSocket to a VM serial console. 28 // handleConsoleWS bridges a browser WebSocket to a VM serial console.
29 // EventSource-style auth: browsers cannot set headers on a WebSocket dial, so 29 // EventSource-style auth: browsers cannot set headers on a WebSocket dial, so
30 // the request carries a one-time short-TTL ticket minted via the 30 // the request carries a one-time short-TTL ticket minted via the
31 // admin-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) — 31 // user-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) — the
32 // the admin token never appears in a URL. 32 // caller's PAT/session never appears in a URL. The ticket carries the minting
33 // principal's tenant, which gates which VM the console may attach to.
33 func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) { 34 func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) {
34 // The console WS is ticket-authed and tickets carry no principal yet — 35 tenant, ok := a.tickets.consume(r.URL.Query().Get("ticket"))
35 // per-tenant console isolation is a recorded tenant-#2 blocker (spec 36 if !ok {
36 // §Deferred), stated here rather than implied.
37 if !a.tickets.consume(r.URL.Query().Get("ticket")) {
38 http.Error(w, "unauthorized", http.StatusUnauthorized) 37 http.Error(w, "unauthorized", http.StatusUnauthorized)
39 return 38 return
40 } 39 }
@@ -44,9 +43,11 @@ func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) {
44 } 43 }
45 id := r.PathValue("id") 44 id := r.PathValue("id")
46 vm, ok := a.vmByID(id) 45 vm, ok := a.vmByID(id)
47 if !ok || vm.DeletedAt != nil { 46 if !ok || vm.DeletedAt != nil || !mayActAs(Principal{Tenant: tenant}, vm.Tenant) {
48 // GetVM does not filter tombstones; a console to a VM being decommissioned 47 // GetVM does not filter tombstones; a console to a VM being decommissioned
49 // or deleted must not open (the agent leg would refuse it anyway). 48 // or deleted must not open (the agent leg would refuse it anyway). A VM in
49 // another tenant answers identically to a missing one — existence is not
50 // leaked across tenants (matches the mayActAs 404 convention).
50 http.Error(w, "not found", http.StatusNotFound) 51 http.Error(w, "not found", http.StatusNotFound)
51 return 52 return
52 } 53 }
internal/server/api/console_test.go
Old New
@@ -69,7 +69,7 @@ func newConsoleAPI(t *testing.T) (*API, consoleFixture) {
69 t.Helper() 69 t.Helper()
70 ts, _, _, _, a := newServer(t) 70 ts, _, _, _, a := newServer(t)
71 out := enroll(t, ts) 71 out := enroll(t, ts)
72 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 72 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
73 map[string]any{"host_id": out["host_id"], "name": "console-vm"}) 73 map[string]any{"host_id": out["host_id"], "name": "console-vm"})
74 require.Equal(t, 201, resp.StatusCode) 74 require.Equal(t, 201, resp.StatusCode)
75 var created map[string]string 75 var created map[string]string
@@ -98,10 +98,10 @@ func TestConsoleWSRejectsDeletedVM(t *testing.T) {
98 a.SetConsoleDialer(&fakeConsole{}) 98 a.SetConsoleDialer(&fakeConsole{})
99 99
100 // Tombstone the VM (GetVM does not filter tombstones). 100 // Tombstone the VM (GetVM does not filter tombstones).
101 resp := do(t, "DELETE", fix.ts.URL+"/api/v1/vms/"+fix.vmID, "admintok", nil) 101 resp := do(t, "DELETE", fix.ts.URL+"/api/v1/vms/"+fix.vmID, testPAT, nil)
102 require.Equal(t, 204, resp.StatusCode) 102 require.Equal(t, 204, resp.StatusCode)
103 103
104 ticket := mintTicket(t, fix.ts.URL, "admintok") 104 ticket := mintTicket(t, fix.ts.URL, testPAT)
105 r, err := fix.ts.Client().Get(fix.ts.URL + "/api/v1/vms/" + fix.vmID + "/console/ws?ticket=" + ticket) 105 r, err := fix.ts.Client().Get(fix.ts.URL + "/api/v1/vms/" + fix.vmID + "/console/ws?ticket=" + ticket)
106 require.NoError(t, err) 106 require.NoError(t, err)
107 defer r.Body.Close() 107 defer r.Body.Close()
@@ -113,7 +113,7 @@ func TestConsoleWSBridgesBytes(t *testing.T) {
113 fc := &fakeConsole{opened: make(chan struct{}, 1)} 113 fc := &fakeConsole{opened: make(chan struct{}, 1)}
114 a.SetConsoleDialer(fc) 114 a.SetConsoleDialer(fc)
115 115
116 ticket := mintTicket(t, fix.ts.URL, "admintok") 116 ticket := mintTicket(t, fix.ts.URL, testPAT)
117 c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil) 117 c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil)
118 require.NoError(t, err) 118 require.NoError(t, err)
119 defer c.CloseNow() 119 defer c.CloseNow()
@@ -146,7 +146,7 @@ func TestConsoleWSCloseTearsDownAgentStream(t *testing.T) {
146 fc := &fakeConsole{opened: make(chan struct{}, 1)} 146 fc := &fakeConsole{opened: make(chan struct{}, 1)}
147 a.SetConsoleDialer(fc) 147 a.SetConsoleDialer(fc)
148 148
149 ticket := mintTicket(t, fix.ts.URL, "admintok") 149 ticket := mintTicket(t, fix.ts.URL, testPAT)
150 c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil) 150 c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil)
151 require.NoError(t, err) 151 require.NoError(t, err)
152 <-fc.opened // handler has dialed the fake; serverEnd is set 152 <-fc.opened // handler has dialed the fake; serverEnd is set
@@ -172,7 +172,7 @@ func TestConsoleWSHostOfflineClosesWithReason(t *testing.T) {
172 a, fix := newConsoleAPI(t) 172 a, fix := newConsoleAPI(t)
173 a.SetConsoleDialer(&fakeConsole{err: errors.New("agent not connected")}) 173 a.SetConsoleDialer(&fakeConsole{err: errors.New("agent not connected")})
174 174
175 ticket := mintTicket(t, fix.ts.URL, "admintok") 175 ticket := mintTicket(t, fix.ts.URL, testPAT)
176 c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil) 176 c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil)
177 require.NoError(t, err, "the upgrade succeeds; failure arrives as a close frame") 177 require.NoError(t, err, "the upgrade succeeds; failure arrives as a close frame")
178 defer c.CloseNow() 178 defer c.CloseNow()
@@ -187,7 +187,7 @@ func TestConsoleWSTicketIsSingleUse(t *testing.T) {
187 a, fix := newConsoleAPI(t) 187 a, fix := newConsoleAPI(t)
188 a.SetConsoleDialer(&fakeConsole{}) 188 a.SetConsoleDialer(&fakeConsole{})
189 189
190 ticket := mintTicket(t, fix.ts.URL, "admintok") 190 ticket := mintTicket(t, fix.ts.URL, testPAT)
191 url := fix.ts.URL + "/api/v1/vms/" + fix.vmID + "/console/ws?ticket=" + ticket 191 url := fix.ts.URL + "/api/v1/vms/" + fix.vmID + "/console/ws?ticket=" + ticket
192 // Consume it once (plain GET is fine — the ticket is consumed before upgrade). 192 // Consume it once (plain GET is fine — the ticket is consumed before upgrade).
193 _, _ = fix.ts.Client().Get(url) 193 _, _ = fix.ts.Client().Get(url)
internal/server/api/decommission_api_test.go
Old New
@@ -23,8 +23,8 @@ func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) {
23 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 23 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
24 require.NoError(t, err) 24 require.NoError(t, err)
25 t.Cleanup(func() { st.Close() }) 25 t.Cleanup(func() { st.Close() })
26 seedTestTenant(t, st)
26 a := New(Config{ 27 a := New(Config{
27 AdminToken: "admintok",
28 HostSecret: []byte("hostsecret"), 28 HostSecret: []byte("hostsecret"),
29 DefaultImage: DefaultImage{URL: "http://img", SHA256: strings.Repeat("a", 64)}, 29 DefaultImage: DefaultImage{URL: "http://img", SHA256: strings.Repeat("a", 64)},
30 AdvertiseHTTP: "http://127.0.0.1:8080", 30 AdvertiseHTTP: "http://127.0.0.1:8080",
@@ -34,8 +34,9 @@ func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) {
34 // BYO-CA precondition: VM create requires the tenant to have ≥1 registered 34 // BYO-CA precondition: VM create requires the tenant to have ≥1 registered
35 // SSH user CA. Seed the default tenant so VM-create tests reach the create 35 // SSH user CA. Seed the default tenant so VM-create tests reach the create
36 // path rather than the precondition (mirrors newServer in api_test.go). 36 // path rather than the precondition (mirrors newServer in api_test.go).
37 require.NoError(t, st.AddTenantUserCA(store.DefaultTenant, 37 require.NoError(t, st.AddTenantUserCA(testTenant,
38 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test")) 38 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test"))
39 mintTestPAT(t, st)
39 ts := httptest.NewServer(a.Handler()) 40 ts := httptest.NewServer(a.Handler())
40 t.Cleanup(ts.Close) 41 t.Cleanup(ts.Close)
41 return ts, a, st 42 return ts, a, st
@@ -47,23 +48,23 @@ func TestDecommissionEndpointThenSweepRemovesDrainedHost(t *testing.T) {
47 hostID := out["host_id"] 48 hostID := out["host_id"]
48 49
49 // Decommission a host with no VMs. 50 // Decommission a host with no VMs.
50 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil) 51 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil)
51 require.Equal(t, http.StatusAccepted, resp.StatusCode) 52 require.Equal(t, http.StatusAccepted, resp.StatusCode)
52 53
53 // It now reports decommissioning. 54 // It now reports decommissioning.
54 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)) 55 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
55 require.Len(t, hosts, 1) 56 require.Len(t, hosts, 1)
56 assert.Equal(t, "decommissioning", hosts[0]["status"]) 57 assert.Equal(t, "decommissioning", hosts[0]["status"])
57 58
58 // The sweeper finalizes it (no VMs => drained). 59 // The sweeper finalizes it (no VMs => drained).
59 assert.True(t, a.sweepDecommissioned(), "sweep should remove the drained host") 60 assert.True(t, a.sweepDecommissioned(), "sweep should remove the drained host")
60 hosts = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)) 61 hosts = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
61 assert.Empty(t, hosts, "host should be gone after sweep") 62 assert.Empty(t, hosts, "host should be gone after sweep")
62 } 63 }
63 64
64 func TestDecommissionUnknownHostIs404(t *testing.T) { 65 func TestDecommissionUnknownHostIs404(t *testing.T) {
65 ts, _, _ := apiServer(t) 66 ts, _, _ := apiServer(t)
66 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/nope", "admintok", nil) 67 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/nope", testPAT, nil)
67 assert.Equal(t, http.StatusNotFound, resp.StatusCode) 68 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
68 } 69 }
69 70
@@ -71,13 +72,13 @@ func TestSweepLeavesHostWithVMs(t *testing.T) {
71 ts, a, _ := apiServer(t) 72 ts, a, _ := apiServer(t)
72 out := enroll(t, ts) 73 out := enroll(t, ts)
73 hostID := out["host_id"] 74 hostID := out["host_id"]
74 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": hostID, "name": "vm-a"}) 75 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": "vm-a"})
75 require.Equal(t, http.StatusCreated, resp.StatusCode) 76 require.Equal(t, http.StatusCreated, resp.StatusCode)
76 77
77 do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil) 78 do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil)
78 // VM row still present (not yet reaped) => sweep must not remove the host. 79 // VM row still present (not yet reaped) => sweep must not remove the host.
79 assert.False(t, a.sweepDecommissioned(), "host with VM rows must not be swept") 80 assert.False(t, a.sweepDecommissioned(), "host with VM rows must not be swept")
80 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)) 81 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
81 require.Len(t, hosts, 1) 82 require.Len(t, hosts, 1)
82 } 83 }
83 84
@@ -87,7 +88,7 @@ func TestEventsStreamSendsSnapshot(t *testing.T) {
87 88
88 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 89 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
89 defer cancel() 90 defer cancel()
90 req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/v1/events?ticket="+mintTicket(t, ts.URL, "admintok"), nil) 91 req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/v1/events?ticket="+mintTicket(t, ts.URL, testPAT), nil)
91 resp, err := http.DefaultClient.Do(req) 92 resp, err := http.DefaultClient.Do(req)
92 require.NoError(t, err) 93 require.NoError(t, err)
93 defer resp.Body.Close() 94 defer resp.Body.Close()
internal/server/api/decommission_poke_test.go
Old New
@@ -21,7 +21,7 @@ func TestDecommissionPokesAgent(t *testing.T) {
21 ch, cancel := a.hub.Subscribe(hostID) 21 ch, cancel := a.hub.Subscribe(hostID)
22 defer cancel() 22 defer cancel()
23 23
24 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil) 24 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil)
25 require.Equal(t, http.StatusAccepted, resp.StatusCode) 25 require.Equal(t, http.StatusAccepted, resp.StatusCode)
26 26
27 select { 27 select {
@@ -39,14 +39,14 @@ func TestForceDecommissionRemovesHostWithVMs(t *testing.T) {
39 out := enroll(t, ts) 39 out := enroll(t, ts)
40 hostID := out["host_id"] 40 hostID := out["host_id"]
41 41
42 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": hostID, "name": "vm-a"}) 42 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": "vm-a"})
43 require.Equal(t, http.StatusCreated, resp.StatusCode) 43 require.Equal(t, http.StatusCreated, resp.StatusCode)
44 44
45 // Graceful delete would only tombstone and wait; force removes now. 45 // Graceful delete would only tombstone and wait; force removes now.
46 resp = do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", "admintok", nil) 46 resp = do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", testPAT, nil)
47 require.Equal(t, http.StatusOK, resp.StatusCode) 47 require.Equal(t, http.StatusOK, resp.StatusCode)
48 48
49 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)) 49 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
50 assert.Empty(t, hosts, "force must remove the host immediately") 50 assert.Empty(t, hosts, "force must remove the host immediately")
51 } 51 }
52 52
@@ -58,9 +58,9 @@ func TestCreateVMRejectedOnDecommissioningHost(t *testing.T) {
58 out := enroll(t, ts) 58 out := enroll(t, ts)
59 hostID := out["host_id"] 59 hostID := out["host_id"]
60 60
61 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil) 61 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, testPAT, nil)
62 require.Equal(t, http.StatusAccepted, resp.StatusCode) 62 require.Equal(t, http.StatusAccepted, resp.StatusCode)
63 63
64 resp = do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": hostID, "name": "vm-late"}) 64 resp = do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": "vm-late"})
65 assert.Equal(t, http.StatusConflict, resp.StatusCode, "create on a decommissioning host must be rejected") 65 assert.Equal(t, http.StatusConflict, resp.StatusCode, "create on a decommissioning host must be rejected")
66 } 66 }
internal/server/api/events.go
Old New
@@ -25,10 +25,25 @@ import (
25 // is gone with the box), reclaiming the CIDR without waiting for a drain that 25 // is gone with the box), reclaiming the CIDR without waiting for a drain that
26 // can never happen. 26 // can never happen.
27 func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { 27 func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
28 if !a.requireFleet(w, r) { 28 id := r.PathValue("id")
29
30 // Resolve the host's tenant up front: it is both the ownership gate (a
31 // foreign-tenant host answers exactly like a missing one — no existence
32 // leak) and the scope for the decommission audit row. Both paths below
33 // destroy the host row, so its tenant must be read before then.
34 h, err := a.st.GetHost(id)
35 switch {
36 case errors.Is(err, sql.ErrNoRows):
37 http.Error(w, "host not found", http.StatusNotFound)
38 return
39 case err != nil:
40 http.Error(w, "internal error", http.StatusInternalServerError)
41 return
42 }
43 if !mayActAs(principalFromContext(r), h.Tenant) {
44 http.Error(w, "host not found", http.StatusNotFound)
29 return 45 return
30 } 46 }
31 id := r.PathValue("id")
32 47
33 if forceParam(r) { 48 if forceParam(r) {
34 purged, err := a.st.ForceRemoveHost(id) 49 purged, err := a.st.ForceRemoveHost(id)
@@ -40,7 +55,7 @@ func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
40 http.Error(w, "internal error", http.StatusInternalServerError) 55 http.Error(w, "internal error", http.StatusInternalServerError)
41 return 56 return
42 } 57 }
43 a.audit("host.decommission", map[string]string{ 58 a.audit(h.Tenant, "host.decommission", map[string]string{
44 "host_id": id, "remote": clientIP(r), 59 "host_id": id, "remote": clientIP(r),
45 "force": "true", "vms_purged": strconv.Itoa(purged), 60 "force": "true", "vms_purged": strconv.Itoa(purged),
46 }) 61 })
@@ -61,7 +76,7 @@ func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
61 http.Error(w, "internal error", http.StatusInternalServerError) 76 http.Error(w, "internal error", http.StatusInternalServerError)
62 return 77 return
63 } 78 }
64 a.audit("host.decommission", map[string]string{"host_id": id, "remote": clientIP(r)}) 79 a.audit(h.Tenant, "host.decommission", map[string]string{"host_id": id, "remote": clientIP(r)})
65 if a.upgrader != nil { 80 if a.upgrader != nil {
66 a.upgrader.ClearAgentUpgrade(id) 81 a.upgrader.ClearAgentUpgrade(id)
67 } 82 }
@@ -87,13 +102,10 @@ func forceParam(r *http.Request) bool {
87 // session is closed by syncsvc within one report tick; the host stays dark 102 // session is closed by syncsvc within one report tick; the host stays dark
88 // until the operator re-enrolls it with a fresh join blob. 103 // until the operator re-enrolls it with a fresh join blob.
89 func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) { 104 func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) {
90 if !a.requireFleet(w, r) {
91 return
92 }
93 id := r.PathValue("id") 105 id := r.PathValue("id")
94 // Audit is written inside the bump transaction (a security action must 106 // Ownership gate before any mutation: a foreign-tenant host answers exactly
95 // not be able to happen unrecorded). 107 // like a missing one (no existence leak), matching the mayActAs convention.
96 _, err := a.st.BumpCredGeneration(id, clientIP(r)) 108 h, err := a.st.GetHost(id)
97 switch { 109 switch {
98 case errors.Is(err, sql.ErrNoRows): 110 case errors.Is(err, sql.ErrNoRows):
99 http.Error(w, "host not found", http.StatusNotFound) 111 http.Error(w, "host not found", http.StatusNotFound)
@@ -102,6 +114,20 @@ func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) {
102 http.Error(w, "internal error", http.StatusInternalServerError) 114 http.Error(w, "internal error", http.StatusInternalServerError)
103 return 115 return
104 } 116 }
117 if !mayActAs(principalFromContext(r), h.Tenant) {
118 http.Error(w, "host not found", http.StatusNotFound)
119 return
120 }
121 // Audit is written inside the bump transaction (a security action must
122 // not be able to happen unrecorded).
123 if _, err := a.st.BumpCredGeneration(id, clientIP(r)); err != nil {
124 if errors.Is(err, sql.ErrNoRows) {
125 http.Error(w, "host not found", http.StatusNotFound)
126 return
127 }
128 http.Error(w, "internal error", http.StatusInternalServerError)
129 return
130 }
105 w.WriteHeader(http.StatusNoContent) 131 w.WriteHeader(http.StatusNoContent)
106 } 132 }
107 133
@@ -137,24 +163,20 @@ func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent {
137 return out 163 return out
138 } 164 }
139 165
140 // handleListAudit returns the newest audit rows (default 100, ?limit=N caps 166 // handleListAudit returns the newest audit rows for the caller's tenant
141 // at 1000). Completes the forensic story: rows were previously reachable only 167 // (default 100, ?limit=N caps at 1000). Completes the forensic story: rows were
142 // by opening the SQLite file. 168 // previously reachable only by opening the SQLite file.
143 // 169 //
144 // Audit read stays Fleet-gated (not per-tenant): audit_log has no tenant column 170 // Scoped to the principal's tenant: audit_log now carries a tenant column, and
145 // and some rows are genuinely tenant-less — e.g. host.enroll.denied written from 171 // each row is filed under the tenant it concerns (rows written before any
146 // an UNAUTHENTICATED enroll attempt, before any tenant is known. Per-tenant 172 // tenant is known — e.g. a denied enroll attempt — fall to store.SystemTenant,
147 // audit is a recorded tenant-#2 blocker (spec §Deferred); stated here rather 173 // which no principal holds).
148 // than implied.
149 func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { 174 func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) {
150 if !a.requireFleet(w, r) {
151 return
152 }
153 limit, ok := parseLimit(w, r) 175 limit, ok := parseLimit(w, r)
154 if !ok { 176 if !ok {
155 return 177 return
156 } 178 }
157 rows, err := a.st.ListAudit(limit) 179 rows, err := a.st.ListAudit(principalFromContext(r).Tenant, limit)
158 if err != nil { 180 if err != nil {
159 http.Error(w, "internal error", http.StatusInternalServerError) 181 http.Error(w, "internal error", http.StatusInternalServerError)
160 return 182 return
@@ -173,7 +195,7 @@ func (a *API) handleListVMEvents(w http.ResponseWriter, r *http.Request) {
173 if !ok { 195 if !ok {
174 return 196 return
175 } 197 }
176 rows, err := a.st.ListVMEvents(id, limit) 198 rows, err := a.st.ListVMEvents(principalFromContext(r).Tenant, id, limit)
177 if err != nil { 199 if err != nil {
178 http.Error(w, "internal error", http.StatusInternalServerError) 200 http.Error(w, "internal error", http.StatusInternalServerError)
179 return 201 return
@@ -181,20 +203,24 @@ func (a *API) handleListVMEvents(w http.ResponseWriter, r *http.Request) {
181 writeJSON(w, http.StatusOK, auditRowsToResponse(rows)) 203 writeJSON(w, http.StatusOK, auditRowsToResponse(rows))
182 } 204 }
183 205
184 // handleMintStreamTicket issues a one-time short-TTL ticket for the SSE 206 // handleMintStreamTicket issues a one-time short-TTL ticket for the SSE stream
185 // stream (admin-authenticated; the ticket is the only thing that ever 207 // or console WS (user-authenticated; the ticket is the only thing that ever
186 // appears in a URL). 208 // appears in a URL). The ticket is stamped with the caller's tenant so the
209 // stream/console it later unlocks is scoped to that tenant, not the fleet.
187 func (a *API) handleMintStreamTicket(w http.ResponseWriter, r *http.Request) { 210 func (a *API) handleMintStreamTicket(w http.ResponseWriter, r *http.Request) {
188 writeJSON(w, http.StatusCreated, types.StreamTicketResponse{Ticket: a.tickets.mint()}) 211 writeJSON(w, http.StatusCreated, types.StreamTicketResponse{Ticket: a.tickets.mint(principalFromContext(r).Tenant)})
189 } 212 }
190 213
191 // handleEvents streams the fleet snapshot as Server-Sent Events. It subscribes 214 // handleEvents streams the caller tenant's fleet-view snapshot as Server-Sent
192 // to the central snapshot hub — which marshals ONE shared snapshot on a 1s tick 215 // Events. It subscribes to the central snapshot hub — which reads the store ONCE
193 // / desired-state wake and fans the identical bytes to every client — and writes 216 // on a 1s tick / desired-state wake and marshals one filtered payload per
194 // each delivered snapshot as an `event: state` frame. The hub delivers the 217 // subscribed tenant, fanning each connection its own tenant's bytes — and writes
195 // current snapshot immediately on subscribe, so a new client gets initial state 218 // each delivered snapshot as an `event: state` frame. The tenant is the one the
196 // without doing its own marshal, and suppresses unchanged snapshots so no frame 219 // consumed ticket was minted for, so a connection never sees another tenant's
197 // is pushed when nothing changed. A periodic comment keeps the connection alive. 220 // hosts/VMs. The hub delivers the current snapshot immediately on subscribe, so a
221 // new client gets initial state without doing its own marshal, and suppresses
222 // unchanged snapshots so no frame is pushed when nothing changed. A periodic
223 // comment keeps the connection alive.
198 func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) { 224 func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) {
199 // Assert streaming support BEFORE consuming the one-time ticket, so a 500 on 225 // Assert streaming support BEFORE consuming the one-time ticket, so a 500 on
200 // a non-flushing ResponseWriter doesn't burn the client's ticket. 226 // a non-flushing ResponseWriter doesn't burn the client's ticket.
@@ -203,7 +229,8 @@ func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) {
203 http.Error(w, "streaming unsupported", http.StatusInternalServerError) 229 http.Error(w, "streaming unsupported", http.StatusInternalServerError)
204 return 230 return
205 } 231 }
206 if !a.tickets.consume(r.URL.Query().Get("ticket")) { 232 tenant, ok := a.tickets.consume(r.URL.Query().Get("ticket"))
233 if !ok {
207 http.Error(w, "unauthorized", http.StatusUnauthorized) 234 http.Error(w, "unauthorized", http.StatusUnauthorized)
208 return 235 return
209 } 236 }
@@ -212,7 +239,9 @@ func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) {
212 w.Header().Set("Cache-Control", "no-cache") 239 w.Header().Set("Cache-Control", "no-cache")
213 w.Header().Set("Connection", "keep-alive") 240 w.Header().Set("Connection", "keep-alive")
214 241
215 snaps, unsub := a.snap.subscribe() 242 // Subscribe scoped to the ticket's tenant: the hub fans this connection only
243 // its tenant's snapshot bytes, so no cross-tenant host/VM/metric ever reaches it.
244 snaps, unsub := a.snap.subscribe(tenant)
216 defer unsub() 245 defer unsub()
217 246
218 heartbeat := time.NewTicker(15 * time.Second) 247 heartbeat := time.NewTicker(15 * time.Second)
@@ -244,27 +273,36 @@ func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) {
244 } 273 }
245 } 274 }
246 275
247 // marshalSnapshot builds and JSON-encodes the current fleet snapshot. The 276 // marshalSnapshots reads the fleet snapshot ONCE and JSON-encodes one payload
248 // store reads happen in one transaction (store.Snapshot) so hosts, allocation 277 // per requested tenant, each filtered to that tenant's hosts/VMs. The store
249 // and VMs can never mix state from two different epochs. Each referenced host's 278 // reads happen in one transaction (store.Snapshot) so hosts, allocation and VMs
250 // registry state is fetched ONCE (not once per VM), then shared by both the host 279 // can never mix state from two different epochs; the registry states are fetched
251 // and VM builders — registry.Get deep-clones the whole host report per call. 280 // ONCE over the union of hosts (registry.Get deep-clones the whole host report
252 func (a *API) marshalSnapshot() ([]byte, error) { 281 // per call) and shared across every tenant's marshal. The hub calls this with
282 // the distinct set of currently-subscribed tenants, so N connected tenants cost
283 // one store read and N marshals per tick — not N store reads.
284 func (a *API) marshalSnapshots(tenants []string) (map[string][]byte, error) {
253 hosts, alloc, vms, err := a.st.Snapshot() 285 hosts, alloc, vms, err := a.st.Snapshot()
254 if err != nil { 286 if err != nil {
255 return nil, err 287 return nil, err
256 } 288 }
257 // SSE is ticket-authed and tickets carry no principal yet — per-tenant
258 // stream isolation is a recorded tenant-#2 blocker (spec §Deferred). The
259 // stream therefore remains fleet-wide, stated here rather than implied.
260 fleet := Principal{Fleet: true}
261 hosts = filterHosts(fleet, hosts)
262 vms = filterVMs(fleet, vms)
263 states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms)) 289 states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms))
264 return json.Marshal(types.StateSnapshot{ 290 latest := a.latestVersion()
265 Hosts: a.buildHostResponses(hosts, alloc, states), 291 out := make(map[string][]byte, len(tenants))
266 VMs: a.buildVMResponses(vms, states), 292 for _, tenant := range tenants {
267 ServerVersion: version.Version, 293 p := Principal{Tenant: tenant}
268 LatestVersion: a.latestVersion(), 294 th := filterHosts(p, hosts)
269 }) 295 tv := filterVMs(p, vms)
296 b, err := json.Marshal(types.StateSnapshot{
297 Hosts: a.buildHostResponses(th, alloc, states),
298 VMs: a.buildVMResponses(tv, states),
299 ServerVersion: version.Version,
300 LatestVersion: latest,
301 })
302 if err != nil {
303 return nil, err
304 }
305 out[tenant] = b
306 }
307 return out, nil
270 } 308 }
internal/server/api/events_test.go
Old New
@@ -46,19 +46,19 @@ func readFirstSSEEvent(t *testing.T, url string) (int, bool) {
46 return 200, false 46 return 200, false
47 } 47 }
48 48
49 // TestStreamTicketFlow pins the SSE auth model: the long-lived admin token 49 // TestStreamTicketFlow pins the SSE auth model: long-lived credentials never
50 // never rides in a URL. A one-time short-TTL ticket is minted over an 50 // ride in a URL. A one-time short-TTL ticket is minted over an authenticated
51 // authenticated POST; the events stream consumes it; replay fails; the old 51 // POST; the events stream consumes it; replay fails; the old ?token= path is
52 // ?token= path is gone. 52 // gone.
53 func TestStreamTicketFlow(t *testing.T) { 53 func TestStreamTicketFlow(t *testing.T) {
54 ts, _, _ := testServer(t) 54 ts, _, _ := testServer(t)
55 55
56 t.Run("mint requires admin", func(t *testing.T) { 56 t.Run("mint requires a credential", func(t *testing.T) {
57 resp := do(t, "POST", ts.URL+"/api/v1/stream-tickets", "wrong", nil) 57 resp := do(t, "POST", ts.URL+"/api/v1/stream-tickets", "wrong", nil)
58 assert.Equal(t, 401, resp.StatusCode) 58 assert.Equal(t, 401, resp.StatusCode)
59 }) 59 })
60 60
61 tick := mintTicket(t, ts.URL, "admintok") 61 tick := mintTicket(t, ts.URL, testPAT)
62 62
63 t.Run("valid ticket streams", func(t *testing.T) { 63 t.Run("valid ticket streams", func(t *testing.T) {
64 code, gotState := readFirstSSEEvent(t, ts.URL+"/api/v1/events?ticket="+tick) 64 code, gotState := readFirstSSEEvent(t, ts.URL+"/api/v1/events?ticket="+tick)
@@ -76,20 +76,26 @@ func TestStreamTicketFlow(t *testing.T) {
76 assert.Equal(t, 401, code) 76 assert.Equal(t, 401, code)
77 }) 77 })
78 78
79 t.Run("admin token in query string is rejected", func(t *testing.T) { 79 t.Run("PAT in query string is rejected", func(t *testing.T) {
80 code, _ := readFirstSSEEvent(t, ts.URL+"/api/v1/events?token=admintok") 80 code, _ := readFirstSSEEvent(t, ts.URL+"/api/v1/events?token="+testPAT)
81 assert.Equal(t, 401, code, "an admin token in the URL must not authenticate the stream") 81 assert.Equal(t, 401, code, "a PAT in the URL must not authenticate the stream; only tickets do")
82 }) 82 })
83 } 83 }
84 84
85 // TestStreamTicketExpiry pins the TTL with an injected clock. 85 // TestStreamTicketExpiry pins the TTL with an injected clock, and that consume
86 // returns the tenant a ticket was minted for.
86 func TestStreamTicketExpiry(t *testing.T) { 87 func TestStreamTicketExpiry(t *testing.T) {
87 now := time.Unix(1_750_000_000, 0) 88 now := time.Unix(1_750_000_000, 0)
88 tk := newTicketStore(func() time.Time { return now }) 89 tk := newTicketStore(func() time.Time { return now })
89 tick := tk.mint() 90 tick := tk.mint("acme")
90 now = now.Add(streamTicketTTL + time.Second) 91 now = now.Add(streamTicketTTL + time.Second)
91 assert.False(t, tk.consume(tick), "expired ticket must not be consumable") 92 _, ok := tk.consume(tick)
92 fresh := tk.mint() 93 assert.False(t, ok, "expired ticket must not be consumable")
93 assert.True(t, tk.consume(fresh)) 94
94 assert.False(t, tk.consume(fresh), "one-time: second consume fails") 95 fresh := tk.mint("acme")
96 tenant, ok := tk.consume(fresh)
97 assert.True(t, ok)
98 assert.Equal(t, "acme", tenant, "consume returns the minting tenant")
99 _, ok = tk.consume(fresh)
100 assert.False(t, ok, "one-time: second consume fails")
95 } 101 }
internal/server/api/hostinfo_api_test.go
Old New
@@ -21,7 +21,7 @@ func TestHostResponseIncludesFacts(t *testing.T) {
21 Kernel: "6.1.0-18-amd64", CPUModel: "AMD EPYC 7302P", Virt: "kvm", 21 Kernel: "6.1.0-18-amd64", CPUModel: "AMD EPYC 7302P", Virt: "kvm",
22 })) 22 }))
23 23
24 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)) 24 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil))
25 require.Len(t, hosts, 1) 25 require.Len(t, hosts, 1)
26 assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", hosts[0]["os_pretty"]) 26 assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", hosts[0]["os_pretty"])
27 assert.Equal(t, "6.1.0-18-amd64", hosts[0]["kernel"]) 27 assert.Equal(t, "6.1.0-18-amd64", hosts[0]["kernel"])
internal/server/api/isolation_test.go
Old New
@@ -0,0 +1,310 @@
1 package api
2
3 import (
4 "bufio"
5 "context"
6 "crypto/rand"
7 "encoding/json"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/a73x/eitri/internal/server/api/types"
15 "github.com/a73x/eitri/internal/server/sshca"
16 "github.com/a73x/eitri/internal/server/store"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19 "golang.org/x/crypto/ssh"
20 )
21
22 // twoTenant is a two-tenant world: the seeded `default` tenant (auth'd by the
23 // package-global testPAT) and a second `beta` tenant, each with one enrolled
24 // host carrying one VM. It is the fixture every isolation test shares.
25 type twoTenant struct {
26 ts *httptest.Server
27 st *store.Store
28 a *API
29 betaID string
30 betaPAT string
31 defHost string
32 defVM string
33 betaHost string
34 betaVM string
35 }
36
37 // enrollWith mints an enroll token with pat and redeems it, returning the new
38 // host id. Mirrors enroll() but for an arbitrary credential + host name.
39 func enrollWith(t *testing.T, ts *httptest.Server, pat, name string) string {
40 t.Helper()
41 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", pat, nil)
42 require.Equal(t, 201, resp.StatusCode)
43 var tok map[string]string
44 require.NoError(t, json.NewDecoder(resp.Body).Decode(&tok))
45 resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
46 "token": tok["token"], "name": name, "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
47 require.Equal(t, 201, resp.StatusCode)
48 var out map[string]string
49 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
50 return out["host_id"]
51 }
52
53 // createVMWith creates a VM on hostID with pat, returning the VM id.
54 func createVMWith(t *testing.T, ts *httptest.Server, pat, hostID, name string) string {
55 t.Helper()
56 resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID, "name": name})
57 require.Equal(t, 201, resp.StatusCode)
58 var out map[string]string
59 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
60 return out["id"]
61 }
62
63 func newTwoTenant(t *testing.T) twoTenant {
64 t.Helper()
65 ts, st, _, _, a := newServer(t)
66
67 // Second tenant with its own PAT and BYO user CA (the VM-create precondition).
68 beta, err := st.CreateTenantForIdentity("https://issuer.example", "sub-beta", "beta@example.com")
69 require.NoError(t, err)
70 betaPAT, _, err := st.CreateAPIToken(beta.ID, "beta", 0)
71 require.NoError(t, err)
72 require.NoError(t, st.AddTenantUserCA(beta.ID,
73 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBETASEEDCA beta-seed", "tenant", "beta-seed", "test"))
74
75 w := twoTenant{ts: ts, st: st, a: a, betaID: beta.ID, betaPAT: betaPAT}
76 w.defHost = enrollWith(t, ts, testPAT, "default-host")
77 w.defVM = createVMWith(t, ts, testPAT, w.defHost, "default-vm")
78 w.betaHost = enrollWith(t, ts, betaPAT, "beta-host")
79 w.betaVM = createVMWith(t, ts, betaPAT, w.betaHost, "beta-vm")
80 return w
81 }
82
83 // readSSESnapshot opens the events stream at url and returns the first
84 // `event: state` frame parsed as a StateSnapshot.
85 func readSSESnapshot(t *testing.T, url string) types.StateSnapshot {
86 t.Helper()
87 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
88 defer cancel()
89 req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
90 resp, err := http.DefaultClient.Do(req)
91 require.NoError(t, err)
92 defer resp.Body.Close()
93 require.Equal(t, 200, resp.StatusCode)
94
95 sc := bufio.NewScanner(resp.Body)
96 sawState := false
97 for sc.Scan() {
98 line := sc.Text()
99 if strings.HasPrefix(line, "event: state") {
100 sawState = true
101 continue
102 }
103 if sawState && strings.HasPrefix(line, "data: ") {
104 var snap types.StateSnapshot
105 require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &snap))
106 return snap
107 }
108 }
109 t.Fatal("no state frame received")
110 return types.StateSnapshot{}
111 }
112
113 // TestSSEStreamIsTenantScoped proves the SSE fan-out is filtered per connection:
114 // a stream opened with a ticket minted by one tenant's credential carries ONLY
115 // that tenant's hosts/VMs — the other tenant's resources never appear.
116 func TestSSEStreamIsTenantScoped(t *testing.T) {
117 w := newTwoTenant(t)
118
119 // default's stream: only default's host/VM.
120 defSnap := readSSESnapshot(t, w.ts.URL+"/api/v1/events?ticket="+mintTicket(t, w.ts.URL, testPAT))
121 defHostIDs := hostIDSet(defSnap)
122 defVMIDs := vmIDSet(defSnap)
123 assert.Contains(t, defHostIDs, w.defHost)
124 assert.Contains(t, defVMIDs, w.defVM)
125 assert.NotContains(t, defHostIDs, w.betaHost, "default's stream must not carry beta's host")
126 assert.NotContains(t, defVMIDs, w.betaVM, "default's stream must not carry beta's VM")
127
128 // beta's stream: only beta's host/VM.
129 betaSnap := readSSESnapshot(t, w.ts.URL+"/api/v1/events?ticket="+mintTicket(t, w.ts.URL, w.betaPAT))
130 betaHostIDs := hostIDSet(betaSnap)
131 betaVMIDs := vmIDSet(betaSnap)
132 assert.Contains(t, betaHostIDs, w.betaHost)
133 assert.Contains(t, betaVMIDs, w.betaVM)
134 assert.NotContains(t, betaHostIDs, w.defHost, "beta's stream must not carry default's host")
135 assert.NotContains(t, betaVMIDs, w.defVM, "beta's stream must not carry default's VM")
136 }
137
138 func hostIDSet(s types.StateSnapshot) map[string]bool {
139 out := map[string]bool{}
140 for _, h := range s.Hosts {
141 out[h.ID] = true
142 }
143 return out
144 }
145
146 func vmIDSet(s types.StateSnapshot) map[string]bool {
147 out := map[string]bool{}
148 for _, v := range s.VMs {
149 out[v.ID] = true
150 }
151 return out
152 }
153
154 // TestConsoleWSRejectsForeignTenantVM proves the console attach ownership gate: a
155 // ticket minted by default cannot open a console to beta's VM — it answers 404
156 // (no existence leak), identical to a missing VM.
157 func TestConsoleWSRejectsForeignTenantVM(t *testing.T) {
158 w := newTwoTenant(t)
159 w.a.SetConsoleDialer(&fakeConsole{}) // wired, so we reach the ownership check (not 503)
160
161 // default's ticket, beta's VM → 404 before any upgrade/attach.
162 ticket := mintTicket(t, w.ts.URL, testPAT)
163 resp, err := w.ts.Client().Get(w.ts.URL + "/api/v1/vms/" + w.betaVM + "/console/ws?ticket=" + ticket)
164 require.NoError(t, err)
165 defer resp.Body.Close()
166 assert.Equal(t, 404, resp.StatusCode, "cross-tenant console must 404, not leak existence")
167
168 // Sanity: beta's own ticket reaches beta's VM (the handshake upgrades).
169 betaTicket := mintTicket(t, w.ts.URL, w.betaPAT)
170 r2, err := w.ts.Client().Get(w.ts.URL + "/api/v1/vms/" + w.betaVM + "/console/ws?ticket=" + betaTicket)
171 require.NoError(t, err)
172 defer r2.Body.Close()
173 assert.NotEqual(t, 404, r2.StatusCode, "beta reaching its own VM must not 404")
174 }
175
176 // TestAuditIsTenantScoped proves GET /api/v1/audit returns only the caller
177 // tenant's rows: beta's enroll/mint/create actions never surface for default and
178 // vice versa.
179 func TestAuditIsTenantScoped(t *testing.T) {
180 w := newTwoTenant(t)
181
182 defRows := listAudit(t, w.ts, testPAT)
183 betaRows := listAudit(t, w.ts, w.betaPAT)
184
185 // Each tenant's own VM name shows in its audit; the other's never does.
186 assert.True(t, auditMentions(defRows, "default-vm"), "default must see its own vm.create")
187 assert.False(t, auditMentions(defRows, "beta-vm"), "default must not see beta's audit rows")
188 assert.True(t, auditMentions(betaRows, "beta-vm"), "beta must see its own vm.create")
189 assert.False(t, auditMentions(betaRows, "default-vm"), "beta must not see default's audit rows")
190 }
191
192 func listAudit(t *testing.T, ts *httptest.Server, pat string) []map[string]any {
193 t.Helper()
194 resp := do(t, "GET", ts.URL+"/api/v1/audit", pat, nil)
195 require.Equal(t, 200, resp.StatusCode)
196 var rows []map[string]any
197 require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
198 return rows
199 }
200
201 func auditMentions(rows []map[string]any, needle string) bool {
202 for _, r := range rows {
203 b, _ := json.Marshal(r)
204 if strings.Contains(string(b), needle) {
205 return true
206 }
207 }
208 return false
209 }
210
211 // TestListEndpointsAreTenantScoped proves the real HTTP list paths (not just the
212 // in-package filter) never cross tenants: beta's PAT sees only beta's host/VM.
213 func TestListEndpointsAreTenantScoped(t *testing.T) {
214 w := newTwoTenant(t)
215
216 betaHosts := decodeJSONKeys(t, do(t, "GET", w.ts.URL+"/api/v1/hosts", w.betaPAT, nil))
217 require.Len(t, betaHosts, 1)
218 assert.Equal(t, w.betaHost, betaHosts[0]["id"])
219
220 betaVMs := decodeJSONKeys(t, do(t, "GET", w.ts.URL+"/api/v1/vms", w.betaPAT, nil))
221 require.Len(t, betaVMs, 1)
222 assert.Equal(t, w.betaVM, betaVMs[0]["id"])
223
224 // default sees only its own, too.
225 defHosts := decodeJSONKeys(t, do(t, "GET", w.ts.URL+"/api/v1/hosts", testPAT, nil))
226 require.Len(t, defHosts, 1)
227 assert.Equal(t, w.defHost, defHosts[0]["id"])
228 }
229
230 // TestHostOperationsRejectForeignTenant proves every ex-fleet host operation
231 // (decommission, revoke-credential, upgrade-agent) answers 404 for a host in
232 // another tenant — same not-found response as a missing host, no existence leak.
233 func TestHostOperationsRejectForeignTenant(t *testing.T) {
234 w := newTwoTenant(t)
235 // upgrade-agent needs release + upgrader wired to reach the ownership gate
236 // (they are checked before the host lookup).
237 w.a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
238 w.a.SetAgentUpgrader(&fakeUpgrader{})
239
240 // default acting on beta's host → 404 on every operation.
241 assert.Equal(t, 404, do(t, "DELETE", w.ts.URL+"/api/v1/hosts/"+w.betaHost, testPAT, nil).StatusCode,
242 "cross-tenant decommission must 404")
243 assert.Equal(t, 404, do(t, "POST", w.ts.URL+"/api/v1/hosts/"+w.betaHost+"/revoke-credential", testPAT, nil).StatusCode,
244 "cross-tenant revoke-credential must 404")
245 assert.Equal(t, 404, do(t, "POST", w.ts.URL+"/api/v1/hosts/"+w.betaHost+"/upgrade-agent", testPAT, nil).StatusCode,
246 "cross-tenant upgrade-agent must 404")
247
248 // beta's host must still be intact (the ownership gate ran before mutate).
249 h, err := w.st.GetHost(w.betaHost)
250 require.NoError(t, err)
251 assert.Equal(t, int64(1), h.CredGeneration, "revoke-credential must not have bumped a foreign host")
252 assert.NotEqual(t, "decommissioning", h.Status, "decommission must not have touched a foreign host")
253 }
254
255 // TestEnrollTokenMintBindsTenantNoFleet proves enroll-token mint works for an
256 // ordinary tenant credential (no fleet bit exists) and binds the token — and so
257 // the joining host — to the minting principal's tenant.
258 func TestEnrollTokenMintBindsTenantNoFleet(t *testing.T) {
259 ts, st, _, _, _ := newServer(t)
260 beta, err := st.CreateTenantForIdentity("https://issuer.example", "sub-b", "b@example.com")
261 require.NoError(t, err)
262 betaPAT, _, err := st.CreateAPIToken(beta.ID, "beta", 0)
263 require.NoError(t, err)
264
265 hostID := enrollWith(t, ts, betaPAT, "b-host")
266 h, err := st.GetHost(hostID)
267 require.NoError(t, err)
268 assert.Equal(t, beta.ID, h.Tenant, "a host joined with beta's token belongs to beta")
269 }
270
271 // TestSSHCertRevokeRejectsForeignTenantCert proves the revoke ownership gate: a
272 // cert whose signing CA belongs to another tenant answers 404 and is NOT revoked;
273 // the owning tenant revokes the same cert line successfully, and the revocation
274 // LIST stays tenant-scoped.
275 func TestSSHCertRevokeRejectsForeignTenantCert(t *testing.T) {
276 w := newTwoTenant(t)
277
278 // beta registers a REAL user CA and mints a user cert signed by it.
279 betaCA := newCASigner(t)
280 require.NoError(t, w.st.AddTenantUserCA(w.betaID,
281 sshca.AuthorizedKeyLine(betaCA.PublicKey()), "tenant", "beta-real-ca", "test"))
282
283 pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(genUserPubKey(t)))
284 require.NoError(t, err)
285 cert := &ssh.Certificate{
286 Key: pk, Serial: 0xBEEF, CertType: ssh.UserCert, ValidBefore: ssh.CertTimeInfinity,
287 }
288 require.NoError(t, cert.SignCert(rand.Reader, betaCA))
289 line := string(ssh.MarshalAuthorizedKey(cert))
290
291 // default tries to revoke beta's cert → 404, and the serial stays live.
292 resp := do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": line})
293 assert.Equal(t, 404, resp.StatusCode, "revoking another tenant's cert must 404")
294 revoked, err := w.st.IsSSHCertRevoked(cert.Serial)
295 require.NoError(t, err)
296 assert.False(t, revoked, "a cross-tenant revoke must not have taken effect")
297
298 // beta revokes its own cert → 204, and it shows only in beta's list.
299 resp = do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", w.betaPAT, map[string]any{"certificate": line})
300 require.Equal(t, 204, resp.StatusCode)
301 revoked, err = w.st.IsSSHCertRevoked(cert.Serial)
302 require.NoError(t, err)
303 assert.True(t, revoked)
304
305 var betaList, defList []map[string]any
306 require.NoError(t, json.NewDecoder(do(t, "GET", w.ts.URL+"/api/v1/ssh-certs/revoked", w.betaPAT, nil).Body).Decode(&betaList))
307 require.NoError(t, json.NewDecoder(do(t, "GET", w.ts.URL+"/api/v1/ssh-certs/revoked", testPAT, nil).Body).Decode(&defList))
308 assert.Len(t, betaList, 1, "beta sees its own revocation")
309 assert.Empty(t, defList, "default must not see beta's revocation")
310 }
internal/server/api/principal.go
Old New
@@ -7,19 +7,14 @@ import (
7 "github.com/a73x/eitri/internal/server/store" 7 "github.com/a73x/eitri/internal/server/store"
8 ) 8 )
9 9
10 // Principal is the authenticated actor attached to every admin-API request by 10 // Principal is the authenticated actor attached to every user-API request by
11 // adminAuth. It is the F3 seam: today the bearer token maps to the single 11 // userAuth. It is the F3 seam: userAuth resolves a PAT or session cookie to the
12 // bootstrap principal; multi-user auth later changes only how a Principal is 12 // credential's tenant; handlers consume the Principal without caring how it was
13 // RESOLVED, never how handlers consume it. 13 // resolved. Every credential is scoped to exactly one tenant — there is no
14 // fleet-wide principal and no fleet-wide user-facing operation (spec §3/§4).
14 type Principal struct { 15 type Principal struct {
15 // Tenant is the tenant scope this principal acts within. 16 // Tenant is the tenant scope this principal acts within.
16 Tenant string 17 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 } 18 }
24 19
25 // principalKey is the context key for the request principal. 20 // principalKey is the context key for the request principal.
@@ -31,29 +26,23 @@ func withPrincipal(ctx context.Context, p Principal) context.Context {
31 } 26 }
32 27
33 // principalFromContext returns the request's principal. A request that never 28 // principalFromContext returns the request's principal. A request that never
34 // passed adminAuth yields the zero Principal — no tenant, no fleet bit — so 29 // passed userAuth yields the zero Principal — no tenant — so every check
35 // every check downstream fails closed. 30 // downstream fails closed.
36 func principalFromContext(r *http.Request) Principal { 31 func principalFromContext(r *http.Request) Principal {
37 p, _ := r.Context().Value(principalKey{}).(Principal) 32 p, _ := r.Context().Value(principalKey{}).(Principal)
38 return p 33 return p
39 } 34 }
40 35
41 // mayActAs reports whether p may act on a resource owned by tenant. Strict 36 // 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 37 // scope-equality; an empty tenant on either side never matches (resources always
43 // matches (resources always have a tenant; a zero principal must not pair 38 // have a tenant; a zero principal must not pair with a malformed resource via
44 // with a malformed resource via "" == ""). 39 // "" == "").
45 func mayActAs(p Principal, tenant string) bool { 40 func mayActAs(p Principal, tenant string) bool {
46 if p.Fleet {
47 return true
48 }
49 return tenant != "" && p.Tenant == tenant 41 return tenant != "" && p.Tenant == tenant
50 } 42 }
51 43
52 // filterVMs returns only the VMs p may act on; Fleet sees the whole fleet. 44 // filterVMs returns only the VMs p may act on.
53 func filterVMs(p Principal, vms []store.VM) []store.VM { 45 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)) 46 out := make([]store.VM, 0, len(vms))
58 for _, vm := range vms { 47 for _, vm := range vms {
59 if mayActAs(p, vm.Tenant) { 48 if mayActAs(p, vm.Tenant) {
@@ -65,9 +54,6 @@ func filterVMs(p Principal, vms []store.VM) []store.VM {
65 54
66 // filterHosts is filterVMs for hosts. 55 // filterHosts is filterVMs for hosts.
67 func filterHosts(p Principal, hosts []store.Host) []store.Host { 56 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)) 57 out := make([]store.Host, 0, len(hosts))
72 for _, h := range hosts { 58 for _, h := range hosts {
73 if mayActAs(p, h.Tenant) { 59 if mayActAs(p, h.Tenant) {
@@ -76,13 +62,3 @@ func filterHosts(p Principal, hosts []store.Host) []store.Host {
76 } 62 }
77 return out 63 return out
78 } 64 }
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
@@ -21,7 +21,6 @@ func TestMayActAs(t *testing.T) {
21 }{ 21 }{
22 {"same tenant", Principal{Tenant: "t1"}, "t1", true}, 22 {"same tenant", Principal{Tenant: "t1"}, "t1", true},
23 {"different tenant", Principal{Tenant: "t1"}, "t2", false}, 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}, 24 {"zero principal fails closed", Principal{}, "t1", false},
26 // The empty-tenant/empty-target degenerate: a zero principal must not 25 // The empty-tenant/empty-target degenerate: a zero principal must not
27 // accidentally match a (never-valid) empty resource tenant via "" == "". 26 // accidentally match a (never-valid) empty resource tenant via "" == "".
@@ -35,34 +34,13 @@ func TestMayActAs(t *testing.T) {
35 } 34 }
36 35
37 func TestPrincipalFromContextFailsClosed(t *testing.T) { 36 func TestPrincipalFromContextFailsClosed(t *testing.T) {
38 // A request that never went through adminAuth carries no principal: the 37 // A request that never went through userAuth carries no principal: the zero
39 // zero value has no tenant and no fleet bit. 38 // value has no tenant, so every downstream mayActAs check fails closed.
40 r := httptest.NewRequest("GET", "/", nil) 39 r := httptest.NewRequest("GET", "/", nil)
41 p := principalFromContext(r) 40 p := principalFromContext(r)
42 assert.False(t, p.Fleet)
43 assert.Empty(t, p.Tenant) 41 assert.Empty(t, p.Tenant)
44 } 42 }
45 43
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) { 44 func TestFilterByTenant(t *testing.T) {
67 vms := []store.VM{{ID: "a", Tenant: "t1"}, {ID: "b", Tenant: "t2"}} 45 vms := []store.VM{{ID: "a", Tenant: "t1"}, {ID: "b", Tenant: "t2"}}
68 hosts := []store.Host{{ID: "h1", Tenant: "t1"}, {ID: "h2", Tenant: "t2"}} 46 hosts := []store.Host{{ID: "h1", Tenant: "t1"}, {ID: "h2", Tenant: "t2"}}
@@ -75,15 +53,18 @@ func TestFilterByTenant(t *testing.T) {
75 require.Len(t, gotHosts, 1) 53 require.Len(t, gotHosts, 1)
76 assert.Equal(t, "h1", gotHosts[0].ID) 54 assert.Equal(t, "h1", gotHosts[0].ID)
77 55
78 fleet := Principal{Tenant: "t1", Fleet: true} 56 // A different tenant sees only its own; there is no fleet-wide view.
79 assert.Len(t, filterVMs(fleet, vms), 2, "fleet sees everything") 57 other := Principal{Tenant: "t2"}
80 assert.Len(t, filterHosts(fleet, hosts), 2) 58 require.Len(t, filterVMs(other, vms), 1)
59 assert.Equal(t, "b", filterVMs(other, vms)[0].ID)
60 require.Len(t, filterHosts(other, hosts), 1)
61 assert.Equal(t, "h2", filterHosts(other, hosts)[0].ID)
81 } 62 }
82 63
83 // foreignRequest builds a request carrying a Principal scoped to a tenant 64 // foreignRequest builds a request carrying a Principal scoped to a tenant
84 // other than "default" (where testServer/enroll resources land), for calling 65 // other than "default" (where testServer/enroll resources land), for calling
85 // handlers DIRECTLY — bypassing the mux, since adminAuth would otherwise 66 // handlers DIRECTLY — bypassing the mux, since userAuth would otherwise
86 // overwrite the principal with the Fleet bootstrap principal. 67 // overwrite the principal with the credential's own tenant.
87 func foreignRequest(t *testing.T, method, path string, body any) *http.Request { 68 func foreignRequest(t *testing.T, method, path string, body any) *http.Request {
88 t.Helper() 69 t.Helper()
89 var buf bytes.Buffer 70 var buf bytes.Buffer
@@ -100,7 +81,7 @@ func foreignRequest(t *testing.T, method, path string, body any) *http.Request {
100 func TestHandleListVMsFiltersForeignTenant(t *testing.T) { 81 func TestHandleListVMsFiltersForeignTenant(t *testing.T) {
101 ts, _, _, _, a := newServer(t) 82 ts, _, _, _, a := newServer(t)
102 out := enroll(t, ts) 83 out := enroll(t, ts)
103 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 84 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
104 map[string]any{"host_id": out["host_id"], "name": "vm-a"}) 85 map[string]any{"host_id": out["host_id"], "name": "vm-a"})
105 require.Equal(t, 201, resp.StatusCode) 86 require.Equal(t, 201, resp.StatusCode)
106 87
@@ -121,7 +102,7 @@ func TestHandleDeleteVMForeignTenantIsNotFoundAndNoop(t *testing.T) {
121 ts, st, _, _, a := newServer(t) 102 ts, st, _, _, a := newServer(t)
122 out := enroll(t, ts) 103 out := enroll(t, ts)
123 created := map[string]string{} 104 created := map[string]string{}
124 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 105 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
125 map[string]any{"host_id": out["host_id"], "name": "vm-a"}) 106 map[string]any{"host_id": out["host_id"], "name": "vm-a"})
126 require.Equal(t, 201, resp.StatusCode) 107 require.Equal(t, 201, resp.StatusCode)
127 require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) 108 require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
internal/server/api/routes.go
Old New
@@ -13,7 +13,7 @@ type AuthTier int
13 const ( 13 const (
14 AuthPublic AuthTier = iota // no auth (public material / rate-limited) 14 AuthPublic AuthTier = iota // no auth (public material / rate-limited)
15 AuthTicket // one-time short-TTL ticket in ?ticket= (browser streams) 15 AuthTicket // one-time short-TTL ticket in ?ticket= (browser streams)
16 AuthAdmin // Authorization: Bearer <admin token> 16 AuthUser // Authorization: Bearer <PAT>, or eitri_session cookie
17 ) 17 )
18 18
19 // RouteKind is what rides the connection after the status line. 19 // RouteKind is what rides the connection after the status line.
@@ -76,8 +76,8 @@ var routeTable = []Route{
76 }, 76 },
77 // SSE live status. EventSource cannot set headers, so the stream 77 // SSE live status. EventSource cannot set headers, so the stream
78 // authenticates with a one-time short-TTL ticket minted via the 78 // authenticates with a one-time short-TTL ticket minted via the
79 // admin-authenticated POST /api/v1/stream-tickets — the long-lived admin 79 // user-authenticated POST /api/v1/stream-tickets — the caller's PAT or
80 // token never rides in a URL (proxy/access logs). 80 // session never rides in a URL (proxy/access logs).
81 { 81 {
82 Method: "GET", 82 Method: "GET",
83 Path: "/api/v1/events", 83 Path: "/api/v1/events",
@@ -90,8 +90,8 @@ var routeTable = []Route{
90 handler: (*API).handleEvents, 90 handler: (*API).handleEvents,
91 }, 91 },
92 // Console WS: ticket-authed like the SSE stream (browsers cannot set 92 // Console WS: ticket-authed like the SSE stream (browsers cannot set
93 // headers on a WebSocket dial). More specific than the /api/v1/ admin 93 // headers on a WebSocket dial). More specific than the /api/v1/
94 // subtree, so ServeMux routes it here without admin auth. 94 // user-authenticated subtree, so ServeMux routes it here without that auth.
95 { 95 {
96 Method: "GET", 96 Method: "GET",
97 Path: "/api/v1/vms/{id}/console/ws", 97 Path: "/api/v1/vms/{id}/console/ws",
@@ -103,11 +103,11 @@ var routeTable = []Route{
103 handler: (*API).handleConsoleWS, 103 handler: (*API).handleConsoleWS,
104 }, 104 },
105 105
106 // Admin routes — wrapped with auth middleware. 106 // User routes — wrapped with the PAT/session auth middleware.
107 { 107 {
108 Method: "POST", 108 Method: "POST",
109 Path: "/api/v1/enroll-tokens", 109 Path: "/api/v1/enroll-tokens",
110 Auth: AuthAdmin, 110 Auth: AuthUser,
111 Kind: KindJSON, 111 Kind: KindJSON,
112 Response: (*types.EnrollTokenResponse)(nil), 112 Response: (*types.EnrollTokenResponse)(nil),
113 Success: http.StatusCreated, 113 Success: http.StatusCreated,
@@ -117,7 +117,7 @@ var routeTable = []Route{
117 { 117 {
118 Method: "GET", 118 Method: "GET",
119 Path: "/api/v1/hosts", 119 Path: "/api/v1/hosts",
120 Auth: AuthAdmin, 120 Auth: AuthUser,
121 Kind: KindJSON, 121 Kind: KindJSON,
122 Response: []types.Host(nil), 122 Response: []types.Host(nil),
123 Success: http.StatusOK, 123 Success: http.StatusOK,
@@ -127,7 +127,7 @@ var routeTable = []Route{
127 { 127 {
128 Method: "DELETE", 128 Method: "DELETE",
129 Path: "/api/v1/hosts/{id}", 129 Path: "/api/v1/hosts/{id}",
130 Auth: AuthAdmin, 130 Auth: AuthUser,
131 Kind: KindJSON, 131 Kind: KindJSON,
132 Success: http.StatusAccepted, 132 Success: http.StatusAccepted,
133 Query: []QueryParam{{Name: "force", Doc: "purge VM rows and remove the host immediately (dead hardware escape hatch)"}}, 133 Query: []QueryParam{{Name: "force", Doc: "purge VM rows and remove the host immediately (dead hardware escape hatch)"}},
@@ -137,7 +137,7 @@ var routeTable = []Route{
137 { 137 {
138 Method: "POST", 138 Method: "POST",
139 Path: "/api/v1/hosts/{id}/revoke-credential", 139 Path: "/api/v1/hosts/{id}/revoke-credential",
140 Auth: AuthAdmin, 140 Auth: AuthUser,
141 Kind: KindJSON, 141 Kind: KindJSON,
142 Success: http.StatusNoContent, 142 Success: http.StatusNoContent,
143 Doc: "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled.", 143 Doc: "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled.",
@@ -146,7 +146,7 @@ var routeTable = []Route{
146 { 146 {
147 Method: "POST", 147 Method: "POST",
148 Path: "/api/v1/hosts/{id}/upgrade-agent", 148 Path: "/api/v1/hosts/{id}/upgrade-agent",
149 Auth: AuthAdmin, 149 Auth: AuthUser,
150 Kind: KindJSON, 150 Kind: KindJSON,
151 Success: http.StatusAccepted, 151 Success: http.StatusAccepted,
152 Doc: "Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout).", 152 Doc: "Offer the host's agent a self-upgrade to the latest known release (per-host, human-controlled rollout).",
@@ -155,7 +155,7 @@ var routeTable = []Route{
155 { 155 {
156 Method: "GET", 156 Method: "GET",
157 Path: "/api/v1/audit", 157 Path: "/api/v1/audit",
158 Auth: AuthAdmin, 158 Auth: AuthUser,
159 Kind: KindJSON, 159 Kind: KindJSON,
160 Response: []types.AuditEvent(nil), 160 Response: []types.AuditEvent(nil),
161 Success: http.StatusOK, 161 Success: http.StatusOK,
@@ -166,7 +166,7 @@ var routeTable = []Route{
166 { 166 {
167 Method: "POST", 167 Method: "POST",
168 Path: "/api/v1/stream-tickets", 168 Path: "/api/v1/stream-tickets",
169 Auth: AuthAdmin, 169 Auth: AuthUser,
170 Kind: KindJSON, 170 Kind: KindJSON,
171 Response: (*types.StreamTicketResponse)(nil), 171 Response: (*types.StreamTicketResponse)(nil),
172 Success: http.StatusCreated, 172 Success: http.StatusCreated,
@@ -176,7 +176,7 @@ var routeTable = []Route{
176 { 176 {
177 Method: "GET", 177 Method: "GET",
178 Path: "/api/v1/vms", 178 Path: "/api/v1/vms",
179 Auth: AuthAdmin, 179 Auth: AuthUser,
180 Kind: KindJSON, 180 Kind: KindJSON,
181 Response: []types.VM(nil), 181 Response: []types.VM(nil),
182 Success: http.StatusOK, 182 Success: http.StatusOK,
@@ -186,7 +186,7 @@ var routeTable = []Route{
186 { 186 {
187 Method: "POST", 187 Method: "POST",
188 Path: "/api/v1/vms", 188 Path: "/api/v1/vms",
189 Auth: AuthAdmin, 189 Auth: AuthUser,
190 Kind: KindJSON, 190 Kind: KindJSON,
191 Request: (*types.CreateVMRequest)(nil), 191 Request: (*types.CreateVMRequest)(nil),
192 Response: (*types.CreateVMResponse)(nil), 192 Response: (*types.CreateVMResponse)(nil),
@@ -197,7 +197,7 @@ var routeTable = []Route{
197 { 197 {
198 Method: "PATCH", 198 Method: "PATCH",
199 Path: "/api/v1/vms/{id}", 199 Path: "/api/v1/vms/{id}",
200 Auth: AuthAdmin, 200 Auth: AuthUser,
201 Kind: KindJSON, 201 Kind: KindJSON,
202 Request: (*types.PatchVMRequest)(nil), 202 Request: (*types.PatchVMRequest)(nil),
203 Success: http.StatusNoContent, 203 Success: http.StatusNoContent,
@@ -207,7 +207,7 @@ var routeTable = []Route{
207 { 207 {
208 Method: "DELETE", 208 Method: "DELETE",
209 Path: "/api/v1/vms/{id}", 209 Path: "/api/v1/vms/{id}",
210 Auth: AuthAdmin, 210 Auth: AuthUser,
211 Kind: KindJSON, 211 Kind: KindJSON,
212 Success: http.StatusNoContent, 212 Success: http.StatusNoContent,
213 Doc: "Tombstone a VM for teardown; restorable within the grace window via restore.", 213 Doc: "Tombstone a VM for teardown; restorable within the grace window via restore.",
@@ -216,7 +216,7 @@ var routeTable = []Route{
216 { 216 {
217 Method: "POST", 217 Method: "POST",
218 Path: "/api/v1/vms/{id}/restore", 218 Path: "/api/v1/vms/{id}/restore",
219 Auth: AuthAdmin, 219 Auth: AuthUser,
220 Kind: KindJSON, 220 Kind: KindJSON,
221 Success: http.StatusNoContent, 221 Success: http.StatusNoContent,
222 Doc: "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest.", 222 Doc: "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest.",
@@ -225,7 +225,7 @@ var routeTable = []Route{
225 { 225 {
226 Method: "GET", 226 Method: "GET",
227 Path: "/api/v1/vms/{id}/events", 227 Path: "/api/v1/vms/{id}/events",
228 Auth: AuthAdmin, 228 Auth: AuthUser,
229 Kind: KindJSON, 229 Kind: KindJSON,
230 Response: []types.AuditEvent(nil), 230 Response: []types.AuditEvent(nil),
231 Success: http.StatusOK, 231 Success: http.StatusOK,
@@ -238,7 +238,7 @@ var routeTable = []Route{
238 { 238 {
239 Method: "POST", 239 Method: "POST",
240 Path: "/api/v1/tenants/{tenant}/user-cas", 240 Path: "/api/v1/tenants/{tenant}/user-cas",
241 Auth: AuthAdmin, 241 Auth: AuthUser,
242 Kind: KindJSON, 242 Kind: KindJSON,
243 Request: (*types.UserCARequest)(nil), 243 Request: (*types.UserCARequest)(nil),
244 Response: (*types.UserCAUploadResponse)(nil), 244 Response: (*types.UserCAUploadResponse)(nil),
@@ -249,7 +249,7 @@ var routeTable = []Route{
249 { 249 {
250 Method: "GET", 250 Method: "GET",
251 Path: "/api/v1/tenants/{tenant}/user-cas", 251 Path: "/api/v1/tenants/{tenant}/user-cas",
252 Auth: AuthAdmin, 252 Auth: AuthUser,
253 Kind: KindJSON, 253 Kind: KindJSON,
254 Response: []types.UserCA(nil), 254 Response: []types.UserCA(nil),
255 Success: http.StatusOK, 255 Success: http.StatusOK,
@@ -259,7 +259,7 @@ var routeTable = []Route{
259 { 259 {
260 Method: "DELETE", 260 Method: "DELETE",
261 Path: "/api/v1/tenants/{tenant}/user-cas", 261 Path: "/api/v1/tenants/{tenant}/user-cas",
262 Auth: AuthAdmin, 262 Auth: AuthUser,
263 Kind: KindJSON, 263 Kind: KindJSON,
264 Request: (*types.UserCARequest)(nil), 264 Request: (*types.UserCARequest)(nil),
265 Success: http.StatusNoContent, 265 Success: http.StatusNoContent,
@@ -272,7 +272,7 @@ var routeTable = []Route{
272 { 272 {
273 Method: "POST", 273 Method: "POST",
274 Path: "/api/v1/ssh-certs/revoke", 274 Path: "/api/v1/ssh-certs/revoke",
275 Auth: AuthAdmin, 275 Auth: AuthUser,
276 Kind: KindJSON, 276 Kind: KindJSON,
277 Request: (*types.RevokeSSHCertRequest)(nil), 277 Request: (*types.RevokeSSHCertRequest)(nil),
278 Success: http.StatusNoContent, 278 Success: http.StatusNoContent,
@@ -282,11 +282,54 @@ var routeTable = []Route{
282 { 282 {
283 Method: "GET", 283 Method: "GET",
284 Path: "/api/v1/ssh-certs/revoked", 284 Path: "/api/v1/ssh-certs/revoked",
285 Auth: AuthAdmin, 285 Auth: AuthUser,
286 Kind: KindJSON, 286 Kind: KindJSON,
287 Response: []types.RevokedCert(nil), 287 Response: []types.RevokedCert(nil),
288 Success: http.StatusOK, 288 Success: http.StatusOK,
289 Doc: "List revoked SSH user certificate serials (with reason and time), newest first.", 289 Doc: "List revoked SSH user certificate serials (with reason and time), newest first.",
290 handler: (*API).handleListRevokedSSHCerts, 290 handler: (*API).handleListRevokedSSHCerts,
291 }, 291 },
292 // Identity + personal access token lifecycle. /me renders the signed-in
293 // identity; PATs are the non-browser API credential (minted with a session,
294 // value shown once, listed as metadata only, revoked by id).
295 {
296 Method: "GET",
297 Path: "/api/v1/me",
298 Auth: AuthUser,
299 Kind: KindJSON,
300 Response: (*types.Me)(nil),
301 Success: http.StatusOK,
302 Doc: "The signed-in identity: the caller's tenant handle and bound email.",
303 handler: (*API).handleMe,
304 },
305 {
306 Method: "POST",
307 Path: "/api/v1/tokens",
308 Auth: AuthUser,
309 Kind: KindJSON,
310 Request: (*types.CreateAPITokenRequest)(nil),
311 Response: (*types.CreateAPITokenResponse)(nil),
312 Success: http.StatusCreated,
313 Doc: "Mint a personal access token; the secret is returned exactly once. An optional TTL sets expiry (0 = non-expiring).",
314 handler: (*API).handleCreateAPIToken,
315 },
316 {
317 Method: "GET",
318 Path: "/api/v1/tokens",
319 Auth: AuthUser,
320 Kind: KindJSON,
321 Response: []types.APIToken(nil),
322 Success: http.StatusOK,
323 Doc: "List the tenant's personal access tokens (metadata only — never the secret), newest first.",
324 handler: (*API).handleListAPITokens,
325 },
326 {
327 Method: "DELETE",
328 Path: "/api/v1/tokens/{id}",
329 Auth: AuthUser,
330 Kind: KindJSON,
331 Success: http.StatusNoContent,
332 Doc: "Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak).",
333 handler: (*API).handleRevokeAPIToken,
334 },
292 } 335 }
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 = 22 37 const wantRoutes = 26
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/snapshot_hub.go
Old New
@@ -6,54 +6,63 @@ import (
6 "time" 6 "time"
7 ) 7 )
8 8
9 // snapshotHub computes the fleet SSE snapshot ONCE, centrally, and fans the 9 // snapshotHub computes the SSE snapshot centrally and fans it to connected
10 // identical bytes out to every connected client. Before this, each SSE client 10 // clients, PER TENANT. Before this, each SSE client goroutine ran its own 1s
11 // goroutine ran its own 1s ticker and marshalled its own snapshot (a DB tx + 11 // ticker and marshalled its own snapshot (a DB tx + full build + json.Marshal),
12 // full build + json.Marshal), so M clients meant M full snapshots every second. 12 // so M clients meant M full snapshots every second. The hub collapses that to
13 // The hub collapses that to one build per tick/wake regardless of client count. 13 // one store read per tick plus one marshal per DISTINCT SUBSCRIBED TENANT,
14 // regardless of how many clients each tenant has.
15 //
16 // Isolation: a subscriber registers with its tenant and only ever receives that
17 // tenant's bytes — the hub filters the fleet snapshot into a separate payload
18 // per tenant, so no cross-tenant host/VM/metric can reach a connection.
14 // 19 //
15 // Fan-out is latest-wins / non-blocking: each subscriber holds a buffer-1 20 // Fan-out is latest-wins / non-blocking: each subscriber holds a buffer-1
16 // channel carrying only the newest snapshot. A lagging client drops intermediate 21 // channel carrying only the newest snapshot for its tenant. A lagging client
17 // snapshots (correct — SSE state is a full snapshot, so the newest supersedes any 22 // drops intermediate snapshots (correct — SSE state is a full snapshot, so the
18 // it missed), and the central loop NEVER blocks on a slow reader, so a stuck 23 // newest supersedes any it missed), and the central loop NEVER blocks on a slow
19 // client can neither wedge the hub nor grow memory unbounded. 24 // reader, so a stuck client can neither wedge the hub nor grow memory unbounded.
20 type snapshotHub struct { 25 type snapshotHub struct {
21 build func() ([]byte, error) // marshals the current snapshot (a.marshalSnapshot) 26 // build reads the store snapshot ONCE and returns one marshalled payload per
22 wake <-chan struct{} // desired-state wake source (single subscription) 27 // requested tenant (a.marshalSnapshots). The hub passes it the distinct set
23 notifUnsub func() // releases the notifier subscription on Close 28 // of currently-subscribed tenants.
29 build func(tenants []string) (map[string][]byte, error)
30 wake <-chan struct{} // desired-state wake source (single subscription)
31 notifUnsub func() // releases the notifier subscription on Close
24 32
25 mu sync.Mutex 33 mu sync.Mutex
26 current []byte // latest marshalled snapshot; delivered to new subscribers immediately 34 // current holds the latest marshalled snapshot per tenant, delivered to new
27 subs map[chan []byte]struct{} 35 // subscribers of that tenant immediately.
36 current map[string][]byte
37 // subs maps each subscriber channel to the tenant it is scoped to.
38 subs map[chan []byte]string
28 39
29 stop chan struct{} 40 stop chan struct{}
30 done chan struct{} 41 done chan struct{}
31 stopOnce sync.Once 42 stopOnce sync.Once
32 } 43 }
33 44
34 // newSnapshotHub builds the hub, subscribes to the notifier SYNCHRONOUSLY (so no 45 // newSnapshotHub builds the hub and subscribes to the notifier SYNCHRONOUSLY (so
35 // wake can be lost in the window before run's goroutine is scheduled), and 46 // no wake can be lost in the window before run's goroutine is scheduled). Unlike
36 // computes the INITIAL snapshot, so a client that subscribes before the first 47 // the pre-tenant hub it computes NO initial snapshot — payloads are per-tenant
37 // tick still gets current state. The caller must launch run() (once). 48 // and no tenant is known until a client subscribes, so the initial build for a
38 func newSnapshotHub(build func() ([]byte, error), notif *notifier) *snapshotHub { 49 // tenant happens on its first subscribe. The caller must launch run() (once).
50 func newSnapshotHub(build func(tenants []string) (map[string][]byte, error), notif *notifier) *snapshotHub {
39 wake, unsub := notif.subscribe() 51 wake, unsub := notif.subscribe()
40 h := &snapshotHub{ 52 return &snapshotHub{
41 build: build, 53 build: build,
42 wake: wake, 54 wake: wake,
43 notifUnsub: unsub, 55 notifUnsub: unsub,
44 subs: make(map[chan []byte]struct{}), 56 current: make(map[string][]byte),
57 subs: make(map[chan []byte]string),
45 stop: make(chan struct{}), 58 stop: make(chan struct{}),
46 done: make(chan struct{}), 59 done: make(chan struct{}),
47 } 60 }
48 if b, err := build(); err == nil {
49 h.current = b
50 }
51 return h
52 } 61 }
53 62
54 // run is the hub's single goroutine: it recomputes the snapshot on a 1s tick (to 63 // run is the hub's single goroutine: it recomputes on a 1s tick (to catch
55 // catch agent-reported actual-state changes) or on a desired-state wake, at most 64 // agent-reported actual-state changes) or on a desired-state wake, at most once
56 // once per event. It returns when Close is called. 65 // per event. It returns when Close is called.
57 func (h *snapshotHub) run() { 66 func (h *snapshotHub) run() {
58 defer close(h.done) 67 defer close(h.done)
59 tick := time.NewTicker(time.Second) 68 tick := time.NewTicker(time.Second)
@@ -70,21 +79,60 @@ func (h *snapshotHub) run() {
70 } 79 }
71 } 80 }
72 81
73 // recompute builds the snapshot once and, if the bytes changed, stores them and 82 // subscribedTenants returns the distinct set of tenants with at least one live
74 // fans them to all subscribers. Unchanged bytes are suppressed (no push), matching 83 // subscriber. Caller must hold h.mu.
75 // the prior per-client diff behaviour. 84 func (h *snapshotHub) subscribedTenants() []string {
85 seen := make(map[string]struct{}, len(h.subs))
86 out := make([]string, 0, len(h.subs))
87 for _, tn := range h.subs {
88 if _, ok := seen[tn]; ok {
89 continue
90 }
91 seen[tn] = struct{}{}
92 out = append(out, tn)
93 }
94 return out
95 }
96
97 // recompute reads the store ONCE, marshals a payload per subscribed tenant, and
98 // for each tenant whose bytes changed, stores them and fans them to that tenant's
99 // subscribers. Unchanged bytes are suppressed (no push), matching the prior
100 // per-client diff behaviour.
76 func (h *snapshotHub) recompute() { 101 func (h *snapshotHub) recompute() {
77 b, err := h.build() 102 h.mu.Lock()
103 tenants := h.subscribedTenants()
104 h.mu.Unlock()
105 if len(tenants) == 0 {
106 return // no subscribers: nothing to build
107 }
108 payloads, err := h.build(tenants)
78 if err != nil { 109 if err != nil {
79 return // transient store error: keep serving the last good snapshot 110 return // transient store error: keep serving the last good snapshots
80 } 111 }
81 h.mu.Lock() 112 h.mu.Lock()
82 defer h.mu.Unlock() 113 defer h.mu.Unlock()
83 if bytes.Equal(b, h.current) { 114 // A tenant can lose its last subscriber between snapshotting tenants above and
84 return 115 // re-taking the lock; only cache/fan payloads whose tenant still has a live
116 // subscriber, so h.current never accumulates entries for departed tenants.
117 live := make(map[string]struct{})
118 for _, tn := range h.subs {
119 live[tn] = struct{}{}
120 }
121 changed := make(map[string]struct{}, len(payloads))
122 for tn, b := range payloads {
123 if _, ok := live[tn]; !ok {
124 continue
125 }
126 if !bytes.Equal(b, h.current[tn]) {
127 h.current[tn] = b
128 changed[tn] = struct{}{}
129 }
85 } 130 }
86 h.current = b 131 for ch, tn := range h.subs {
87 for ch := range h.subs { 132 if _, ok := changed[tn]; !ok {
133 continue
134 }
135 b := h.current[tn]
88 // Latest-wins: drain any stale pending snapshot, then send the newest. 136 // Latest-wins: drain any stale pending snapshot, then send the newest.
89 // Both sends are non-blocking; because subscribers only ever receive 137 // Both sends are non-blocking; because subscribers only ever receive
90 // (never send) and we hold h.mu, the post-drain buffer has room and the 138 // (never send) and we hold h.mu, the post-drain buffer has room and the
@@ -100,24 +148,57 @@ func (h *snapshotHub) recompute() {
100 } 148 }
101 } 149 }
102 150
103 // subscribe registers a client and immediately delivers the CURRENT snapshot so 151 // subscribe registers a client scoped to tenant and immediately delivers that
104 // a new connection gets initial state without doing its own marshal. It returns 152 // tenant's CURRENT snapshot so a new connection gets initial state without doing
105 // the client's buffer-1 channel and an unsubscribe func. 153 // its own marshal. The first subscriber for a tenant computes that tenant's
106 func (h *snapshotHub) subscribe() (<-chan []byte, func()) { 154 // payload on demand (outside the lock — build reads the store). It returns the
155 // client's buffer-1 channel and an unsubscribe func.
156 func (h *snapshotHub) subscribe(tenant string) (<-chan []byte, func()) {
107 ch := make(chan []byte, 1) 157 ch := make(chan []byte, 1)
158
159 h.mu.Lock()
160 cur, ok := h.current[tenant]
161 h.mu.Unlock()
162 if !ok {
163 // First subscriber for this tenant: build its initial payload now so the
164 // connection gets state without waiting for the next tick. A build error
165 // just means no initial frame — the next recompute will deliver one.
166 if payloads, err := h.build([]string{tenant}); err == nil {
167 cur = payloads[tenant]
168 }
169 }
170
108 h.mu.Lock() 171 h.mu.Lock()
109 if h.current != nil { 172 if cur != nil {
110 ch <- h.current // buffer-1 and empty ⇒ never blocks under the lock 173 h.current[tenant] = cur
174 ch <- cur // buffer-1 and empty ⇒ never blocks under the lock
111 } 175 }
112 h.subs[ch] = struct{}{} 176 h.subs[ch] = tenant
113 h.mu.Unlock() 177 h.mu.Unlock()
178
114 return ch, func() { 179 return ch, func() {
115 h.mu.Lock() 180 h.mu.Lock()
116 delete(h.subs, ch) 181 delete(h.subs, ch)
182 // Drop the tenant's cached payload once its last subscriber leaves, so a
183 // fleet of transient tenants cannot grow h.current unboundedly.
184 if !h.tenantHasSubs(tenant) {
185 delete(h.current, tenant)
186 }
117 h.mu.Unlock() 187 h.mu.Unlock()
118 } 188 }
119 } 189 }
120 190
191 // tenantHasSubs reports whether any live subscriber is scoped to tenant. Caller
192 // must hold h.mu.
193 func (h *snapshotHub) tenantHasSubs(tenant string) bool {
194 for _, tn := range h.subs {
195 if tn == tenant {
196 return true
197 }
198 }
199 return false
200 }
201
121 // Close stops the hub goroutine, waits for it to exit, and releases the notifier 202 // Close stops the hub goroutine, waits for it to exit, and releases the notifier
122 // subscription. Idempotent — safe to call more than once (e.g. a shutdown path 203 // subscription. Idempotent — safe to call more than once (e.g. a shutdown path
123 // plus a test cleanup). 204 // plus a test cleanup).
internal/server/api/snapshot_hub_test.go
Old New
@@ -23,41 +23,59 @@ func waitFor(t *testing.T, d time.Duration, cond func() bool) {
23 require.True(t, cond(), "condition not met within %s", d) 23 require.True(t, cond(), "condition not met within %s", d)
24 } 24 }
25 25
26 // constBuild returns a build func that hands every requested tenant the same
27 // bytes b (ignoring the tenant), counting how many times it is called.
28 func constBuild(b string, calls *atomic.Int64) func([]string) (map[string][]byte, error) {
29 return func(tenants []string) (map[string][]byte, error) {
30 calls.Add(1)
31 out := make(map[string][]byte, len(tenants))
32 for _, tn := range tenants {
33 out[tn] = []byte(b)
34 }
35 return out, nil
36 }
37 }
38
26 // TestSnapshotHubCloseIdempotent proves Close can be called more than once 39 // TestSnapshotHubCloseIdempotent proves Close can be called more than once
27 // without panicking (close-of-closed-channel) — a shutdown path plus a test 40 // without panicking (close-of-closed-channel) — a shutdown path plus a test
28 // cleanup must both be safe. 41 // cleanup must both be safe.
29 func TestSnapshotHubCloseIdempotent(t *testing.T) { 42 func TestSnapshotHubCloseIdempotent(t *testing.T) {
30 h := newSnapshotHub(func() ([]byte, error) { return []byte("x"), nil }, newNotifier()) 43 var calls atomic.Int64
44 h := newSnapshotHub(constBuild("x", &calls), newNotifier())
31 go h.run() 45 go h.run()
32 h.Close() 46 h.Close()
33 h.Close() // must not panic 47 h.Close() // must not panic
34 } 48 }
35 49
36 // TestSnapshotHubSingleBuildFanout proves the whole point of the hub: one 50 // TestSnapshotHubSingleBuildFanout proves the whole point of the hub: one
37 // underlying snapshot build is fanned to every subscriber as identical bytes, 51 // underlying build per tick is fanned to every subscriber OF A TENANT as
38 // and a wake drives exactly one recompute regardless of how many clients are 52 // identical bytes, and a wake drives exactly one build regardless of how many
39 // attached (M clients must NOT cause M builds). 53 // clients are attached (M clients on one tenant must NOT cause M builds).
40 func TestSnapshotHubSingleBuildFanout(t *testing.T) { 54 func TestSnapshotHubSingleBuildFanout(t *testing.T) {
41 var builds atomic.Int64 55 var builds atomic.Int64
42 build := func() ([]byte, error) { 56 build := func(tenants []string) (map[string][]byte, error) {
43 n := builds.Add(1) 57 n := builds.Add(1)
44 return []byte(fmt.Sprintf("snap-%d", n)), nil 58 out := make(map[string][]byte, len(tenants))
59 for _, tn := range tenants {
60 out[tn] = []byte(fmt.Sprintf("snap-%d", n))
61 }
62 return out, nil
45 } 63 }
46 notif := newNotifier() 64 notif := newNotifier()
47 h := newSnapshotHub(build, notif) // computes the initial snapshot (build #1) 65 h := newSnapshotHub(build, notif)
48 require.Equal(t, int64(1), builds.Load())
49 go h.run() 66 go h.run()
50 defer h.Close() 67 defer h.Close()
51 68
52 // Three subscribers each get the CURRENT snapshot immediately, without 69 // Three subscribers on the SAME tenant. The first triggers the initial build
53 // triggering their own build. 70 // for that tenant; the other two reuse the cached current.
54 const n = 3 71 const n = 3
55 chans := make([]<-chan []byte, n) 72 chans := make([]<-chan []byte, n)
56 for i := range n { 73 for i := range n {
57 ch, unsub := h.subscribe() 74 ch, unsub := h.subscribe("default")
58 defer unsub() 75 defer unsub()
59 chans[i] = ch 76 chans[i] = ch
60 } 77 }
78 require.Equal(t, int64(1), builds.Load(), "only the first subscriber of a tenant builds")
61 for i, ch := range chans { 79 for i, ch := range chans {
62 select { 80 select {
63 case b := <-ch: 81 case b := <-ch:
@@ -66,12 +84,8 @@ func TestSnapshotHubSingleBuildFanout(t *testing.T) {
66 t.Fatalf("subscriber %d got no initial snapshot", i) 84 t.Fatalf("subscriber %d got no initial snapshot", i)
67 } 85 }
68 } 86 }
69 // Still exactly one build despite three subscribers.
70 assert.Equal(t, int64(1), builds.Load(), "subscribing must not build")
71 87
72 // One wake ⇒ exactly one recompute, fanned identically to all three. 88 // One wake ⇒ exactly one build (the single subscribed tenant), fanned to all three.
73 // (The 1s ticker cannot fire within this sub-second window, so builds is
74 // driven solely by the wake here.)
75 notif.notify() 89 notif.notify()
76 waitFor(t, time.Second, func() bool { return builds.Load() == 2 }) 90 waitFor(t, time.Second, func() bool { return builds.Load() == 2 })
77 for i, ch := range chans { 91 for i, ch := range chans {
@@ -86,20 +100,50 @@ func TestSnapshotHubSingleBuildFanout(t *testing.T) {
86 "one wake must drive exactly one build regardless of client count") 100 "one wake must drive exactly one build regardless of client count")
87 } 101 }
88 102
89 // TestSnapshotHubSuppressesUnchanged pins change-suppression: identical bytes 103 // TestSnapshotHubPerTenantPayloads proves each subscriber only ever receives its
90 // on recompute produce no push to subscribers. 104 // OWN tenant's bytes: a single recompute marshals a distinct payload per
105 // subscribed tenant and fans each to the right connection.
106 func TestSnapshotHubPerTenantPayloads(t *testing.T) {
107 // build echoes the tenant name into the payload so a mixup is visible.
108 build := func(tenants []string) (map[string][]byte, error) {
109 out := make(map[string][]byte, len(tenants))
110 for _, tn := range tenants {
111 out[tn] = []byte("payload-for-" + tn)
112 }
113 return out, nil
114 }
115 h := newSnapshotHub(build, newNotifier())
116 go h.run()
117 defer h.Close()
118
119 chA, unsubA := h.subscribe("alpha")
120 defer unsubA()
121 chB, unsubB := h.subscribe("beta")
122 defer unsubB()
123
124 for _, tc := range []struct {
125 ch <-chan []byte
126 want string
127 }{{chA, "payload-for-alpha"}, {chB, "payload-for-beta"}} {
128 select {
129 case b := <-tc.ch:
130 assert.Equal(t, tc.want, string(b))
131 case <-time.After(time.Second):
132 t.Fatalf("no initial snapshot for %s", tc.want)
133 }
134 }
135 }
136
137 // TestSnapshotHubSuppressesUnchanged pins change-suppression: identical bytes on
138 // recompute produce no push to a tenant's subscribers.
91 func TestSnapshotHubSuppressesUnchanged(t *testing.T) { 139 func TestSnapshotHubSuppressesUnchanged(t *testing.T) {
92 var builds atomic.Int64 140 var builds atomic.Int64
93 build := func() ([]byte, error) {
94 builds.Add(1)
95 return []byte("constant"), nil // never changes
96 }
97 notif := newNotifier() 141 notif := newNotifier()
98 h := newSnapshotHub(build, notif) 142 h := newSnapshotHub(constBuild("constant", &builds), notif)
99 go h.run() 143 go h.run()
100 defer h.Close() 144 defer h.Close()
101 145
102 ch, unsub := h.subscribe() 146 ch, unsub := h.subscribe("default")
103 defer unsub() 147 defer unsub()
104 // Drain the immediate initial delivery. 148 // Drain the immediate initial delivery.
105 select { 149 select {
@@ -124,8 +168,13 @@ func TestSnapshotHubSuppressesUnchanged(t *testing.T) {
124 // the central loop never blocks on it. 168 // the central loop never blocks on it.
125 func TestSnapshotHubLatestWins(t *testing.T) { 169 func TestSnapshotHubLatestWins(t *testing.T) {
126 var n atomic.Int64 170 var n atomic.Int64
127 build := func() ([]byte, error) { 171 build := func(tenants []string) (map[string][]byte, error) {
128 return []byte(fmt.Sprintf("v%d", n.Add(1))), nil 172 v := n.Add(1)
173 out := make(map[string][]byte, len(tenants))
174 for _, tn := range tenants {
175 out[tn] = []byte(fmt.Sprintf("v%d", v))
176 }
177 return out, nil
129 } 178 }
130 notif := newNotifier() 179 notif := newNotifier()
131 h := newSnapshotHub(build, notif) 180 h := newSnapshotHub(build, notif)
@@ -135,7 +184,7 @@ func TestSnapshotHubLatestWins(t *testing.T) {
135 // Subscribe but never read: the buffer-1 channel already holds the initial 184 // Subscribe but never read: the buffer-1 channel already holds the initial
136 // snapshot. Further recomputes must overwrite it (drain-stale-then-send), 185 // snapshot. Further recomputes must overwrite it (drain-stale-then-send),
137 // never block the hub. 186 // never block the hub.
138 ch, unsub := h.subscribe() 187 ch, unsub := h.subscribe("default")
139 defer unsub() 188 defer unsub()
140 189
141 for i := range 5 { 190 for i := range 5 {
@@ -151,3 +200,24 @@ func TestSnapshotHubLatestWins(t *testing.T) {
151 t.Fatal("slow subscriber holds nothing") 200 t.Fatal("slow subscriber holds nothing")
152 } 201 }
153 } 202 }
203
204 // TestSnapshotHubDropsTenantOnLastUnsub proves current is pruned when a tenant's
205 // last subscriber leaves, so transient tenants cannot grow the map unboundedly.
206 func TestSnapshotHubDropsTenantOnLastUnsub(t *testing.T) {
207 var calls atomic.Int64
208 h := newSnapshotHub(constBuild("x", &calls), newNotifier())
209 go h.run()
210 defer h.Close()
211
212 _, unsub := h.subscribe("ephemeral")
213 h.mu.Lock()
214 _, present := h.current["ephemeral"]
215 h.mu.Unlock()
216 require.True(t, present, "subscribing must cache the tenant's payload")
217
218 unsub()
219 h.mu.Lock()
220 _, present = h.current["ephemeral"]
221 h.mu.Unlock()
222 assert.False(t, present, "last unsubscribe must drop the tenant's cached payload")
223 }
internal/server/api/spec/spec.go
Old New
@@ -54,7 +54,7 @@ func Generate() ([]byte, error) {
54 "components": map[string]any{ 54 "components": map[string]any{
55 "schemas": g.schemas, 55 "schemas": g.schemas,
56 "securitySchemes": map[string]any{ 56 "securitySchemes": map[string]any{
57 "adminToken": map[string]any{"type": "http", "scheme": "bearer"}, 57 "patToken": map[string]any{"type": "http", "scheme": "bearer"},
58 }, 58 },
59 }, 59 },
60 } 60 }
@@ -101,8 +101,8 @@ func (g *generator) operation(r api.Route) map[string]any {
101 op["parameters"] = params 101 op["parameters"] = params
102 } 102 }
103 103
104 if r.Auth == api.AuthAdmin { 104 if r.Auth == api.AuthUser {
105 op["security"] = []any{map[string]any{"adminToken": []any{}}} 105 op["security"] = []any{map[string]any{"patToken": []any{}}}
106 } 106 }
107 107
108 if r.Request != nil { 108 if r.Request != nil {
internal/server/api/spec/spec_test.go
Old New
@@ -144,15 +144,15 @@ func TestSecurity(t *testing.T) {
144 for _, r := range api.Routes() { 144 for _, r := range api.Routes() {
145 op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method)).(map[string]any) 145 op := dig(t, doc, "paths", r.Path, strings.ToLower(r.Method)).(map[string]any)
146 sec, has := op["security"] 146 sec, has := op["security"]
147 if r.Auth == api.AuthAdmin { 147 if r.Auth == api.AuthUser {
148 want := []any{map[string]any{"adminToken": []any{}}} 148 want := []any{map[string]any{"patToken": []any{}}}
149 if !has { 149 if !has {
150 t.Errorf("%s %s: admin route missing security", r.Method, r.Path) 150 t.Errorf("%s %s: user route missing security", r.Method, r.Path)
151 } else if wantJSON, _ := json.Marshal(want); string(mustJSON(t, sec)) != string(wantJSON) { 151 } else if wantJSON, _ := json.Marshal(want); string(mustJSON(t, sec)) != string(wantJSON) {
152 t.Errorf("%s %s: security = %v", r.Method, r.Path, sec) 152 t.Errorf("%s %s: security = %v", r.Method, r.Path, sec)
153 } 153 }
154 } else if has { 154 } else if has {
155 t.Errorf("%s %s: non-admin route carries security %v", r.Method, r.Path, sec) 155 t.Errorf("%s %s: unauthenticated route carries security %v", r.Method, r.Path, sec)
156 } 156 }
157 } 157 }
158 } 158 }
@@ -228,9 +228,9 @@ func TestDefaultErrorResponse(t *testing.T) {
228 228
229 func TestSecuritySchemes(t *testing.T) { 229 func TestSecuritySchemes(t *testing.T) {
230 doc, _ := generate(t) 230 doc, _ := generate(t)
231 scheme := dig(t, doc, "components", "securitySchemes", "adminToken").(map[string]any) 231 scheme := dig(t, doc, "components", "securitySchemes", "patToken").(map[string]any)
232 if scheme["type"] != "http" || scheme["scheme"] != "bearer" { 232 if scheme["type"] != "http" || scheme["scheme"] != "bearer" {
233 t.Errorf("adminToken scheme = %v, want {type: http, scheme: bearer}", scheme) 233 t.Errorf("patToken scheme = %v, want {type: http, scheme: bearer}", scheme)
234 } 234 }
235 } 235 }
236 236
internal/server/api/sshcert.go
Old New
@@ -70,27 +70,39 @@ func (m *HostMinter) MintHostCert(principal string) (keyPEM, cert string, err er
70 return string(pem), string(ssh.MarshalAuthorizedKey(c)), nil 70 return string(pem), string(ssh.MarshalAuthorizedKey(c)), nil
71 } 71 }
72 72
73 // handleRevokeSSHCert revokes a specific minted user cert by serial so the jump 73 // handleRevokeSSHCert revokes a specific user cert by serial so the jump gate
74 // gate rejects it at auth before its short TTL expires. Admin-authed, idempotent 74 // rejects it at auth before its short TTL expires. Tenant-scoped, idempotent
75 // (re-revoking a serial is a 204 no-op). Revocation is a pure store operation — 75 // (re-revoking a serial is a 204 no-op). Revocation is a pure store operation —
76 // it does NOT depend on the minter being wired, so unlike mint it never 404s on 76 // it does NOT depend on the minter being wired, so unlike mint it never 404s on
77 // a gate-off server. 77 // a gate-off server.
78 //
79 // SCOPING (see the ssh-cert model report): eitri never mints user certs (they
80 // are BYO, self-signed by the tenant's own registered user CA), so there is no
81 // server-side serial→tenant registry to key ownership off. The cert-LINE form
82 // carries a signing CA we CAN resolve: if it is provably another tenant's
83 // registered CA, revoking is a cross-tenant act and answers 404 (no existence
84 // leak). Otherwise (the caller's own CA, or an unregistered CA that is nobody's)
85 // the revocation is filed under the CALLER's tenant. The bare-SERIAL form has no
86 // CA to resolve, so it is always filed under the caller's tenant — but note the
87 // ROW is what's namespaced; gate enforcement stays fleet-wide by serial
88 // (fail-safe, deny-only), so revoking a serial denies it for everyone. Serials
89 // are 64-bit crypto-random and never exposed cross-tenant, which is what keeps
90 // that acceptable; scoping gate enforcement per-tenant is a recorded follow-up.
91 // The revocation LIST is tenant-scoped.
78 func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { 92 func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
79 if !a.requireFleet(w, r) {
80 return
81 }
82 var req types.RevokeSSHCertRequest 93 var req types.RevokeSSHCertRequest
83 if !decodeJSON(w, r, &req) { 94 if !decodeJSON(w, r, &req) {
84 return 95 return
85 } 96 }
97 caller := principalFromContext(r).Tenant
86 98
87 var serial uint64 99 var serial uint64
88 switch { 100 switch {
89 case req.Certificate != "": 101 case req.Certificate != "":
90 // Parse the authorized-key line into a cert and take its serial. The 102 // Parse the authorized-key line into a cert and take its serial. The CA
91 // public key/CA signature are NOT verified here — an admin revoking a 103 // signature itself is not cryptographically verified here (the gate does
92 // serial is asserting "reject this serial", and the gate is where the 104 // that); we only resolve the signing CA to a tenant for the ownership gate.
93 // signature is checked. A non-cert key line is a clear 400. 105 // A non-cert key line is a clear 400.
94 pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.Certificate)) 106 pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.Certificate))
95 if err != nil { 107 if err != nil {
96 http.Error(w, "invalid certificate", http.StatusBadRequest) 108 http.Error(w, "invalid certificate", http.StatusBadRequest)
@@ -101,6 +113,15 @@ func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
101 http.Error(w, "not a certificate", http.StatusBadRequest) 113 http.Error(w, "not a certificate", http.StatusBadRequest)
102 return 114 return
103 } 115 }
116 // If the cert is provably signed by another tenant's registered CA, this
117 // is a cross-tenant revoke: answer 404, exactly like a foreign VM.
118 if owner, ok, err := a.st.TenantForUserCA(sshca.AuthorizedKeyLine(cert.SignatureKey)); err != nil {
119 http.Error(w, "internal error", http.StatusInternalServerError)
120 return
121 } else if ok && owner != caller {
122 http.Error(w, "not found", http.StatusNotFound)
123 return
124 }
104 serial = cert.Serial 125 serial = cert.Serial
105 case req.Serial != nil: 126 case req.Serial != nil:
106 serial = *req.Serial 127 serial = *req.Serial
@@ -109,11 +130,11 @@ func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
109 return 130 return
110 } 131 }
111 132
112 if err := a.st.RevokeSSHCert(serial, req.Reason); err != nil { 133 if err := a.st.RevokeSSHCert(caller, serial, req.Reason); err != nil {
113 http.Error(w, "internal error", http.StatusInternalServerError) 134 http.Error(w, "internal error", http.StatusInternalServerError)
114 return 135 return
115 } 136 }
116 a.audit("ssh-cert.revoke", map[string]string{ 137 a.audit(caller, "ssh-cert.revoke", map[string]string{
117 "remote": clientIP(r), 138 "remote": clientIP(r),
118 "serial": strconv.FormatUint(serial, 10), 139 "serial": strconv.FormatUint(serial, 10),
119 "reason": req.Reason, 140 "reason": req.Reason,
@@ -121,13 +142,10 @@ func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
121 w.WriteHeader(http.StatusNoContent) 142 w.WriteHeader(http.StatusNoContent)
122 } 143 }
123 144
124 // handleListRevokedSSHCerts lists the revoked cert serials (+ reason/time), 145 // handleListRevokedSSHCerts lists the caller tenant's revoked cert serials (+
125 // newest first. Admin-authed. 146 // reason/time), newest first.
126 func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) { 147 func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) {
127 if !a.requireFleet(w, r) { 148 revoked, err := a.st.ListRevokedSSHCerts(principalFromContext(r).Tenant)
128 return
129 }
130 revoked, err := a.st.ListRevokedSSHCerts()
131 if err != nil { 149 if err != nil {
132 http.Error(w, "internal error", http.StatusInternalServerError) 150 http.Error(w, "internal error", http.StatusInternalServerError)
133 return 151 return
internal/server/api/sshcert_test.go
Old New
@@ -9,7 +9,6 @@ import (
9 "net/http" 9 "net/http"
10 "testing" 10 "testing"
11 11
12 "github.com/a73x/eitri/internal/server/store"
13 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require" 13 "github.com/stretchr/testify/require"
15 "golang.org/x/crypto/ssh" 14 "golang.org/x/crypto/ssh"
@@ -86,7 +85,7 @@ func TestCreateVMMintsPerVMHostCert(t *testing.T) {
86 a.SetHostCertMinter(rec) 85 a.SetHostCertMinter(rec)
87 out := enroll(t, ts) 86 out := enroll(t, ts)
88 87
89 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 88 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
90 map[string]any{"host_id": out["host_id"], "name": "hosty"}) 89 map[string]any{"host_id": out["host_id"], "name": "hosty"})
91 require.Equal(t, http.StatusCreated, resp.StatusCode) 90 require.Equal(t, http.StatusCreated, resp.StatusCode)
92 91
@@ -95,7 +94,7 @@ func TestCreateVMMintsPerVMHostCert(t *testing.T) {
95 "host-cert principal must be <tenant>.<name>") 94 "host-cert principal must be <tenant>.<name>")
96 95
97 // The store row carries the private key PEM + the cert. 96 // The store row carries the private key PEM + the cert.
98 vm, err := st.VMByTenantName(store.DefaultTenant, "hosty") 97 vm, err := st.VMByTenantName(testTenant, "hosty")
99 require.NoError(t, err) 98 require.NoError(t, err)
100 require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted") 99 require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted")
101 require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted") 100 require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted")
@@ -111,7 +110,7 @@ func TestCreateVMMintsPerVMHostCert(t *testing.T) {
111 require.NoError(t, checker.CheckHostKey("default.hosty:22", nil, cert)) 110 require.NoError(t, checker.CheckHostKey("default.hosty:22", nil, cert))
112 111
113 // The private key must NEVER leak through the VM listing. 112 // The private key must NEVER leak through the VM listing.
114 listResp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) 113 listResp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
115 require.Equal(t, http.StatusOK, listResp.StatusCode) 114 require.Equal(t, http.StatusOK, listResp.StatusCode)
116 body, err := io.ReadAll(listResp.Body) 115 body, err := io.ReadAll(listResp.Body)
117 require.NoError(t, err) 116 require.NoError(t, err)
@@ -124,11 +123,11 @@ func TestCreateVMMintsPerVMHostCert(t *testing.T) {
124 func TestCreateVMWithoutHostMinterHasNoHostCert(t *testing.T) { 123 func TestCreateVMWithoutHostMinterHasNoHostCert(t *testing.T) {
125 ts, st, _, _, _ := newServer(t) 124 ts, st, _, _, _ := newServer(t)
126 out := enroll(t, ts) 125 out := enroll(t, ts)
127 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", 126 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
128 map[string]any{"host_id": out["host_id"], "name": "plainvm"}) 127 map[string]any{"host_id": out["host_id"], "name": "plainvm"})
129 require.Equal(t, http.StatusCreated, resp.StatusCode) 128 require.Equal(t, http.StatusCreated, resp.StatusCode)
130 129
131 vm, err := st.VMByTenantName(store.DefaultTenant, "plainvm") 130 vm, err := st.VMByTenantName(testTenant, "plainvm")
132 require.NoError(t, err) 131 require.NoError(t, err)
133 assert.Empty(t, vm.SSHHostKey) 132 assert.Empty(t, vm.SSHHostKey)
134 assert.Empty(t, vm.SSHHostCert) 133 assert.Empty(t, vm.SSHHostCert)
@@ -140,7 +139,7 @@ func TestSSHCertRevokeBySerial(t *testing.T) {
140 ts, st, _, _, _ := newServer(t) 139 ts, st, _, _, _ := newServer(t)
141 140
142 const serial = uint64(0xFFFFFFFF00000001) // > MaxInt64, exercises the bit-cast 141 const serial = uint64(0xFFFFFFFF00000001) // > MaxInt64, exercises the bit-cast
143 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", 142 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT,
144 map[string]any{"serial": serial, "reason": "lost yubikey"}) 143 map[string]any{"serial": serial, "reason": "lost yubikey"})
145 require.Equal(t, http.StatusNoContent, resp.StatusCode) 144 require.Equal(t, http.StatusNoContent, resp.StatusCode)
146 145
@@ -149,7 +148,7 @@ func TestSSHCertRevokeBySerial(t *testing.T) {
149 assert.True(t, revoked) 148 assert.True(t, revoked)
150 149
151 // List endpoint reflects it, serial rendered as a string. 150 // List endpoint reflects it, serial rendered as a string.
152 listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "admintok", nil) 151 listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", testPAT, nil)
153 require.Equal(t, http.StatusOK, listResp.StatusCode) 152 require.Equal(t, http.StatusOK, listResp.StatusCode)
154 var out []map[string]any 153 var out []map[string]any
155 require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out)) 154 require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out))
@@ -177,7 +176,7 @@ func TestSSHCertRevokeByCertLine(t *testing.T) {
177 require.NoError(t, cert.SignCert(rand.Reader, ca)) 176 require.NoError(t, cert.SignCert(rand.Reader, ca))
178 line := string(ssh.MarshalAuthorizedKey(cert)) 177 line := string(ssh.MarshalAuthorizedKey(cert))
179 178
180 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", 179 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT,
181 map[string]any{"certificate": line}) 180 map[string]any{"certificate": line})
182 require.Equal(t, http.StatusNoContent, resp.StatusCode) 181 require.Equal(t, http.StatusNoContent, resp.StatusCode)
183 182
@@ -191,10 +190,10 @@ func TestSSHCertRevokeByCertLine(t *testing.T) {
191 func TestSSHCertRevokeIdempotent(t *testing.T) { 190 func TestSSHCertRevokeIdempotent(t *testing.T) {
192 ts, _, _, _, _ := newServer(t) 191 ts, _, _, _, _ := newServer(t)
193 body := map[string]any{"serial": uint64(7)} 192 body := map[string]any{"serial": uint64(7)}
194 require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", body).StatusCode) 193 require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, body).StatusCode)
195 require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", body).StatusCode) 194 require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, body).StatusCode)
196 195
197 listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "admintok", nil) 196 listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", testPAT, nil)
198 var out []map[string]any 197 var out []map[string]any
199 require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out)) 198 require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out))
200 assert.Len(t, out, 1) 199 assert.Len(t, out, 1)
@@ -202,13 +201,13 @@ func TestSSHCertRevokeIdempotent(t *testing.T) {
202 201
203 func TestSSHCertRevokeMissingFieldsIs400(t *testing.T) { 202 func TestSSHCertRevokeMissingFieldsIs400(t *testing.T) {
204 ts, _, _, _, _ := newServer(t) 203 ts, _, _, _, _ := newServer(t)
205 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", map[string]any{"reason": "no serial"}) 204 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"reason": "no serial"})
206 assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 205 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
207 } 206 }
208 207
209 func TestSSHCertRevokeBadCertLineIs400(t *testing.T) { 208 func TestSSHCertRevokeBadCertLineIs400(t *testing.T) {
210 ts, _, _, _, _ := newServer(t) 209 ts, _, _, _, _ := newServer(t)
211 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", map[string]any{"certificate": "not-a-cert"}) 210 resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": "not-a-cert"})
212 assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 211 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
213 } 212 }
214 213
internal/server/api/testdata/api-token-list.golden.json
Old New
@@ -0,0 +1,10 @@
1 [
2 {
3 "created_at": "2026-07-27T12:00:00Z",
4 "expires_at": "2026-07-27T13:00:00Z",
5 "id": "tok-abc123",
6 "last_used_at": "2026-07-27T12:30:00Z",
7 "name": "boot-gate",
8 "revoked_at": ""
9 }
10 ]
internal/server/api/testdata/create-api-token-request.golden.json
Old New
@@ -0,0 +1,4 @@
1 {
2 "name": "boot-gate",
3 "ttl_seconds": 3600
4 }
internal/server/api/testdata/create-api-token-response.golden.json
Old New
@@ -0,0 +1,6 @@
1 {
2 "expires_at": "2026-07-27T13:00:00Z",
3 "id": "tok-abc123",
4 "name": "boot-gate",
5 "token": "eitri_pat_deadbeef"
6 }
internal/server/api/testdata/me.golden.json
Old New
@@ -0,0 +1,4 @@
1 {
2 "email": "alex@emery.xyz",
3 "tenant": "alex"
4 }
internal/server/api/ticket.go
Old New
@@ -12,50 +12,62 @@ import (
12 // EventSource connect — one minute is generous. 12 // EventSource connect — one minute is generous.
13 const streamTicketTTL = time.Minute 13 const streamTicketTTL = time.Minute
14 14
15 // ticketEntry is one live stream ticket: its expiry and the tenant of the
16 // principal that minted it. The SSE stream and console WS both consume tickets
17 // and scope their per-connection view to this tenant.
18 type ticketEntry struct {
19 exp time.Time
20 tenant string
21 }
22
15 // ticketStore holds one-time SSE stream tickets in memory. Tickets are 23 // ticketStore holds one-time SSE stream tickets in memory. Tickets are
16 // deliberately endpoint-agnostic: the SSE stream and the console WS share 24 // deliberately endpoint-agnostic: the SSE stream and the console WS share this
17 // this one store — both mints are admin-authed at the same privilege, so 25 // one store. Each ticket carries the minting principal's tenant so the stream a
18 // per-endpoint scoping would add ceremony without adding a boundary. Tickets 26 // browser opens is scoped to exactly that tenant (browsers cannot set an auth
19 // are ephemeral session bootstrap — a server restart just means the client 27 // header on an EventSource/WebSocket dial, so the ticket is the only credential —
20 // mints a fresh one on its next reconnect — so no durability is needed. now 28 // and it must not be a fleet-wide one). Tickets are ephemeral session bootstrap —
21 // is injectable for tests. 29 // a server restart just means the client mints a fresh one on its next reconnect
30 // — so no durability is needed. now is injectable for tests.
22 type ticketStore struct { 31 type ticketStore struct {
23 mu sync.Mutex 32 mu sync.Mutex
24 tickets map[string]time.Time // ticket → expiry 33 tickets map[string]ticketEntry // ticket → entry
25 now func() time.Time 34 now func() time.Time
26 } 35 }
27 36
28 func newTicketStore(now func() time.Time) *ticketStore { 37 func newTicketStore(now func() time.Time) *ticketStore {
29 return &ticketStore{tickets: map[string]time.Time{}, now: now} 38 return &ticketStore{tickets: map[string]ticketEntry{}, now: now}
30 } 39 }
31 40
32 // mint issues a fresh one-time ticket, pruning expired ones while it holds 41 // mint issues a fresh one-time ticket bound to tenant, pruning expired ones while
33 // the lock (mints are operator-paced; the map stays tiny). 42 // it holds the lock (mints are operator-paced; the map stays tiny).
34 func (t *ticketStore) mint() string { 43 func (t *ticketStore) mint(tenant string) string {
35 t.mu.Lock() 44 t.mu.Lock()
36 defer t.mu.Unlock() 45 defer t.mu.Unlock()
37 now := t.now() 46 now := t.now()
38 for k, exp := range t.tickets { 47 for k, e := range t.tickets {
39 if now.After(exp) { 48 if now.After(e.exp) {
40 delete(t.tickets, k) 49 delete(t.tickets, k)
41 } 50 }
42 } 51 }
43 tick := random.Hex(16) 52 tick := random.Hex(16)
44 t.tickets[tick] = now.Add(streamTicketTTL) 53 t.tickets[tick] = ticketEntry{exp: now.Add(streamTicketTTL), tenant: tenant}
45 return tick 54 return tick
46 } 55 }
47 56
48 // consume redeems a ticket exactly once; expired or unknown tickets fail. 57 // consume redeems a ticket exactly once, returning the tenant it was minted for;
49 // The map lookup is not constant-time by design: tickets are 128-bit 58 // expired or unknown tickets fail (ok=false, empty tenant). The map lookup is not
50 // crypto-random, single-use, and 60s-TTL, so timing attacks are academic — 59 // constant-time by design: tickets are 128-bit crypto-random, single-use, and
51 // unlike the long-lived admin token, which does get constantTimeTokenMatch. 60 // 60s-TTL, so timing attacks are academic.
52 func (t *ticketStore) consume(tick string) bool { 61 func (t *ticketStore) consume(tick string) (tenant string, ok bool) {
53 t.mu.Lock() 62 t.mu.Lock()
54 defer t.mu.Unlock() 63 defer t.mu.Unlock()
55 exp, ok := t.tickets[tick] 64 e, found := t.tickets[tick]
56 if !ok { 65 if !found {
57 return false 66 return "", false
58 } 67 }
59 delete(t.tickets, tick) // one-time, even when expired 68 delete(t.tickets, tick) // one-time, even when expired
60 return !t.now().After(exp) 69 if t.now().After(e.exp) {
70 return "", false
71 }
72 return e.tenant, true
61 } 73 }
internal/server/api/tokens.go
Old New
@@ -0,0 +1,124 @@
1 package api
2
3 import (
4 "database/sql"
5 "errors"
6 "net/http"
7 "time"
8
9 "github.com/a73x/eitri/internal/server/api/types"
10 )
11
12 // handleMe returns the signed-in identity: the caller's tenant handle and the
13 // email bound to the tenant row. It works identically for a PAT- or
14 // session-authenticated caller — both resolve to a tenant, and the email comes
15 // off that tenant's row. The default tenant, until claimed, has an empty email.
16 func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
17 tenant := principalFromContext(r).Tenant
18 tn, ok, err := a.st.TenantByID(tenant)
19 if err != nil {
20 http.Error(w, "internal error", http.StatusInternalServerError)
21 return
22 }
23 if !ok {
24 // The credential resolved to a tenant that no longer has a row — treat
25 // it as an invalid session rather than inventing an identity.
26 http.Error(w, "sign in required", http.StatusUnauthorized)
27 return
28 }
29 writeJSON(w, http.StatusOK, types.Me{Email: tn.Email, Tenant: tn.ID})
30 }
31
32 // handleCreateAPIToken mints a tenant-scoped personal access token and returns
33 // the secret exactly once. The store owns the secret's generation and hashing
34 // (never echoes it again). ExpiresAt in the response is computed here from the
35 // requested TTL, so it can drift from the store's stored expires_at by up to a
36 // second (the two capture time.Now microseconds apart, both truncated to
37 // RFC3339 seconds); GET /api/v1/tokens is the source of truth for the exact
38 // value. Empty ExpiresAt means non-expiring.
39 func (a *API) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
40 var req types.CreateAPITokenRequest
41 if !decodeJSON(w, r, &req) {
42 return
43 }
44 if req.Name == "" {
45 http.Error(w, "name is required", http.StatusBadRequest)
46 return
47 }
48 // Upper clamp keeps time.Duration(ttl)*time.Second from overflowing
49 // int64 nanoseconds; a century is "non-expiring" for any honest caller.
50 const maxTTLSeconds = 100 * 365 * 24 * 60 * 60
51 if req.TTLSeconds < 0 || req.TTLSeconds > maxTTLSeconds {
52 http.Error(w, "ttl_seconds must be between 0 and 3153600000", http.StatusBadRequest)
53 return
54 }
55 tenant := principalFromContext(r).Tenant
56 ttl := time.Duration(req.TTLSeconds) * time.Second
57 secret, id, err := a.st.CreateAPIToken(tenant, req.Name, ttl)
58 if err != nil {
59 http.Error(w, "internal error", http.StatusInternalServerError)
60 return
61 }
62 expiresAt := ""
63 if ttl > 0 {
64 expiresAt = time.Now().UTC().Add(ttl).Format(time.RFC3339)
65 }
66 a.audit(tenant, "api-token.mint", map[string]string{"token_id": id, "name": req.Name})
67 writeJSON(w, http.StatusCreated, types.CreateAPITokenResponse{
68 ExpiresAt: expiresAt,
69 ID: id,
70 Name: req.Name,
71 Token: secret,
72 })
73 }
74
75 // handleListAPITokens lists the tenant's PAT metadata (never a secret), newest
76 // first. Nil timestamp columns (non-expiring / never used / not revoked) map to
77 // empty strings.
78 func (a *API) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
79 tenant := principalFromContext(r).Tenant
80 toks, err := a.st.ListAPITokens(tenant)
81 if err != nil {
82 http.Error(w, "internal error", http.StatusInternalServerError)
83 return
84 }
85 out := make([]types.APIToken, 0, len(toks))
86 for _, tok := range toks {
87 out = append(out, types.APIToken{
88 CreatedAt: tok.CreatedAt.Format(time.RFC3339),
89 ExpiresAt: formatTimePtr(tok.ExpiresAt),
90 ID: tok.ID,
91 LastUsedAt: formatTimePtr(tok.LastUsedAt),
92 Name: tok.Name,
93 RevokedAt: formatTimePtr(tok.RevokedAt),
94 })
95 }
96 writeJSON(w, http.StatusOK, out)
97 }
98
99 // handleRevokeAPIToken revokes a PAT by id, scoped to the caller's tenant. An
100 // unknown id, a foreign tenant's id, and an already-revoked id all answer 404:
101 // revoked-vs-unknown indistinguishability is deliberate, so revoking cannot be
102 // used to probe which token ids exist across the partition.
103 func (a *API) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) {
104 tenant := principalFromContext(r).Tenant
105 id := r.PathValue("id")
106 if err := a.st.RevokeAPIToken(tenant, id); err != nil {
107 if errors.Is(err, sql.ErrNoRows) {
108 http.Error(w, "not found", http.StatusNotFound)
109 return
110 }
111 http.Error(w, "internal error", http.StatusInternalServerError)
112 return
113 }
114 a.audit(tenant, "api-token.revoke", map[string]string{"token_id": id})
115 w.WriteHeader(http.StatusNoContent)
116 }
117
118 // formatTimePtr renders a nullable store timestamp as RFC3339, or "" when nil.
119 func formatTimePtr(t *time.Time) string {
120 if t == nil {
121 return ""
122 }
123 return t.Format(time.RFC3339)
124 }
internal/server/api/tokens_test.go
Old New
@@ -0,0 +1,152 @@
1 package api
2
3 import (
4 "encoding/json"
5 "testing"
6
7 "github.com/a73x/eitri/internal/server/api/types"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 // TestMeIdentity pins GET /api/v1/me: it returns the caller's tenant and the
13 // email bound to that tenant's row, over BOTH credential paths (PAT and
14 // session).
15 func TestMeIdentity(t *testing.T) {
16 ts, st, _, _, _ := newServer(t)
17
18 t.Run("PAT returns the tenant and its bound email", func(t *testing.T) {
19 resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil)
20 require.Equal(t, 200, resp.StatusCode)
21 var me types.Me
22 require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
23 assert.Equal(t, testTenant, me.Tenant)
24 assert.Equal(t, testTenant+"@test.local", me.Email)
25 })
26
27 t.Run("session carries the same identity", func(t *testing.T) {
28 sess := sessionFor(t, st, testTenant)
29 resp := doCookie(t, "GET", ts.URL+"/api/v1/me", sess, nil)
30 require.Equal(t, 200, resp.StatusCode)
31 var me types.Me
32 require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
33 assert.Equal(t, testTenant, me.Tenant)
34 })
35
36 t.Run("bound tenant returns its email", func(t *testing.T) {
37 tn, err := st.CreateTenantForIdentity("https://idp", "sub-me", "alex@emery.xyz")
38 require.NoError(t, err)
39 pat, _, err := st.CreateAPIToken(tn.ID, "me", 0)
40 require.NoError(t, err)
41 resp := do(t, "GET", ts.URL+"/api/v1/me", pat, nil)
42 require.Equal(t, 200, resp.StatusCode)
43 var me types.Me
44 require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
45 assert.Equal(t, tn.ID, me.Tenant)
46 assert.Equal(t, "alex@emery.xyz", me.Email)
47 })
48 }
49
50 // TestAPITokenLifecycle exercises mint → list → revoke through the HTTP surface:
51 // the secret comes back once and is prefixed, list shows metadata (no secret),
52 // and revoke removes it from the usable set.
53 func TestAPITokenLifecycle(t *testing.T) {
54 ts, _, _, _, _ := newServer(t)
55
56 // Mint with a TTL: ExpiresAt is set.
57 resp := do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
58 types.CreateAPITokenRequest{Name: "worker", TTLSeconds: 3600})
59 require.Equal(t, 201, resp.StatusCode)
60 var minted types.CreateAPITokenResponse
61 require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted))
62 assert.Equal(t, "worker", minted.Name)
63 assert.NotEmpty(t, minted.ID)
64 assert.Contains(t, minted.Token, "eitri_pat_", "secret is prefixed for leak-grepping")
65 assert.NotEmpty(t, minted.ExpiresAt, "a TTL sets expires_at")
66
67 // The minted secret authenticates.
68 assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", minted.Token, nil).StatusCode)
69
70 // List shows it, metadata only (never the secret).
71 resp = do(t, "GET", ts.URL+"/api/v1/tokens", testPAT, nil)
72 require.Equal(t, 200, resp.StatusCode)
73 body := decodeJSONKeys(t, resp)
74 found := false
75 for _, row := range body {
76 if row["id"] == minted.ID {
77 found = true
78 assert.Equal(t, "worker", row["name"])
79 assert.NotContains(t, row, "token", "list never carries the secret")
80 assert.NotEmpty(t, row["created_at"])
81 }
82 }
83 assert.True(t, found, "the minted token appears in the list")
84
85 // Revoke it: 204, and it no longer authenticates.
86 resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+minted.ID, testPAT, nil)
87 assert.Equal(t, 204, resp.StatusCode)
88 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", minted.Token, nil).StatusCode)
89
90 // Re-revoking the same id is a 404 (indistinguishable from unknown).
91 resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+minted.ID, testPAT, nil)
92 assert.Equal(t, 404, resp.StatusCode)
93 }
94
95 // TestCreateAPITokenNonExpiring: a zero TTL mints a non-expiring token and the
96 // response ExpiresAt is empty.
97 func TestCreateAPITokenNonExpiring(t *testing.T) {
98 ts, _, _, _, _ := newServer(t)
99 resp := do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
100 types.CreateAPITokenRequest{Name: "perm", TTLSeconds: 0})
101 require.Equal(t, 201, resp.StatusCode)
102 var minted types.CreateAPITokenResponse
103 require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted))
104 assert.Empty(t, minted.ExpiresAt, "ttl 0 ⇒ non-expiring ⇒ empty expires_at")
105 }
106
107 // TestCreateAPITokenValidation: an empty name and a negative TTL are both 400.
108 func TestCreateAPITokenValidation(t *testing.T) {
109 ts, _, _, _, _ := newServer(t)
110
111 assert.Equal(t, 400, do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
112 types.CreateAPITokenRequest{Name: "", TTLSeconds: 60}).StatusCode)
113
114 assert.Equal(t, 400, do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
115 types.CreateAPITokenRequest{Name: "bad", TTLSeconds: -1}).StatusCode)
116 }
117
118 // TestRevokeAPITokenForeignIs404: a token owned by another tenant cannot be
119 // revoked and answers 404 — the same as an unknown id, so revoke never leaks
120 // which token ids exist across the partition. It also stays usable afterward.
121 func TestRevokeAPITokenForeignIs404(t *testing.T) {
122 ts, st, _, _, _ := newServer(t)
123
124 beta, err := st.CreateTenantForIdentity("https://idp", "sub-beta", "beta@example.com")
125 require.NoError(t, err)
126 betaSecret, betaID, err := st.CreateAPIToken(beta.ID, "beta", 0)
127 require.NoError(t, err)
128
129 // The default-tenant caller tries to revoke beta's token id.
130 resp := do(t, "DELETE", ts.URL+"/api/v1/tokens/"+betaID, testPAT, nil)
131 assert.Equal(t, 404, resp.StatusCode)
132
133 // Beta's token is untouched.
134 assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", betaSecret, nil).StatusCode)
135 }
136
137 // TestListAPITokensTenantScoped: list returns only the caller's tokens, never
138 // another tenant's.
139 func TestListAPITokensTenantScoped(t *testing.T) {
140 ts, st, _, _, _ := newServer(t)
141
142 beta, err := st.CreateTenantForIdentity("https://idp", "sub-b", "b@example.com")
143 require.NoError(t, err)
144 _, betaID, err := st.CreateAPIToken(beta.ID, "beta-only", 0)
145 require.NoError(t, err)
146
147 resp := do(t, "GET", ts.URL+"/api/v1/tokens", testPAT, nil)
148 require.Equal(t, 200, resp.StatusCode)
149 for _, row := range decodeJSONKeys(t, resp) {
150 assert.NotEqual(t, betaID, row["id"], "default's list must not include beta's token")
151 }
152 }
internal/server/api/types/types.go
Old New
@@ -244,3 +244,45 @@ type UserCA struct {
244 Label string `json:"label"` 244 Label string `json:"label"`
245 PubKey string `json:"pubkey"` 245 PubKey string `json:"pubkey"`
246 } 246 }
247
248 // Me is the signed-in identity served by GET /api/v1/me, so the SPA can render
249 // who is logged in and the settings page. Response-side only. Fields ordered
250 // alphabetically by json key (see the map-compatibility note above).
251 type Me struct {
252 Email string `json:"email"`
253 Tenant string `json:"tenant"`
254 }
255
256 // CreateAPITokenRequest is the POST /api/v1/tokens body: mint a personal access
257 // token. TTLSeconds is the token lifetime in seconds; 0 mints a non-expiring
258 // token. Request-side only — kept disjoint from the response types so the spec
259 // generator (which emits no `required` array for requests) treats each side
260 // correctly.
261 type CreateAPITokenRequest struct {
262 Name string `json:"name"`
263 TTLSeconds int64 `json:"ttl_seconds"` // 0 = non-expiring
264 }
265
266 // CreateAPITokenResponse answers POST /api/v1/tokens: it carries the token
267 // secret EXACTLY ONCE (the server stores only its hash and never echoes it
268 // again). ExpiresAt is RFC3339, empty for a non-expiring token. Fields ordered
269 // alphabetically by json key.
270 type CreateAPITokenResponse struct {
271 ExpiresAt string `json:"expires_at"` // RFC3339; empty = non-expiring
272 ID string `json:"id"`
273 Name string `json:"name"`
274 Token string `json:"token"`
275 }
276
277 // APIToken is one PAT's metadata in GET /api/v1/tokens — never the secret.
278 // Every timestamp is RFC3339 or empty ("" when the underlying column is NULL:
279 // non-expiring, never used, not revoked). Response-side only. Fields ordered
280 // alphabetically by json key.
281 type APIToken struct {
282 CreatedAt string `json:"created_at"`
283 ExpiresAt string `json:"expires_at"`
284 ID string `json:"id"`
285 LastUsedAt string `json:"last_used_at"`
286 Name string `json:"name"`
287 RevokedAt string `json:"revoked_at"`
288 }
internal/server/api/upgrade.go
Old New
@@ -12,9 +12,6 @@ import (
12 // and pokes its snapshot stream. The human is the rollout controller: nothing 12 // and pokes its snapshot stream. The human is the rollout controller: nothing
13 // upgrades without this per-host click, so a bad release stops at one host. 13 // upgrades without this per-host click, so a bad release stops at one host.
14 func (a *API) handleUpgradeAgent(w http.ResponseWriter, r *http.Request) { 14 func (a *API) handleUpgradeAgent(w http.ResponseWriter, r *http.Request) {
15 if !a.requireFleet(w, r) {
16 return
17 }
18 if a.release == nil || a.upgrader == nil { 15 if a.release == nil || a.upgrader == nil {
19 http.Error(w, "release discovery not configured", http.StatusServiceUnavailable) 16 http.Error(w, "release discovery not configured", http.StatusServiceUnavailable)
20 return 17 return
@@ -34,6 +31,11 @@ func (a *API) handleUpgradeAgent(w http.ResponseWriter, r *http.Request) {
34 http.Error(w, "internal error", http.StatusInternalServerError) 31 http.Error(w, "internal error", http.StatusInternalServerError)
35 return 32 return
36 } 33 }
34 // Ownership gate: a foreign-tenant host answers exactly like a missing one.
35 if !mayActAs(principalFromContext(r), h.Tenant) {
36 http.Error(w, "host not found", http.StatusNotFound)
37 return
38 }
37 st, okReg := a.reg.Get(id) 39 st, okReg := a.reg.Get(id)
38 if !okReg || !st.Online { 40 if !okReg || !st.Online {
39 http.Error(w, "host is offline", http.StatusConflict) 41 http.Error(w, "host is offline", http.StatusConflict)
@@ -50,7 +52,7 @@ func (a *API) handleUpgradeAgent(w http.ResponseWriter, r *http.Request) {
50 } 52 }
51 a.upgrader.OfferAgentUpgrade(id, m.Version, art.URL, art.SHA256) 53 a.upgrader.OfferAgentUpgrade(id, m.Version, art.URL, art.SHA256)
52 a.hub.Poke(id) 54 a.hub.Poke(id)
53 a.audit("host.agent.upgrade", map[string]string{ 55 a.audit(h.Tenant, "host.agent.upgrade", map[string]string{
54 "host_id": id, "remote": clientIP(r), 56 "host_id": id, "remote": clientIP(r),
55 "from": st.AgentVersion, "to": m.Version, 57 "from": st.AgentVersion, "to": m.Version,
56 }) 58 })
internal/server/api/upgrade_test.go
Old New
@@ -55,7 +55,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
55 up := &fakeUpgrader{} 55 up := &fakeUpgrader{}
56 a.SetAgentUpgrader(up) 56 a.SetAgentUpgrader(up)
57 57
58 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil) 58 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
59 require.Equal(t, http.StatusAccepted, resp.StatusCode) 59 require.Equal(t, http.StatusAccepted, resp.StatusCode)
60 60
61 assert.Equal(t, hostID, up.host) 61 assert.Equal(t, hostID, up.host)
@@ -63,7 +63,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
63 assert.Equal(t, "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", up.url) 63 assert.Equal(t, "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", up.url)
64 assert.Equal(t, "abcd", up.sha) 64 assert.Equal(t, "abcd", up.sha)
65 65
66 auditResp := do(t, "GET", ts.URL+"/api/v1/audit", "admintok", nil) 66 auditResp := do(t, "GET", ts.URL+"/api/v1/audit", testPAT, nil)
67 require.Equal(t, http.StatusOK, auditResp.StatusCode) 67 require.Equal(t, http.StatusOK, auditResp.StatusCode)
68 var rows []types.AuditEvent 68 var rows []types.AuditEvent
69 require.NoError(t, json.NewDecoder(auditResp.Body).Decode(&rows)) 69 require.NoError(t, json.NewDecoder(auditResp.Body).Decode(&rows))
@@ -86,7 +86,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
86 a.SetAgentUpgrader(&fakeUpgrader{}) 86 a.SetAgentUpgrader(&fakeUpgrader{})
87 // a.release left nil. 87 // a.release left nil.
88 88
89 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil) 89 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
90 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) 90 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
91 }) 91 })
92 92
@@ -99,7 +99,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
99 a.SetReleaseSource(fakeRelease{ok: false}) 99 a.SetReleaseSource(fakeRelease{ok: false})
100 a.SetAgentUpgrader(&fakeUpgrader{}) 100 a.SetAgentUpgrader(&fakeUpgrader{})
101 101
102 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil) 102 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
103 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) 103 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
104 }) 104 })
105 105
@@ -108,7 +108,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
108 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true}) 108 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
109 a.SetAgentUpgrader(&fakeUpgrader{}) 109 a.SetAgentUpgrader(&fakeUpgrader{})
110 110
111 resp := do(t, "POST", ts.URL+"/api/v1/hosts/does-not-exist/upgrade-agent", "admintok", nil) 111 resp := do(t, "POST", ts.URL+"/api/v1/hosts/does-not-exist/upgrade-agent", testPAT, nil)
112 assert.Equal(t, http.StatusNotFound, resp.StatusCode) 112 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
113 }) 113 })
114 114
@@ -120,7 +120,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
120 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true}) 120 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
121 a.SetAgentUpgrader(&fakeUpgrader{}) 121 a.SetAgentUpgrader(&fakeUpgrader{})
122 122
123 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil) 123 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
124 assert.Equal(t, http.StatusConflict, resp.StatusCode) 124 assert.Equal(t, http.StatusConflict, resp.StatusCode)
125 }) 125 })
126 126
@@ -133,7 +133,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
133 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true}) 133 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
134 a.SetAgentUpgrader(&fakeUpgrader{}) 134 a.SetAgentUpgrader(&fakeUpgrader{})
135 135
136 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil) 136 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
137 assert.Equal(t, http.StatusConflict, resp.StatusCode) 137 assert.Equal(t, http.StatusConflict, resp.StatusCode)
138 }) 138 })
139 139
@@ -149,7 +149,7 @@ func TestUpgradeAgentEndpoint(t *testing.T) {
149 }}) 149 }})
150 a.SetAgentUpgrader(&fakeUpgrader{}) 150 a.SetAgentUpgrader(&fakeUpgrader{})
151 151
152 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", "admintok", nil) 152 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
153 assert.Equal(t, http.StatusConflict, resp.StatusCode) 153 assert.Equal(t, http.StatusConflict, resp.StatusCode)
154 }) 154 })
155 } 155 }
@@ -168,7 +168,7 @@ func TestHostResponseAgentUpdateAvailable(t *testing.T) {
168 reg.UpdateReport(hostID, registry.Report{}) 168 reg.UpdateReport(hostID, registry.Report{})
169 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true}) 169 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
170 170
171 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil) 171 resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
172 require.Equal(t, http.StatusOK, resp.StatusCode) 172 require.Equal(t, http.StatusOK, resp.StatusCode)
173 items := decodeJSONKeys(t, resp) 173 items := decodeJSONKeys(t, resp)
174 require.Len(t, items, 1) 174 require.Len(t, items, 1)
@@ -184,7 +184,7 @@ func TestHostResponseAgentUpdateAvailable(t *testing.T) {
184 reg.UpdateReport(hostID, registry.Report{}) 184 reg.UpdateReport(hostID, registry.Report{})
185 // a.release left nil. 185 // a.release left nil.
186 186
187 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil) 187 resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
188 require.Equal(t, http.StatusOK, resp.StatusCode) 188 require.Equal(t, http.StatusOK, resp.StatusCode)
189 items := decodeJSONKeys(t, resp) 189 items := decodeJSONKeys(t, resp)
190 require.Len(t, items, 1) 190 require.Len(t, items, 1)
@@ -200,7 +200,7 @@ func TestHostResponseAgentUpdateAvailable(t *testing.T) {
200 reg.UpdateReport(hostID, registry.Report{}) 200 reg.UpdateReport(hostID, registry.Report{})
201 a.SetReleaseSource(fakeRelease{ok: false}) 201 a.SetReleaseSource(fakeRelease{ok: false})
202 202
203 resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil) 203 resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
204 require.Equal(t, http.StatusOK, resp.StatusCode) 204 require.Equal(t, http.StatusOK, resp.StatusCode)
205 items := decodeJSONKeys(t, resp) 205 items := decodeJSONKeys(t, resp)
206 require.Len(t, items, 1) 206 require.Len(t, items, 1)
@@ -210,14 +210,16 @@ func TestHostResponseAgentUpdateAvailable(t *testing.T) {
210 210
211 // TestMarshalSnapshotCarriesVersions pins that the SSE snapshot payload 211 // TestMarshalSnapshotCarriesVersions pins that the SSE snapshot payload
212 // itself (not just GET /hosts) carries the server's own build version and the 212 // itself (not just GET /hosts) carries the server's own build version and the
213 // latest known release version. marshalSnapshot is called directly (in-package) 213 // latest known release version. marshalSnapshots is called directly (in-package)
214 // rather than driving the SSE endpoint, per the plan's guidance. 214 // rather than driving the SSE endpoint, per the plan's guidance.
215 func TestMarshalSnapshotCarriesVersions(t *testing.T) { 215 func TestMarshalSnapshotCarriesVersions(t *testing.T) {
216 _, _, _, _, a := newServer(t) 216 _, _, _, _, a := newServer(t)
217 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true}) 217 a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
218 218
219 raw, err := a.marshalSnapshot() 219 payloads, err := a.marshalSnapshots([]string{testTenant})
220 require.NoError(t, err) 220 require.NoError(t, err)
221 raw := payloads[testTenant]
222 require.NotNil(t, raw)
221 223
222 var snap types.StateSnapshot 224 var snap types.StateSnapshot
223 require.NoError(t, json.Unmarshal(raw, &snap)) 225 require.NoError(t, json.Unmarshal(raw, &snap))
internal/server/api/usercas.go
Old New
@@ -31,7 +31,7 @@ func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) {
31 http.Error(w, "internal error", http.StatusInternalServerError) 31 http.Error(w, "internal error", http.StatusInternalServerError)
32 return 32 return
33 } 33 }
34 a.audit("user-ca.upload", map[string]string{"tenant": tenant, "fingerprint": ssh.FingerprintSHA256(pub)}) 34 a.audit(tenant, "user-ca.upload", map[string]string{"tenant": tenant, "fingerprint": ssh.FingerprintSHA256(pub)})
35 writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)}) 35 writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)})
36 } 36 }
37 37
internal/server/api/usercas_test.go
Old New
@@ -6,7 +6,6 @@ import (
6 "testing" 6 "testing"
7 7
8 "github.com/a73x/eitri/internal/server/sshca" 8 "github.com/a73x/eitri/internal/server/sshca"
9 "github.com/a73x/eitri/internal/server/store"
10 "github.com/stretchr/testify/assert" 9 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require" 10 "github.com/stretchr/testify/require"
12 ) 11 )
@@ -22,7 +21,7 @@ func TestUploadUserCAStoresCanonicalLine(t *testing.T) {
22 require.NoError(t, err) 21 require.NoError(t, err)
23 line := sshca.AuthorizedKeyLine(signer.PublicKey()) 22 line := sshca.AuthorizedKeyLine(signer.PublicKey())
24 23
25 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+store.DefaultTenant+"/user-cas", "admintok", 24 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT,
26 map[string]any{"public_key": line, "label": "yubikey-ca"}) 25 map[string]any{"public_key": line, "label": "yubikey-ca"})
27 require.Equal(t, http.StatusCreated, resp.StatusCode) 26 require.Equal(t, http.StatusCreated, resp.StatusCode)
28 27
@@ -33,12 +32,12 @@ func TestUploadUserCAStoresCanonicalLine(t *testing.T) {
33 tenant, ok, err := st.TenantForUserCA(line) 32 tenant, ok, err := st.TenantForUserCA(line)
34 require.NoError(t, err) 33 require.NoError(t, err)
35 require.True(t, ok, "the uploaded CA line must resolve back to a tenant") 34 require.True(t, ok, "the uploaded CA line must resolve back to a tenant")
36 assert.Equal(t, store.DefaultTenant, tenant) 35 assert.Equal(t, testTenant, tenant)
37 } 36 }
38 37
39 func TestUploadUserCAGarbageKeyIs400(t *testing.T) { 38 func TestUploadUserCAGarbageKeyIs400(t *testing.T) {
40 ts, _, _, _, _ := newServer(t) 39 ts, _, _, _, _ := newServer(t)
41 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+store.DefaultTenant+"/user-cas", "admintok", 40 resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT,
42 map[string]any{"public_key": "not-a-key"}) 41 map[string]any{"public_key": "not-a-key"})
43 assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 42 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
44 } 43 }
internal/server/api/wire_golden_test.go
Old New
@@ -204,4 +204,30 @@ func TestWireGolden(t *testing.T) {
204 Label: "team-alpha-ca", 204 Label: "team-alpha-ca",
205 PubKey: "ssh-ed25519 AAAAC3Nza ca-comment", 205 PubKey: "ssh-ed25519 AAAAC3Nza ca-comment",
206 }}) 206 }})
207
208 goldenCheck(t, "me", types.Me{
209 Email: "alex@emery.xyz",
210 Tenant: "alex",
211 })
212
213 goldenCheck(t, "create-api-token-request", types.CreateAPITokenRequest{
214 Name: "boot-gate",
215 TTLSeconds: 3600,
216 })
217
218 goldenCheck(t, "create-api-token-response", types.CreateAPITokenResponse{
219 ExpiresAt: "2026-07-27T13:00:00Z",
220 ID: "tok-abc123",
221 Name: "boot-gate",
222 Token: "eitri_pat_deadbeef",
223 })
224
225 goldenCheck(t, "api-token-list", []types.APIToken{{
226 CreatedAt: "2026-07-27T12:00:00Z",
227 ExpiresAt: "2026-07-27T13:00:00Z",
228 ID: "tok-abc123",
229 LastUsedAt: "2026-07-27T12:30:00Z",
230 Name: "boot-gate",
231 RevokedAt: "",
232 }})
207 } 233 }
internal/server/config/config.go
Old New
@@ -4,9 +4,12 @@ package config
4 4
5 // Config is the eitri-server config file schema (decoded from JSON). 5 // Config is the eitri-server config file schema (decoded from JSON).
6 type Config struct { 6 type Config struct {
7 HTTPListen string `json:"http_listen"` 7 HTTPListen string `json:"http_listen"`
8 QUICListen string `json:"quic_listen"` 8 QUICListen string `json:"quic_listen"`
9 DBPath string `json:"db_path"` 9 DBPath string `json:"db_path"`
10 // AdminToken is retired: sign-in is OIDC (see OIDC below) and console
11 // credentials are sessions/PATs. The field is kept only so the server can
12 // detect a stale token in an old config and warn the operator to remove it.
10 AdminToken string `json:"admin_token"` 13 AdminToken string `json:"admin_token"`
11 HostSecret string `json:"host_secret"` 14 HostSecret string `json:"host_secret"`
12 CIDRPool string `json:"cidr_pool"` 15 CIDRPool string `json:"cidr_pool"`
@@ -44,4 +47,19 @@ type Config struct {
44 // field is absent — applied by cmd, not here). Empty string in an 47 // field is absent — applied by cmd, not here). Empty string in an
45 // explicit config disables release discovery and every upgrade surface. 48 // explicit config disables release discovery and every upgrade surface.
46 ReleaseManifestURL *string `json:"release_manifest_url"` 49 ReleaseManifestURL *string `json:"release_manifest_url"`
50 // OIDC configures the console sign-in relying party (required — issuer,
51 // client_id and public_url must be set; see OIDC).
52 OIDC OIDC `json:"oidc"`
53 }
54
55 // OIDC configures the server's relying-party side. The issuer is sometimes
56 // the bundled eitri-oidc next door and sometimes an external IdP — the
57 // server cannot tell the difference (spec §2).
58 type OIDC struct {
59 Issuer string `json:"issuer"`
60 ClientID string `json:"client_id"`
61 ClientSecret string `json:"client_secret"` // external confidential clients only
62 PublicURL string `json:"public_url"`
63 AllowedDomains []string `json:"allowed_domains"` // optional signup gate
64 AllowedIdentities []string `json:"allowed_identities"` // optional signup gate
47 } 65 }
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(DefaultTenant) 51 tok, _ := s.CreateEnrollmentToken(testTenant)
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/apitokens.go
Old New
@@ -0,0 +1,132 @@
1 package store
2
3 import (
4 "crypto/sha256"
5 "database/sql"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "time"
10
11 "github.com/a73x/eitri/internal/random"
12 )
13
14 // APIToken is the metadata of a personal access token — never the secret, which
15 // exists in cleartext only at mint time. ExpiresAt/LastUsedAt/RevokedAt are nil
16 // when unset (non-expiring / never used / not revoked).
17 type APIToken struct {
18 ID, Name string
19 CreatedAt time.Time
20 ExpiresAt *time.Time
21 LastUsedAt *time.Time
22 RevokedAt *time.Time
23 }
24
25 // hashToken returns the hex SHA-256 of a PAT secret. The stored hash is the
26 // constant-time defense: a lookup keyed on the hash of the presented secret
27 // never compares the secret itself, so there is no timing side-channel to
28 // exploit and a database leak yields no usable tokens.
29 func hashToken(secret string) string {
30 sum := sha256.Sum256([]byte(secret))
31 return hex.EncodeToString(sum[:])
32 }
33
34 // CreateAPIToken mints a tenant-scoped PAT. The secret is returned once (stored
35 // only as its SHA-256) and prefixed eitri_pat_ so it is greppable in leaks. A
36 // ttl of 0 mints a non-expiring token (NULL expires_at).
37 func (s *Store) CreateAPIToken(tenant, name string, ttl time.Duration) (secret, id string, err error) {
38 secret = "eitri_pat_" + random.Hex(32)
39 id = random.Hex(16)
40 now := time.Now().UTC()
41
42 var expiresAt any // NULL when ttl == 0
43 if ttl != 0 {
44 expiresAt = now.Add(ttl).Format(time.RFC3339)
45 }
46 _, err = s.db.Exec(
47 `INSERT INTO api_tokens(id, tenant, name, token_hash, created_at, expires_at) VALUES (?,?,?,?,?,?)`,
48 id, tenant, name, hashToken(secret), now.Format(time.RFC3339), expiresAt,
49 )
50 if err != nil {
51 return "", "", fmt.Errorf("create api token: %w", err)
52 }
53 return secret, id, nil
54 }
55
56 // TenantForAPIToken resolves a presented PAT secret to its tenant, enforcing
57 // revocation and expiry. ok=false (no error) for an unknown, revoked, or
58 // expired token. On success it stamps last_used_at. The lookup is keyed on the
59 // hash of the secret (constant-time defense — see hashToken).
60 func (s *Store) TenantForAPIToken(secret string) (string, bool, error) {
61 hash := hashToken(secret)
62 now := time.Now().UTC().Format(time.RFC3339)
63 var tenant string
64 err := s.db.QueryRow(
65 `SELECT tenant FROM api_tokens
66 WHERE token_hash=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
67 hash, now,
68 ).Scan(&tenant)
69 if errors.Is(err, sql.ErrNoRows) {
70 return "", false, nil
71 }
72 if err != nil {
73 return "", false, fmt.Errorf("tenant for api token: %w", err)
74 }
75 if _, err := s.db.Exec(`UPDATE api_tokens SET last_used_at=? WHERE token_hash=?`, now, hash); err != nil {
76 return "", false, fmt.Errorf("touch api token: %w", err)
77 }
78 return tenant, true, nil
79 }
80
81 // ListAPITokens returns tenant's tokens (metadata only — never the secret or
82 // hash), newest first.
83 func (s *Store) ListAPITokens(tenant string) ([]APIToken, error) {
84 rows, err := s.db.Query(
85 `SELECT id, name, created_at, expires_at, last_used_at, revoked_at
86 FROM api_tokens WHERE tenant=? ORDER BY created_at DESC, id`, tenant)
87 if err != nil {
88 return nil, fmt.Errorf("list api tokens: %w", err)
89 }
90 defer rows.Close()
91 var out []APIToken
92 for rows.Next() {
93 var tok APIToken
94 var created string
95 var expires, lastUsed, revoked sql.NullString
96 if err := rows.Scan(&tok.ID, &tok.Name, &created, &expires, &lastUsed, &revoked); err != nil {
97 return nil, err
98 }
99 tok.CreatedAt, _ = time.Parse(time.RFC3339, created)
100 tok.ExpiresAt = parseNullTime(expires)
101 tok.LastUsedAt = parseNullTime(lastUsed)
102 tok.RevokedAt = parseNullTime(revoked)
103 out = append(out, tok)
104 }
105 return out, rows.Err()
106 }
107
108 // RevokeAPIToken marks a token revoked. It is scoped to tenant: another
109 // tenant's token id is not found (sql.ErrNoRows), so revoke cannot reach across
110 // the partition. Re-revoking keeps the original revoked_at.
111 func (s *Store) RevokeAPIToken(tenant, id string) error {
112 res, err := s.db.Exec(
113 `UPDATE api_tokens SET revoked_at=? WHERE id=? AND tenant=? AND revoked_at IS NULL`,
114 time.Now().UTC().Format(time.RFC3339), id, tenant)
115 if err != nil {
116 return fmt.Errorf("revoke api token: %w", err)
117 }
118 if n, _ := res.RowsAffected(); n == 0 {
119 // Unknown id, wrong tenant, or already revoked — all not-found to the caller.
120 return sql.ErrNoRows
121 }
122 return nil
123 }
124
125 // parseNullTime maps a nullable RFC3339 column to *time.Time (nil when NULL).
126 func parseNullTime(ns sql.NullString) *time.Time {
127 if !ns.Valid {
128 return nil
129 }
130 t, _ := time.Parse(time.RFC3339, ns.String)
131 return &t
132 }
internal/server/store/apitokens_test.go
Old New
@@ -0,0 +1,89 @@
1 package store
2
3 import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 func TestAPITokenMintAndUse(t *testing.T) {
13 s := newStore(t)
14
15 secret, id, err := s.CreateAPIToken(testTenant, "laptop", 0)
16 require.NoError(t, err)
17 assert.True(t, strings.HasPrefix(secret, "eitri_pat_"), "greppable prefix")
18 assert.NotEmpty(t, id)
19
20 tenant, ok, err := s.TenantForAPIToken(secret)
21 require.NoError(t, err)
22 require.True(t, ok)
23 assert.Equal(t, testTenant, tenant)
24
25 // Using it stamps last_used_at.
26 toks, err := s.ListAPITokens(testTenant)
27 require.NoError(t, err)
28 require.Len(t, toks, 1)
29 assert.Equal(t, id, toks[0].ID)
30 assert.Equal(t, "laptop", toks[0].Name)
31 require.NotNil(t, toks[0].LastUsedAt)
32 assert.Nil(t, toks[0].ExpiresAt, "ttl 0 ⇒ non-expiring")
33 }
34
35 func TestAPITokenUnknown(t *testing.T) {
36 s := newStore(t)
37 _, ok, err := s.TenantForAPIToken("eitri_pat_nope")
38 require.NoError(t, err)
39 assert.False(t, ok)
40 }
41
42 func TestAPITokenExpiry(t *testing.T) {
43 s := newStore(t)
44 secret, _, err := s.CreateAPIToken(testTenant, "short", -time.Minute)
45 require.NoError(t, err)
46
47 _, ok, err := s.TenantForAPIToken(secret)
48 require.NoError(t, err)
49 assert.False(t, ok, "expired token must not resolve")
50 }
51
52 func TestAPITokenRevoke(t *testing.T) {
53 s := newStore(t)
54 secret, id, err := s.CreateAPIToken(testTenant, "revme", time.Hour)
55 require.NoError(t, err)
56
57 require.NoError(t, s.RevokeAPIToken(testTenant, id))
58
59 _, ok, err := s.TenantForAPIToken(secret)
60 require.NoError(t, err)
61 assert.False(t, ok, "revoked token must not resolve")
62
63 // Revoked token still lists (metadata), with revoked_at set.
64 toks, err := s.ListAPITokens(testTenant)
65 require.NoError(t, err)
66 require.Len(t, toks, 1)
67 require.NotNil(t, toks[0].RevokedAt)
68 }
69
70 func TestAPITokenTenantIsolation(t *testing.T) {
71 s := newStore(t)
72 other, err := s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
73 require.NoError(t, err)
74
75 _, id, err := s.CreateAPIToken(testTenant, "mine", time.Hour)
76 require.NoError(t, err)
77
78 // Another tenant cannot see it…
79 toks, err := s.ListAPITokens(other.ID)
80 require.NoError(t, err)
81 assert.Empty(t, toks)
82
83 // …nor revoke it (not-found semantics).
84 err = s.RevokeAPIToken(other.ID, id)
85 require.Error(t, err)
86
87 // The owner still can.
88 require.NoError(t, s.RevokeAPIToken(testTenant, id))
89 }
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(DefaultTenant) 52 tok2, _ := s.CreateEnrollmentToken(testTenant)
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(DefaultTenant) 67 tok3, _ := s.CreateEnrollmentToken(testTenant)
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/evolve.go
Old New
@@ -0,0 +1,30 @@
1 package store
2
3 import (
4 "database/sql"
5 "fmt"
6 )
7
8 // ensureColumn adds a column to an existing table if it is not already
9 // present. The schema const only creates tables; columns added after a
10 // table shipped must go through here so old databases pick them up on Open.
11 //
12 // table, column, and decl are interpolated verbatim into the ALTER TABLE
13 // statement (SQLite cannot bind identifiers): pass trusted compile-time
14 // constants only, never caller- or request-derived strings.
15 func ensureColumn(db *sql.DB, table, column, decl string) error {
16 var n int
17 err := db.QueryRow(
18 `SELECT count(*) FROM pragma_table_info(?) WHERE name = ?`, table, column,
19 ).Scan(&n)
20 if err != nil {
21 return fmt.Errorf("ensure %s.%s: %w", table, column, err)
22 }
23 if n > 0 {
24 return nil
25 }
26 if _, err := db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, decl)); err != nil {
27 return fmt.Errorf("add %s.%s: %w", table, column, err)
28 }
29 return nil
30 }
internal/server/store/evolve_test.go
Old New
@@ -0,0 +1,21 @@
1 package store
2
3 import "testing"
4
5 // ensureColumn must add a missing column exactly once and be a no-op after.
6 func TestEnsureColumnIdempotent(t *testing.T) {
7 s := newStore(t)
8 if err := ensureColumn(s.db, "tenants", "email", "TEXT NOT NULL DEFAULT ''"); err != nil {
9 t.Fatalf("first ensureColumn: %v", err)
10 }
11 if err := ensureColumn(s.db, "tenants", "email", "TEXT NOT NULL DEFAULT ''"); err != nil {
12 t.Fatalf("second ensureColumn: %v", err)
13 }
14 var n int
15 if err := s.db.QueryRow(`SELECT count(*) FROM pragma_table_info('tenants') WHERE name='email'`).Scan(&n); err != nil {
16 t.Fatal(err)
17 }
18 if n != 1 {
19 t.Fatalf("email column count = %d, want 1", n)
20 }
21 }
internal/server/store/identity.go
Old New
@@ -0,0 +1,150 @@
1 package store
2
3 import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 sqlite "modernc.org/sqlite"
11 )
12
13 // Tenant is one partition of the fleet. In v1 a tenant maps 1:1 to a signed-in
14 // identity; the OIDC binding (issuer, subject) lives directly on the row —
15 // there is no separate users table until multi-user tenants exist. Every
16 // tenant is born bound: the only creation path is JIT provisioning on first
17 // sign-in (CreateTenantForIdentity).
18 type Tenant struct {
19 ID, Name string
20 CreatedAt time.Time
21 OIDCIssuer string
22 OIDCSubject string
23 Email string
24 }
25
26 const tenantColumns = `id, name, created_at, oidc_issuer, oidc_subject, email`
27
28 // scanTenant reads a tenantColumns-projected row (positional, must match).
29 type rowScanner interface {
30 Scan(dest ...any) error
31 }
32
33 func scanTenant(sc rowScanner) (Tenant, error) {
34 var tn Tenant
35 var created string
36 if err := sc.Scan(&tn.ID, &tn.Name, &created, &tn.OIDCIssuer, &tn.OIDCSubject, &tn.Email); err != nil {
37 return Tenant{}, err
38 }
39 tn.CreatedAt, _ = time.Parse(time.RFC3339, created)
40 return tn, nil
41 }
42
43 // TenantByIdentity looks up the tenant bound to (issuer, subject) — the durable
44 // identity key (never email, which changes). ok=false with no error means no
45 // tenant is bound to that identity yet (the JIT-provision trigger).
46 func (s *Store) TenantByIdentity(issuer, subject string) (Tenant, bool, error) {
47 tn, err := scanTenant(s.db.QueryRow(
48 `SELECT `+tenantColumns+` FROM tenants WHERE oidc_issuer=? AND oidc_subject=?`, issuer, subject))
49 if errors.Is(err, sql.ErrNoRows) {
50 return Tenant{}, false, nil
51 }
52 if err != nil {
53 return Tenant{}, false, fmt.Errorf("tenant by identity: %w", err)
54 }
55 return tn, true, nil
56 }
57
58 // TenantByID looks up a tenant by its handle (the primary key). ok=false with
59 // no error means no such tenant. Handlers use it to read identity fields off
60 // the tenant row — email for GET /api/v1/me — once the middleware has resolved
61 // the caller's tenant from a PAT or session.
62 func (s *Store) TenantByID(id string) (Tenant, bool, error) {
63 tn, err := scanTenant(s.db.QueryRow(
64 `SELECT ` + tenantColumns + ` FROM tenants WHERE id=?`, id))
65 if errors.Is(err, sql.ErrNoRows) {
66 return Tenant{}, false, nil
67 }
68 if err != nil {
69 return Tenant{}, false, fmt.Errorf("tenant by id: %w", err)
70 }
71 return tn, true, nil
72 }
73
74 // CreateTenantForIdentity JIT-provisions a fresh tenant bound to (issuer,
75 // subject). The handle derives from the email local part (handleFromEmail),
76 // with a numeric suffix resolving collisions; SystemTenant is reserved and
77 // never assigned (a principal holding it could read the system audit scope).
78 // A second create for an identity that already owns a tenant is rejected (the
79 // identity unique index).
80 func (s *Store) CreateTenantForIdentity(issuer, subject, email string) (Tenant, error) {
81 tx, err := s.db.Begin()
82 if err != nil {
83 return Tenant{}, err
84 }
85 defer tx.Rollback()
86
87 base := handleFromEmail(email)
88 now := time.Now().UTC().Format(time.RFC3339)
89
90 // Try handle, handle-2, handle-3… on id (PRIMARY KEY, errno 1555) collision;
91 // SystemTenant is reserved so we skip it. A collision on the identity index
92 // (errno 2067) is terminal: the identity already owns a tenant.
93 for attempt := 1; ; attempt++ {
94 candidate := base
95 if attempt > 1 {
96 candidate = fmt.Sprintf("%s-%d", base, attempt)
97 }
98 if candidate == SystemTenant {
99 continue // reserved; the next suffix wins
100 }
101 _, err := tx.Exec(
102 `INSERT INTO tenants(id, name, created_at, oidc_issuer, oidc_subject, email) VALUES (?,?,?,?,?,?)`,
103 candidate, candidate, now, issuer, subject, email,
104 )
105 if err == nil {
106 if err := tx.Commit(); err != nil {
107 return Tenant{}, err
108 }
109 return Tenant{
110 ID: candidate, Name: candidate,
111 CreatedAt: time.Now().UTC(),
112 OIDCIssuer: issuer,
113 OIDCSubject: subject,
114 Email: email,
115 }, nil
116 }
117 if serr, ok := errors.AsType[*sqlite.Error](err); ok {
118 switch serr.Code() {
119 case 1555: // SQLITE_CONSTRAINT_PRIMARYKEY: handle taken, try next suffix
120 continue
121 case 2067: // SQLITE_CONSTRAINT_UNIQUE: the tenants_identity index tripped
122 return Tenant{}, fmt.Errorf("identity %q already bound to a tenant", subject)
123 }
124 }
125 return Tenant{}, fmt.Errorf("insert tenant: %w", err)
126 }
127 }
128
129 // handleFromEmail derives a tenant handle from an email's local part. Handles
130 // appear in SSH connect names <tenant>.<vm>, so they must be dot-free and
131 // stable; anything outside [a-z0-9-] flattens to '-'.
132 func handleFromEmail(email string) string {
133 local := email
134 if i := strings.IndexByte(email, '@'); i >= 0 {
135 local = email[:i]
136 }
137 var b strings.Builder
138 for _, r := range strings.ToLower(local) {
139 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
140 b.WriteRune(r)
141 } else {
142 b.WriteByte('-')
143 }
144 }
145 h := strings.Trim(b.String(), "-")
146 if h == "" {
147 return "user"
148 }
149 return h
150 }
internal/server/store/identity_test.go
Old New
@@ -0,0 +1,141 @@
1 package store
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 func TestTenantByIdentity(t *testing.T) {
12 s := newStore(t)
13
14 // Not found before any binding.
15 _, ok, err := s.TenantByIdentity("https://idp", "sub-1")
16 require.NoError(t, err)
17 assert.False(t, ok)
18
19 created, err := s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
20 require.NoError(t, err)
21 assert.Equal(t, "alex", created.ID)
22 assert.Equal(t, "https://idp", created.OIDCIssuer)
23 assert.Equal(t, "sub-1", created.OIDCSubject)
24 assert.Equal(t, "alex@a.com", created.Email)
25 assert.False(t, created.CreatedAt.IsZero())
26
27 // Found after create, same row.
28 got, ok, err := s.TenantByIdentity("https://idp", "sub-1")
29 require.NoError(t, err)
30 require.True(t, ok)
31 assert.Equal(t, created.ID, got.ID)
32 assert.Equal(t, "sub-1", got.OIDCSubject)
33 }
34
35 func TestTenantByID(t *testing.T) {
36 s := newStore(t)
37
38 // Unknown handle: not found, no error.
39 _, ok, err := s.TenantByID("nobody")
40 require.NoError(t, err)
41 assert.False(t, ok)
42
43 created, err := s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
44 require.NoError(t, err)
45
46 got, ok, err := s.TenantByID(created.ID)
47 require.NoError(t, err)
48 require.True(t, ok)
49 assert.Equal(t, created.ID, got.ID)
50 assert.Equal(t, "alex@a.com", got.Email)
51 assert.Equal(t, "sub-1", got.OIDCSubject)
52
53 // The harness-provisioned tenant is readable and carries its binding.
54 def, ok, err := s.TenantByID(testTenant)
55 require.NoError(t, err)
56 require.True(t, ok)
57 assert.Equal(t, testTenant, def.ID)
58 assert.Equal(t, testTenant+"@test.local", def.Email)
59 }
60
61 func TestCreateTenantForIdentityHandleCollision(t *testing.T) {
62 s := newStore(t)
63
64 a, err := s.CreateTenantForIdentity("https://idp", "sub-a", "alex@a.com")
65 require.NoError(t, err)
66 assert.Equal(t, "alex", a.ID)
67
68 // Same local part, different identity → numeric suffix.
69 b, err := s.CreateTenantForIdentity("https://idp", "sub-b", "alex@b.com")
70 require.NoError(t, err)
71 assert.Equal(t, "alex-2", b.ID)
72
73 c, err := s.CreateTenantForIdentity("https://idp", "sub-c", "alex@c.com")
74 require.NoError(t, err)
75 assert.Equal(t, "alex-3", c.ID)
76 }
77
78 func TestCreateTenantForIdentityDotFree(t *testing.T) {
79 s := newStore(t)
80
81 // Handles appear in <tenant>.<vm>; dots in the local part flatten to '-'.
82 got, err := s.CreateTenantForIdentity("https://idp", "sub-1", "first.last@x.com")
83 require.NoError(t, err)
84 assert.Equal(t, "first-last", got.ID)
85 assert.NotContains(t, got.ID, ".", "tenant handle must be dot-free")
86 }
87
88 func TestCreateTenantForIdentityReservedSystem(t *testing.T) {
89 s := newStore(t)
90
91 // SystemTenant is reserved (the audit scope for tenant-less events); JIT
92 // never assigns it — a principal holding it could read system audit rows.
93 got, err := s.CreateTenantForIdentity("https://idp", "sub-1", "system@x.com")
94 require.NoError(t, err)
95 assert.Equal(t, "system-2", got.ID)
96 }
97
98 func TestCreateTenantForIdentityDuplicateRejected(t *testing.T) {
99 s := newStore(t)
100
101 _, err := s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
102 require.NoError(t, err)
103
104 // The same identity must not mint a second tenant.
105 _, err = s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
106 require.Error(t, err)
107 }
108
109 func TestUnboundTenantsCoexist(t *testing.T) {
110 s := newStore(t)
111
112 // The identity index is partial (WHERE oidc_subject != ''): unbound rows —
113 // which exist only in databases predating the OIDC binding — must coexist
114 // without tripping it, or such a database fails on open.
115 _, err := s.db.Exec(
116 `INSERT INTO tenants (id, name, created_at) VALUES ('spare', 'spare', '2026-07-28T00:00:00Z')`)
117 require.NoError(t, err)
118 _, err = s.db.Exec(
119 `INSERT INTO tenants (id, name, created_at) VALUES ('spare2', 'spare2', '2026-07-28T00:00:00Z')`)
120 require.NoError(t, err, "a second unbound tenant must not trip the identity index")
121 }
122
123 func TestHandleFromEmail(t *testing.T) {
124 cases := map[string]string{
125 "alex@a.com": "alex",
126 "first.last@x.com": "first-last",
127 "UPPER@x.com": "upper",
128 "a+b@x.com": "a-b",
129 "weird__name@x.com": "weird--name",
130 "-lead-trail-@x.com": "lead-trail",
131 "@x.com": "user",
132 "...@x.com": "user",
133 }
134 for in, want := range cases {
135 got := handleFromEmail(in)
136 assert.Equal(t, want, got, "handleFromEmail(%q)", in)
137 assert.NotContains(t, got, ".", "handle must be dot-free")
138 assert.False(t, strings.HasPrefix(got, "-"), "no leading dash")
139 assert.False(t, strings.HasSuffix(got, "-"), "no trailing dash")
140 }
141 }
internal/server/store/sessions.go
Old New
@@ -0,0 +1,65 @@
1 package store
2
3 import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "time"
8
9 "github.com/a73x/eitri/internal/random"
10 )
11
12 // CreateSession mints a server-side console session for tenant, valid for ttl,
13 // and returns its id — a 256-bit random hex string used verbatim as the
14 // eitri_session cookie value. Server-side rows mean revocation works and a
15 // restart keeps users signed in.
16 func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) {
17 id := random.Hex(32)
18 now := time.Now().UTC()
19 _, err := s.db.Exec(
20 `INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`,
21 id, tenant, now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339),
22 )
23 if err != nil {
24 return "", fmt.Errorf("create session: %w", err)
25 }
26 return id, nil
27 }
28
29 // SessionTenant resolves a session id to its tenant, enforcing expiry on read.
30 // ok=false (no error) for an unknown, deleted, or expired session — the caller
31 // treats all three identically (redirect to sign-in).
32 func (s *Store) SessionTenant(id string) (string, bool, error) {
33 var tenant string
34 err := s.db.QueryRow(
35 `SELECT tenant FROM sessions WHERE id=? AND expires_at > ?`,
36 id, time.Now().UTC().Format(time.RFC3339),
37 ).Scan(&tenant)
38 if errors.Is(err, sql.ErrNoRows) {
39 return "", false, nil
40 }
41 if err != nil {
42 return "", false, fmt.Errorf("session tenant: %w", err)
43 }
44 return tenant, true, nil
45 }
46
47 // DeleteSession revokes a session (sign-out). Deleting an absent id is a no-op.
48 func (s *Store) DeleteSession(id string) error {
49 if _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, id); err != nil {
50 return fmt.Errorf("delete session: %w", err)
51 }
52 return nil
53 }
54
55 // ReapSessions deletes every expired session row and reports how many. Run
56 // opportunistically to keep the table bounded; expiry is already enforced on
57 // read, so this is hygiene, not correctness.
58 func (s *Store) ReapSessions() (int64, error) {
59 res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`,
60 time.Now().UTC().Format(time.RFC3339))
61 if err != nil {
62 return 0, fmt.Errorf("reap sessions: %w", err)
63 }
64 return res.RowsAffected()
65 }
internal/server/store/sessions_test.go
Old New
@@ -0,0 +1,70 @@
1 package store
2
3 import (
4 "testing"
5 "time"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9 )
10
11 func TestSessionLifecycle(t *testing.T) {
12 s := newStore(t)
13
14 id, err := s.CreateSession(testTenant, time.Hour)
15 require.NoError(t, err)
16 assert.Len(t, id, 64, "session id is 64-hex (256-bit)")
17
18 tenant, ok, err := s.SessionTenant(id)
19 require.NoError(t, err)
20 require.True(t, ok)
21 assert.Equal(t, testTenant, tenant)
22
23 // Unknown id.
24 _, ok, err = s.SessionTenant("nope")
25 require.NoError(t, err)
26 assert.False(t, ok)
27
28 // Delete revokes.
29 require.NoError(t, s.DeleteSession(id))
30 _, ok, err = s.SessionTenant(id)
31 require.NoError(t, err)
32 assert.False(t, ok)
33 }
34
35 func TestSessionExpiry(t *testing.T) {
36 s := newStore(t)
37
38 // A negative ttl mints an already-expired session; reads must reject it.
39 id, err := s.CreateSession(testTenant, -time.Minute)
40 require.NoError(t, err)
41
42 _, ok, err := s.SessionTenant(id)
43 require.NoError(t, err)
44 assert.False(t, ok, "expired session must not resolve")
45 }
46
47 func TestReapSessions(t *testing.T) {
48 s := newStore(t)
49
50 live, err := s.CreateSession(testTenant, time.Hour)
51 require.NoError(t, err)
52 _, err = s.CreateSession(testTenant, -time.Minute)
53 require.NoError(t, err)
54 _, err = s.CreateSession(testTenant, -time.Hour)
55 require.NoError(t, err)
56
57 n, err := s.ReapSessions()
58 require.NoError(t, err)
59 assert.Equal(t, int64(2), n, "both expired rows reaped")
60
61 // The live one survives.
62 _, ok, err := s.SessionTenant(live)
63 require.NoError(t, err)
64 assert.True(t, ok)
65
66 // Reaping again removes nothing.
67 n, err = s.ReapSessions()
68 require.NoError(t, err)
69 assert.Equal(t, int64(0), n)
70 }
internal/server/store/store.go
Old New
@@ -33,12 +33,13 @@ var ErrHostNotFound = errors.New("host not found")
33 // not accepting new VMs (e.g. it is decommissioning). 33 // not accepting new VMs (e.g. it is decommissioning).
34 var ErrHostNotEnrolled = errors.New("host not accepting new VMs") 34 var ErrHostNotEnrolled = errors.New("host not accepting new VMs")
35 35
36 // DefaultTenant is the reserved bootstrap tenant every resource collapses to 36 // SystemTenant is the audit scope for events with no resolvable tenant — a
37 // in v1. It is NOT special: no authz/resolve/uniqueness code may branch on 37 // denied enroll attempt, a reap ack racing its host's deletion. It is NOT a
38 // this literal — it is seeded like any tenant and compared value-agnostically. 38 // tenant: no tenants row exists for it, no principal can ever hold it (JIT
39 // The only legitimate reference points are bootstrap wiring (the admin 39 // allocation skips the handle), so system rows are durable in the audit_log
40 // principal, the single CA's tenant) and tests. 40 // but invisible to every tenant-scoped read. Only AppendAudit callers may
41 const DefaultTenant = "default" 41 // reference it.
42 const SystemTenant = "system"
42 43
43 type Store struct { 44 type Store struct {
44 db *sql.DB 45 db *sql.DB
@@ -158,12 +159,18 @@ CREATE TABLE IF NOT EXISTS freed_cidrs (
158 bridge_cidr TEXT PRIMARY KEY 159 bridge_cidr TEXT PRIMARY KEY
159 ); 160 );
160 161
161 -- revoked SSH user certs: an admin can revoke a specific minted user cert by 162 -- revoked SSH user certs: a tenant can revoke a specific user cert by its serial
162 -- its serial (crypto-random uint64, set at mint) so it is rejected at the jump 163 -- (crypto-random uint64, set at mint) so it is rejected at the jump gate before
163 -- gate before its short TTL expires. serial is stored as the int64 bit-pattern 164 -- its short TTL expires. serial is stored as the int64 bit-pattern of the uint64
164 -- of the uint64 (SQLite INTEGER is signed 64-bit) — a bijection, so PRIMARY KEY 165 -- (SQLite INTEGER is signed 64-bit) — a bijection, so PRIMARY KEY uniqueness and
165 -- uniqueness and lookups are preserved. Enforced at the GATE only (see sshgate); 166 -- lookups are preserved. The tenant column (added via ensureColumn in Open — the
166 -- guests trust the CA with no guest-side KRL — a multi-user/rotation follow-up. 167 -- table shipped without it) scopes the revocation LIST per tenant and records
168 -- which tenant filed the revocation; old rows backfill to 'default'. Enforcement
169 -- at the GATE stays fleet-wide by serial (IsSSHCertRevoked ignores tenant): a
170 -- revocation only ever DENIES access, serials are 64-bit crypto-random, and the
171 -- gate has no user-cert mint registry to key a tenant off, so a global kill by
172 -- serial is fail-safe. Guests trust the CA with no guest-side KRL — a
173 -- multi-user/rotation follow-up.
167 CREATE TABLE IF NOT EXISTS revoked_ssh_certs ( 174 CREATE TABLE IF NOT EXISTS revoked_ssh_certs (
168 serial INTEGER PRIMARY KEY, 175 serial INTEGER PRIMARY KEY,
169 revoked_at DATETIME NOT NULL, 176 revoked_at DATETIME NOT NULL,
@@ -190,6 +197,28 @@ CREATE TABLE IF NOT EXISTS tenant_user_cas (
190 ); 197 );
191 CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_user_cas_pubkey ON tenant_user_cas(ca_pubkey); 198 CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_user_cas_pubkey ON tenant_user_cas(ca_pubkey);
192 199
200 -- Console sessions. Server-side so revocation works and restarts keep
201 -- users signed in. id is 256-bit random hex; expiry enforced on read.
202 CREATE TABLE IF NOT EXISTS sessions (
203 id TEXT PRIMARY KEY,
204 tenant TEXT NOT NULL REFERENCES tenants(id),
205 created_at DATETIME NOT NULL,
206 expires_at DATETIME NOT NULL
207 );
208
209 -- Personal access tokens, tenant-scoped, stored as SHA-256 of the secret.
210 -- expires_at NULL means non-expiring.
211 CREATE TABLE IF NOT EXISTS api_tokens (
212 id TEXT PRIMARY KEY,
213 tenant TEXT NOT NULL REFERENCES tenants(id),
214 name TEXT NOT NULL,
215 token_hash TEXT NOT NULL UNIQUE,
216 created_at DATETIME NOT NULL,
217 expires_at DATETIME,
218 last_used_at DATETIME,
219 revoked_at DATETIME
220 );
221
193 -- append-only operational audit trail (enrollment, decommission). Read via 222 -- append-only operational audit trail (enrollment, decommission). Read via
194 -- ListAudit / GET /api/v1/audit; rows are never UPDATEd, and the only DELETE 223 -- ListAudit / GET /api/v1/audit; rows are never UPDATEd, and the only DELETE
195 -- is retention pruning (PruneAudit, driven by the server's audit_retention 224 -- is retention pruning (PruneAudit, driven by the server's audit_retention
@@ -197,10 +226,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_user_cas_pubkey ON tenant_user_cas(
197 -- lexicographic comparison chronological -- PruneAudit's DELETE relies on 226 -- lexicographic comparison chronological -- PruneAudit's DELETE relies on
198 -- every writer keeping that format. 227 -- every writer keeping that format.
199 -- 228 --
200 -- No tenant column BY DESIGN: some rows are genuinely tenant-less — e.g. 229 -- The tenant column (added via ensureColumn in Open — the table shipped
201 -- host.enroll.denied written from an UNAUTHENTICATED enroll attempt, before any 230 -- without it) scopes audit read per tenant. Rows written before any tenant is
202 -- tenant is known. Audit read therefore stays Fleet-gated (handleListAudit); 231 -- known — e.g. host.enroll.denied from an UNAUTHENTICATED enroll attempt — are
203 -- per-tenant audit is a recorded tenant-#2 blocker (spec §Deferred). 232 -- recorded against 'default' (the column's backfill/default value), since a row
233 -- must exist before an authenticated tenant is resolvable.
204 CREATE TABLE IF NOT EXISTS audit_log ( 234 CREATE TABLE IF NOT EXISTS audit_log (
205 id INTEGER PRIMARY KEY AUTOINCREMENT, 235 id INTEGER PRIMARY KEY AUTOINCREMENT,
206 at DATETIME NOT NULL, 236 at DATETIME NOT NULL,
@@ -225,11 +255,29 @@ func Open(path, cidrPool string) (*Store, error) {
225 return nil, fmt.Errorf("apply schema: %w", err) 255 return nil, fmt.Errorf("apply schema: %w", err)
226 } 256 }
227 257
228 // Seed the bootstrap tenant; first call wins, like cidr_pool below. 258 // Columns added after their table shipped: OIDC identity binding on tenants
229 if _, err := db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING`, 259 // and the tenant scope on audit_log. Idempotent; old databases pick them up.
230 DefaultTenant, DefaultTenant, time.Now().UTC().Format(time.RFC3339)); err != nil { 260 for _, c := range []struct{ table, column, decl string }{
261 {"tenants", "oidc_issuer", "TEXT NOT NULL DEFAULT ''"},
262 {"tenants", "oidc_subject", "TEXT NOT NULL DEFAULT ''"},
263 {"tenants", "email", "TEXT NOT NULL DEFAULT ''"},
264 // The 'default' literals are backfill stamps for rows that predate the
265 // tenant column (all writes pass the tenant explicitly, so the DEFAULT
266 // never applies to new rows).
267 {"audit_log", "tenant", "TEXT NOT NULL DEFAULT 'default'"},
268 {"revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"},
269 } {
270 if err := ensureColumn(db, c.table, c.column, c.decl); err != nil {
271 db.Close()
272 return nil, err
273 }
274 }
275 // One identity binds at most one tenant (per issuer). Partial index so
276 // unbound rows (empty issuer+subject) don't collide.
277 if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS tenants_identity
278 ON tenants(oidc_issuer, oidc_subject) WHERE oidc_subject != ''`); err != nil {
231 db.Close() 279 db.Close()
232 return nil, fmt.Errorf("seed default tenant: %w", err) 280 return nil, fmt.Errorf("create tenants_identity index: %w", err)
233 } 281 }
234 282
235 // Store cidr_pool; ON CONFLICT DO NOTHING means the first call wins. 283 // Store cidr_pool; ON CONFLICT DO NOTHING means the first call wins.
@@ -392,8 +440,8 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo
392 "host_id": id, "name": name, "os": osName, "arch": arch, 440 "host_id": id, "name": name, "os": osName, "arch": arch,
393 "remote": remote, "token_hash_prefix": hash[:8], 441 "remote": remote, "token_hash_prefix": hash[:8],
394 }) 442 })
395 if _, err := tx.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`, 443 if _, err := tx.Exec(`INSERT INTO audit_log(at, tenant, action, detail) VALUES (?, ?, ?, ?)`,
396 now.Format(time.RFC3339), "host.enroll", string(detail)); err != nil { 444 now.Format(time.RFC3339), tenant, "host.enroll", string(detail)); err != nil {
397 return Host{}, fmt.Errorf("audit enroll: %w", err) 445 return Host{}, fmt.Errorf("audit enroll: %w", err)
398 } 446 }
399 447
@@ -451,16 +499,17 @@ func (s *Store) BumpCredGeneration(id, remote string) (int64, error) {
451 } 499 }
452 defer tx.Rollback() 500 defer tx.Rollback()
453 var gen int64 501 var gen int64
502 var tenant string
454 if err := tx.QueryRow( 503 if err := tx.QueryRow(
455 `UPDATE hosts SET cred_generation = cred_generation + 1 WHERE id=? RETURNING cred_generation`, id, 504 `UPDATE hosts SET cred_generation = cred_generation + 1 WHERE id=? RETURNING cred_generation, tenant`, id,
456 ).Scan(&gen); err != nil { 505 ).Scan(&gen, &tenant); err != nil {
457 return 0, fmt.Errorf("bump cred_generation for %s: %w", id, err) 506 return 0, fmt.Errorf("bump cred_generation for %s: %w", id, err)
458 } 507 }
459 detail, _ := json.Marshal(map[string]string{ 508 detail, _ := json.Marshal(map[string]string{
460 "host_id": id, "new_generation": strconv.FormatInt(gen, 10), "remote": remote, 509 "host_id": id, "new_generation": strconv.FormatInt(gen, 10), "remote": remote,
461 }) 510 })
462 if _, err := tx.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`, 511 if _, err := tx.Exec(`INSERT INTO audit_log(at, tenant, action, detail) VALUES (?, ?, ?, ?)`,
463 time.Now().UTC().Format(time.RFC3339), "host.credential.revoke", string(detail)); err != nil { 512 time.Now().UTC().Format(time.RFC3339), tenant, "host.credential.revoke", string(detail)); err != nil {
464 return 0, fmt.Errorf("audit revoke: %w", err) 513 return 0, fmt.Errorf("audit revoke: %w", err)
465 } 514 }
466 return gen, tx.Commit() 515 return gen, tx.Commit()
@@ -672,11 +721,13 @@ type AuditEntry struct {
672 Detail string 721 Detail string
673 } 722 }
674 723
675 // AppendAudit records an audit event. detail is a small JSON blob; keep 724 // AppendAudit records an audit event scoped to tenant. detail is a small JSON
676 // secrets out (hash prefixes, not tokens). 725 // blob; keep secrets out (hash prefixes, not tokens). Rows written before an
677 func (s *Store) AppendAudit(action, detail string) error { 726 // authenticated tenant is known (e.g. a denied enroll attempt) pass
678 _, err := s.db.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`, 727 // SystemTenant, which no tenant-scoped read ever surfaces.
679 time.Now().UTC().Format(time.RFC3339), action, detail) 728 func (s *Store) AppendAudit(tenant, action, detail string) error {
729 _, err := s.db.Exec(`INSERT INTO audit_log(at, tenant, action, detail) VALUES (?, ?, ?, ?)`,
730 time.Now().UTC().Format(time.RFC3339), tenant, action, detail)
680 return err 731 return err
681 } 732 }
682 733
@@ -698,24 +749,26 @@ func scanAuditRows(rows *sql.Rows) ([]AuditEntry, error) {
698 return out, rows.Err() 749 return out, rows.Err()
699 } 750 }
700 751
701 // ListAudit returns up to limit audit entries, newest first. 752 // ListAudit returns up to limit of tenant's audit entries, newest first.
702 func (s *Store) ListAudit(limit int) ([]AuditEntry, error) { 753 func (s *Store) ListAudit(tenant string, limit int) ([]AuditEntry, error) {
703 rows, err := s.db.Query(`SELECT at, action, detail FROM audit_log ORDER BY id DESC LIMIT ?`, limit) 754 rows, err := s.db.Query(`SELECT at, action, detail FROM audit_log WHERE tenant=? ORDER BY id DESC LIMIT ?`, tenant, limit)
704 if err != nil { 755 if err != nil {
705 return nil, err 756 return nil, err
706 } 757 }
707 return scanAuditRows(rows) 758 return scanAuditRows(rows)
708 } 759 }
709 760
710 // ListVMEvents returns up to limit audit rows whose detail JSON carries the 761 // ListVMEvents returns up to limit of tenant's audit rows whose detail JSON
711 // given vm_id (the lifecycle timeline for one VM), newest first. It filters on 762 // carries the given vm_id (the lifecycle timeline for one VM), newest first. It
712 // json_extract(detail,'$.vm_id'), so every lifecycle emitter must key the VM id 763 // filters on json_extract(detail,'$.vm_id'), so every lifecycle emitter must
713 // as exactly "vm_id". Historical events for a hard-deleted VM stay returnable: 764 // key the VM id as exactly "vm_id". Scoped to tenant so one tenant cannot read
714 // the append-only log outlives the VM row. 765 // another's VM history. Historical events for a hard-deleted VM stay returnable:
715 func (s *Store) ListVMEvents(vmID string, limit int) ([]AuditEntry, error) { 766 // the append-only log outlives the VM row, and the vm.reap row carries the VM's
767 // tenant.
768 func (s *Store) ListVMEvents(tenant, vmID string, limit int) ([]AuditEntry, error) {
716 rows, err := s.db.Query( 769 rows, err := s.db.Query(
717 `SELECT at, action, detail FROM audit_log WHERE json_extract(detail,'$.vm_id')=? ORDER BY id DESC LIMIT ?`, 770 `SELECT at, action, detail FROM audit_log WHERE tenant=? AND json_extract(detail,'$.vm_id')=? ORDER BY id DESC LIMIT ?`,
718 vmID, limit, 771 tenant, vmID, limit,
719 ) 772 )
720 if err != nil { 773 if err != nil {
721 return nil, err 774 return nil, err
@@ -747,17 +800,18 @@ type RevokedCert struct {
747 Reason string 800 Reason string
748 } 801 }
749 802
750 // RevokeSSHCert adds serial to the revocation list so the gate rejects any cert 803 // RevokeSSHCert adds serial to tenant's revocation list so the gate rejects any
751 // carrying it. Idempotent: revoking an already-revoked serial is a no-op that 804 // cert carrying it. Idempotent: revoking an already-revoked serial is a no-op
752 // keeps the ORIGINAL revoked_at/reason (a re-revoke does not overwrite the 805 // that keeps the ORIGINAL tenant/revoked_at/reason (a re-revoke does not
753 // audit-relevant first record). serial is bit-cast to int64 for storage — 806 // overwrite the audit-relevant first record — including its owning tenant, so a
754 // SQLite INTEGER is signed 64-bit, and the cast is a bijection so uniqueness and 807 // second tenant cannot re-file a serial another already owns). serial is bit-cast
755 // lookups by serial are preserved. 808 // to int64 for storage — SQLite INTEGER is signed 64-bit, and the cast is a
756 func (s *Store) RevokeSSHCert(serial uint64, reason string) error { 809 // bijection so uniqueness and lookups by serial are preserved.
810 func (s *Store) RevokeSSHCert(tenant string, serial uint64, reason string) error {
757 _, err := s.db.Exec( 811 _, err := s.db.Exec(
758 `INSERT INTO revoked_ssh_certs(serial, revoked_at, reason) VALUES (?, ?, ?) 812 `INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason) VALUES (?, ?, ?, ?)
759 ON CONFLICT(serial) DO NOTHING`, 813 ON CONFLICT(serial) DO NOTHING`,
760 int64(serial), time.Now().UTC().Format(time.RFC3339), reason, 814 int64(serial), tenant, time.Now().UTC().Format(time.RFC3339), reason,
761 ) 815 )
762 if err != nil { 816 if err != nil {
763 return fmt.Errorf("revoke ssh cert: %w", err) 817 return fmt.Errorf("revoke ssh cert: %w", err)
@@ -777,10 +831,10 @@ func (s *Store) IsSSHCertRevoked(serial uint64) (bool, error) {
777 return n > 0, nil 831 return n > 0, nil
778 } 832 }
779 833
780 // ListRevokedSSHCerts returns every revoked cert serial (+ reason/time), newest 834 // ListRevokedSSHCerts returns tenant's revoked cert serials (+ reason/time),
781 // first, for the admin list endpoint. 835 // newest first, for the tenant-scoped list endpoint.
782 func (s *Store) ListRevokedSSHCerts() ([]RevokedCert, error) { 836 func (s *Store) ListRevokedSSHCerts(tenant string) ([]RevokedCert, error) {
783 rows, err := s.db.Query(`SELECT serial, revoked_at, reason FROM revoked_ssh_certs ORDER BY revoked_at DESC, serial DESC`) 837 rows, err := s.db.Query(`SELECT serial, revoked_at, reason FROM revoked_ssh_certs WHERE tenant=? ORDER BY revoked_at DESC, serial DESC`, tenant)
784 if err != nil { 838 if err != nil {
785 return nil, err 839 return nil, err
786 } 840 }
internal/server/store/store_test.go
Old New
@@ -20,17 +20,24 @@ func TestPing(t *testing.T) {
20 assert.Error(t, s.Ping(context.Background())) 20 assert.Error(t, s.Ping(context.Background()))
21 } 21 }
22 22
23 // testTenant is the tenant newStore provisions — through the real JIT path,
24 // the only way tenants are born. The name has no significance.
25 const testTenant = "default"
26
23 func newStore(t *testing.T) *Store { 27 func newStore(t *testing.T) *Store {
24 t.Helper() 28 t.Helper()
25 s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16") 29 s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16")
26 require.NoError(t, err) 30 require.NoError(t, err)
31 tn, err := s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
32 require.NoError(t, err)
33 require.Equal(t, testTenant, tn.ID)
27 t.Cleanup(func() { s.Close() }) 34 t.Cleanup(func() { s.Close() })
28 return s 35 return s
29 } 36 }
30 37
31 func enrollHost(t *testing.T, s *Store) Host { 38 func enrollHost(t *testing.T, s *Store) Host {
32 t.Helper() 39 t.Helper()
33 tok, err := s.CreateEnrollmentToken(DefaultTenant) 40 tok, err := s.CreateEnrollmentToken(testTenant)
34 require.NoError(t, err) 41 require.NoError(t, err)
35 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "") 42 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "")
36 require.NoError(t, err) 43 require.NoError(t, err)
@@ -42,18 +49,18 @@ func TestVMByTenantName(t *testing.T) {
42 h := enrollHost(t, s) 49 h := enrollHost(t, s)
43 vm := makeVM(t, s, h, "web-1") 50 vm := makeVM(t, s, h, "web-1")
44 51
45 got, err := s.VMByTenantName(DefaultTenant, "web-1") 52 got, err := s.VMByTenantName(testTenant, "web-1")
46 require.NoError(t, err) 53 require.NoError(t, err)
47 assert.Equal(t, vm.ID, got.ID) 54 assert.Equal(t, vm.ID, got.ID)
48 assert.Equal(t, h.ID, got.HostID) 55 assert.Equal(t, h.ID, got.HostID)
49 56
50 // Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false). 57 // Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false).
51 _, err = s.VMByTenantName(DefaultTenant, "nope") 58 _, err = s.VMByTenantName(testTenant, "nope")
52 assert.ErrorIs(t, err, sql.ErrNoRows) 59 assert.ErrorIs(t, err, sql.ErrNoRows)
53 60
54 // Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM. 61 // Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM.
55 require.NoError(t, s.TombstoneVM(vm.ID)) 62 require.NoError(t, s.TombstoneVM(vm.ID))
56 _, err = s.VMByTenantName(DefaultTenant, "web-1") 63 _, err = s.VMByTenantName(testTenant, "web-1")
57 assert.ErrorIs(t, err, sql.ErrNoRows) 64 assert.ErrorIs(t, err, sql.ErrNoRows)
58 } 65 }
59 66
@@ -101,7 +108,7 @@ func TestVMHostKeyAndCertPersist(t *testing.T) {
101 assert.Equal(t, keyPEM, vms[0].SSHHostKey) 108 assert.Equal(t, keyPEM, vms[0].SSHHostKey)
102 assert.Equal(t, cert, vms[0].SSHHostCert) 109 assert.Equal(t, cert, vms[0].SSHHostCert)
103 110
104 byName, err := s.VMByTenantName(DefaultTenant, "with-hostcert") 111 byName, err := s.VMByTenantName(testTenant, "with-hostcert")
105 require.NoError(t, err) 112 require.NoError(t, err)
106 assert.Equal(t, keyPEM, byName.SSHHostKey) 113 assert.Equal(t, keyPEM, byName.SSHHostKey)
107 assert.Equal(t, cert, byName.SSHHostCert) 114 assert.Equal(t, cert, byName.SSHHostCert)
@@ -116,7 +123,7 @@ func TestSSHCertRevocation(t *testing.T) {
116 assert.False(t, revoked) 123 assert.False(t, revoked)
117 124
118 // Revoke, then it reads back as revoked. 125 // Revoke, then it reads back as revoked.
119 require.NoError(t, s.RevokeSSHCert(42, "leaked laptop")) 126 require.NoError(t, s.RevokeSSHCert(testTenant, 42, "leaked laptop"))
120 revoked, err = s.IsSSHCertRevoked(42) 127 revoked, err = s.IsSSHCertRevoked(42)
121 require.NoError(t, err) 128 require.NoError(t, err)
122 assert.True(t, revoked) 129 assert.True(t, revoked)
@@ -127,8 +134,8 @@ func TestSSHCertRevocation(t *testing.T) {
127 assert.False(t, revoked) 134 assert.False(t, revoked)
128 135
129 // Idempotent: re-revoking keeps the original reason and does not error. 136 // Idempotent: re-revoking keeps the original reason and does not error.
130 require.NoError(t, s.RevokeSSHCert(42, "different reason")) 137 require.NoError(t, s.RevokeSSHCert(testTenant, 42, "different reason"))
131 list, err := s.ListRevokedSSHCerts() 138 list, err := s.ListRevokedSSHCerts(testTenant)
132 require.NoError(t, err) 139 require.NoError(t, err)
133 require.Len(t, list, 1) 140 require.Len(t, list, 1)
134 assert.Equal(t, uint64(42), list[0].Serial) 141 assert.Equal(t, uint64(42), list[0].Serial)
@@ -136,6 +143,43 @@ func TestSSHCertRevocation(t *testing.T) {
136 assert.False(t, list[0].RevokedAt.IsZero()) 143 assert.False(t, list[0].RevokedAt.IsZero())
137 } 144 }
138 145
146 // TestSSHCertRevocationTenantScoped pins that the revocation LIST is per-tenant:
147 // each tenant sees only the serials it filed, while gate enforcement
148 // (IsSSHCertRevoked) stays fleet-wide by serial. A re-revoke of a serial another
149 // tenant already owns keeps the original owner (ON CONFLICT DO NOTHING).
150 func TestSSHCertRevocationTenantScoped(t *testing.T) {
151 s := newStore(t)
152 beta, err := s.CreateTenantForIdentity("https://issuer.example", "sub-beta", "beta@example.com")
153 require.NoError(t, err)
154
155 require.NoError(t, s.RevokeSSHCert(testTenant, 100, "default's cert"))
156 require.NoError(t, s.RevokeSSHCert(beta.ID, 200, "beta's cert"))
157
158 def, err := s.ListRevokedSSHCerts(testTenant)
159 require.NoError(t, err)
160 require.Len(t, def, 1)
161 assert.Equal(t, uint64(100), def[0].Serial)
162
163 bl, err := s.ListRevokedSSHCerts(beta.ID)
164 require.NoError(t, err)
165 require.Len(t, bl, 1)
166 assert.Equal(t, uint64(200), bl[0].Serial)
167
168 // Gate enforcement is fleet-wide by serial: both are revoked regardless of tenant.
169 for _, serial := range []uint64{100, 200} {
170 revoked, err := s.IsSSHCertRevoked(serial)
171 require.NoError(t, err)
172 assert.True(t, revoked, "serial %d must read revoked at the gate", serial)
173 }
174
175 // beta re-filing default's serial is a no-op: original owner (default) keeps it.
176 require.NoError(t, s.RevokeSSHCert(beta.ID, 100, "beta tries to steal"))
177 bl, err = s.ListRevokedSSHCerts(beta.ID)
178 require.NoError(t, err)
179 require.Len(t, bl, 1, "beta must not acquire a serial default already owns")
180 assert.Equal(t, uint64(200), bl[0].Serial)
181 }
182
139 // TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial 183 // TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial
140 // above math.MaxInt64 (as real crypto-random serials routinely are) must 184 // above math.MaxInt64 (as real crypto-random serials routinely are) must
141 // round-trip through insert, lookup, and list without truncation or collision. 185 // round-trip through insert, lookup, and list without truncation or collision.
@@ -145,7 +189,7 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) {
145 const big = uint64(0xFFFFFFFFFFFFFFFF) // all-ones: well past MaxInt64 189 const big = uint64(0xFFFFFFFFFFFFFFFF) // all-ones: well past MaxInt64
146 const other = uint64(0x8000000000000000) 190 const other = uint64(0x8000000000000000)
147 191
148 require.NoError(t, s.RevokeSSHCert(big, "big")) 192 require.NoError(t, s.RevokeSSHCert(testTenant, big, "big"))
149 revoked, err := s.IsSSHCertRevoked(big) 193 revoked, err := s.IsSSHCertRevoked(big)
150 require.NoError(t, err) 194 require.NoError(t, err)
151 assert.True(t, revoked) 195 assert.True(t, revoked)
@@ -155,8 +199,8 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) {
155 require.NoError(t, err) 199 require.NoError(t, err)
156 assert.False(t, revoked) 200 assert.False(t, revoked)
157 201
158 require.NoError(t, s.RevokeSSHCert(other, "other")) 202 require.NoError(t, s.RevokeSSHCert(testTenant, other, "other"))
159 list, err := s.ListRevokedSSHCerts() 203 list, err := s.ListRevokedSSHCerts(testTenant)
160 require.NoError(t, err) 204 require.NoError(t, err)
161 require.Len(t, list, 2) 205 require.Len(t, list, 2)
162 } 206 }
@@ -166,32 +210,32 @@ func TestTenantUserCAs(t *testing.T) {
166 const ca1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIONE" 210 const ca1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIONE"
167 const ca2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAATWO" 211 const ca2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAATWO"
168 212
169 has, err := s.TenantHasUserCA(DefaultTenant) 213 has, err := s.TenantHasUserCA(testTenant)
170 require.NoError(t, err) 214 require.NoError(t, err)
171 assert.False(t, has, "fresh tenant has no CA") 215 assert.False(t, has, "fresh tenant has no CA")
172 216
173 require.NoError(t, s.AddTenantUserCA(DefaultTenant, ca1, "tenant", "laptop", "admin")) 217 require.NoError(t, s.AddTenantUserCA(testTenant, ca1, "tenant", "laptop", "admin"))
174 require.NoError(t, s.AddTenantUserCA(DefaultTenant, ca2, "tenant", "ci", "admin")) 218 require.NoError(t, s.AddTenantUserCA(testTenant, ca2, "tenant", "ci", "admin"))
175 require.NoError(t, s.AddTenantUserCA(DefaultTenant, ca1, "tenant", "dup", "admin")) // idempotent 219 require.NoError(t, s.AddTenantUserCA(testTenant, ca1, "tenant", "dup", "admin")) // idempotent
176 220
177 has, err = s.TenantHasUserCA(DefaultTenant) 221 has, err = s.TenantHasUserCA(testTenant)
178 require.NoError(t, err) 222 require.NoError(t, err)
179 assert.True(t, has) 223 assert.True(t, has)
180 224
181 list, err := s.ListTenantUserCAs(DefaultTenant) 225 list, err := s.ListTenantUserCAs(testTenant)
182 require.NoError(t, err) 226 require.NoError(t, err)
183 require.Len(t, list, 2, "duplicate insert is a no-op") 227 require.Len(t, list, 2, "duplicate insert is a no-op")
184 228
185 ten, ok, err := s.TenantForUserCA(ca1) 229 ten, ok, err := s.TenantForUserCA(ca1)
186 require.NoError(t, err) 230 require.NoError(t, err)
187 assert.True(t, ok) 231 assert.True(t, ok)
188 assert.Equal(t, DefaultTenant, ten) 232 assert.Equal(t, testTenant, ten)
189 233
190 _, ok, err = s.TenantForUserCA("ssh-ed25519 AAAAUNKNOWN") 234 _, ok, err = s.TenantForUserCA("ssh-ed25519 AAAAUNKNOWN")
191 require.NoError(t, err) 235 require.NoError(t, err)
192 assert.False(t, ok) 236 assert.False(t, ok)
193 237
194 require.NoError(t, s.RemoveTenantUserCA(DefaultTenant, ca1)) 238 require.NoError(t, s.RemoveTenantUserCA(testTenant, ca1))
195 _, ok, err = s.TenantForUserCA(ca1) 239 _, ok, err = s.TenantForUserCA(ca1)
196 require.NoError(t, err) 240 require.NoError(t, err)
197 assert.False(t, ok, "removed CA no longer resolves") 241 assert.False(t, ok, "removed CA no longer resolves")
@@ -199,7 +243,7 @@ func TestTenantUserCAs(t *testing.T) {
199 243
200 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { 244 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
201 s := newStore(t) 245 s := newStore(t)
202 tok1, _ := s.CreateEnrollmentToken(DefaultTenant) 246 tok1, _ := s.CreateEnrollmentToken(testTenant)
203 h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") 247 h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "")
204 require.NoError(t, err) 248 require.NoError(t, err)
205 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) 249 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)
@@ -207,7 +251,7 @@ func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
207 _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "") 251 _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "")
208 assert.Error(t, err, "token must be one-time use") 252 assert.Error(t, err, "token must be one-time use")
209 253
210 tok2, _ := s.CreateEnrollmentToken(DefaultTenant) 254 tok2, _ := s.CreateEnrollmentToken(testTenant)
211 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") 255 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
212 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) 256 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)
213 } 257 }
@@ -294,7 +338,9 @@ func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) {
294 s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22") 338 s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22")
295 require.NoError(t, err) 339 require.NoError(t, err)
296 defer s.Close() 340 defer s.Close()
297 tok, _ := s.CreateEnrollmentToken(DefaultTenant) 341 _, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
342 require.NoError(t, err)
343 tok, _ := s.CreateEnrollmentToken(testTenant)
298 h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "") 344 h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "")
299 require.NoError(t, err) 345 require.NoError(t, err)
300 assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved") 346 assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved")
@@ -304,11 +350,13 @@ func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) {
304 s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1) 350 s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1)
305 require.NoError(t, err) 351 require.NoError(t, err)
306 defer s.Close() 352 defer s.Close()
307 tok1, _ := s.CreateEnrollmentToken(DefaultTenant) 353 _, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
354 require.NoError(t, err)
355 tok1, _ := s.CreateEnrollmentToken(testTenant)
308 h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") 356 h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "")
309 require.NoError(t, err) 357 require.NoError(t, err)
310 assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR) 358 assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR)
311 tok2, _ := s.CreateEnrollmentToken(DefaultTenant) 359 tok2, _ := s.CreateEnrollmentToken(testTenant)
312 _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") 360 _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "")
313 assert.ErrorContains(t, err, "exhausted") 361 assert.ErrorContains(t, err, "exhausted")
314 } 362 }
@@ -346,7 +394,7 @@ func TestForceRemoveHostPurgesVMsAndFreesCIDR(t *testing.T) {
346 394
347 // The freed CIDR is consulted before the monotonic allocator, so a fresh 395 // The freed CIDR is consulted before the monotonic allocator, so a fresh
348 // enrollment reuses it. 396 // enrollment reuses it.
349 tok, err := s.CreateEnrollmentToken(DefaultTenant) 397 tok, err := s.CreateEnrollmentToken(testTenant)
350 require.NoError(t, err) 398 require.NoError(t, err)
351 h2, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "") 399 h2, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "")
352 require.NoError(t, err) 400 require.NoError(t, err)
@@ -472,17 +520,17 @@ func TestSnapshotIsAtomicUnderConcurrentWrites(t *testing.T) {
472 // timestamped action+detail row; ListAudit returns newest-first with a limit. 520 // timestamped action+detail row; ListAudit returns newest-first with a limit.
473 func TestAuditLogRoundTrip(t *testing.T) { 521 func TestAuditLogRoundTrip(t *testing.T) {
474 s := newStore(t) 522 s := newStore(t)
475 require.NoError(t, s.AppendAudit("enroll-token.mint", `{"token_hash_prefix":"abcd1234"}`)) 523 require.NoError(t, s.AppendAudit(testTenant, "enroll-token.mint", `{"token_hash_prefix":"abcd1234"}`))
476 require.NoError(t, s.AppendAudit("host.enroll", `{"host_id":"h1","name":"host-a"}`)) 524 require.NoError(t, s.AppendAudit(testTenant, "host.enroll", `{"host_id":"h1","name":"host-a"}`))
477 525
478 rows, err := s.ListAudit(10) 526 rows, err := s.ListAudit(testTenant, 10)
479 require.NoError(t, err) 527 require.NoError(t, err)
480 require.Len(t, rows, 2) 528 require.Len(t, rows, 2)
481 assert.Equal(t, "host.enroll", rows[0].Action, "newest first") 529 assert.Equal(t, "host.enroll", rows[0].Action, "newest first")
482 assert.Contains(t, rows[0].Detail, "h1") 530 assert.Contains(t, rows[0].Detail, "h1")
483 assert.False(t, rows[0].At.IsZero()) 531 assert.False(t, rows[0].At.IsZero())
484 532
485 one, err := s.ListAudit(1) 533 one, err := s.ListAudit(testTenant, 1)
486 require.NoError(t, err) 534 require.NoError(t, err)
487 require.Len(t, one, 1) 535 require.Len(t, one, 1)
488 assert.Equal(t, "host.enroll", one[0].Action) 536 assert.Equal(t, "host.enroll", one[0].Action)
@@ -493,12 +541,12 @@ func TestAuditLogRoundTrip(t *testing.T) {
493 // the limit — the endpoint's filter (json_extract on '$.vm_id') keys off it. 541 // the limit — the endpoint's filter (json_extract on '$.vm_id') keys off it.
494 func TestListVMEvents(t *testing.T) { 542 func TestListVMEvents(t *testing.T) {
495 s := newStore(t) 543 s := newStore(t)
496 require.NoError(t, s.AppendAudit("vm.create", `{"vm_id":"vm-a","name":"alpha"}`)) 544 require.NoError(t, s.AppendAudit(testTenant, "vm.create", `{"vm_id":"vm-a","name":"alpha"}`))
497 require.NoError(t, s.AppendAudit("vm.create", `{"vm_id":"vm-b","name":"bravo"}`)) 545 require.NoError(t, s.AppendAudit(testTenant, "vm.create", `{"vm_id":"vm-b","name":"bravo"}`))
498 require.NoError(t, s.AppendAudit("vm.power", `{"vm_id":"vm-a","power":"stopped"}`)) 546 require.NoError(t, s.AppendAudit(testTenant, "vm.power", `{"vm_id":"vm-a","power":"stopped"}`))
499 require.NoError(t, s.AppendAudit("vm.delete", `{"vm_id":"vm-a","name":"alpha"}`)) 547 require.NoError(t, s.AppendAudit(testTenant, "vm.delete", `{"vm_id":"vm-a","name":"alpha"}`))
500 548
501 rows, err := s.ListVMEvents("vm-a", 100) 549 rows, err := s.ListVMEvents(testTenant, "vm-a", 100)
502 require.NoError(t, err) 550 require.NoError(t, err)
503 require.Len(t, rows, 3, "only vm-a rows, not vm-b's") 551 require.Len(t, rows, 3, "only vm-a rows, not vm-b's")
504 assert.Equal(t, "vm.delete", rows[0].Action, "newest first") 552 assert.Equal(t, "vm.delete", rows[0].Action, "newest first")
@@ -507,7 +555,7 @@ func TestListVMEvents(t *testing.T) {
507 assert.NotContains(t, e.Detail, "vm-b") 555 assert.NotContains(t, e.Detail, "vm-b")
508 } 556 }
509 557
510 limited, err := s.ListVMEvents("vm-a", 1) 558 limited, err := s.ListVMEvents(testTenant, "vm-a", 1)
511 require.NoError(t, err) 559 require.NoError(t, err)
512 require.Len(t, limited, 1, "limit respected") 560 require.Len(t, limited, 1, "limit respected")
513 assert.Equal(t, "vm.delete", limited[0].Action) 561 assert.Equal(t, "vm.delete", limited[0].Action)
@@ -518,12 +566,12 @@ func TestListVMEvents(t *testing.T) {
518 // enrolled host can never exist without its durable audit record. 566 // enrolled host can never exist without its durable audit record.
519 func TestRedeemWritesAuditRowAtomically(t *testing.T) { 567 func TestRedeemWritesAuditRowAtomically(t *testing.T) {
520 s := newStore(t) 568 s := newStore(t)
521 tok, err := s.CreateEnrollmentToken(DefaultTenant) 569 tok, err := s.CreateEnrollmentToken(testTenant)
522 require.NoError(t, err) 570 require.NoError(t, err)
523 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "192.0.2.9") 571 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "192.0.2.9")
524 require.NoError(t, err) 572 require.NoError(t, err)
525 573
526 rows, err := s.ListAudit(5) 574 rows, err := s.ListAudit(testTenant, 5)
527 require.NoError(t, err) 575 require.NoError(t, err)
528 require.NotEmpty(t, rows) 576 require.NotEmpty(t, rows)
529 assert.Equal(t, "host.enroll", rows[0].Action) 577 assert.Equal(t, "host.enroll", rows[0].Action)
@@ -563,7 +611,7 @@ func TestBumpCredGenerationAuditsAtomically(t *testing.T) {
563 h := enrollHost(t, s) 611 h := enrollHost(t, s)
564 _, err := s.BumpCredGeneration(h.ID, "192.0.2.7") 612 _, err := s.BumpCredGeneration(h.ID, "192.0.2.7")
565 require.NoError(t, err) 613 require.NoError(t, err)
566 rows, err := s.ListAudit(1) 614 rows, err := s.ListAudit(testTenant, 1)
567 require.NoError(t, err) 615 require.NoError(t, err)
568 require.Len(t, rows, 1) 616 require.Len(t, rows, 1)
569 assert.Equal(t, "host.credential.revoke", rows[0].Action) 617 assert.Equal(t, "host.credential.revoke", rows[0].Action)
@@ -577,15 +625,15 @@ func TestPruneAuditRemovesOnlyOldRows(t *testing.T) {
577 s := newStore(t) 625 s := newStore(t)
578 // Insert directly so the timestamps are controlled. 626 // Insert directly so the timestamps are controlled.
579 old := time.Now().UTC().Add(-100 * 24 * time.Hour).Format(time.RFC3339) 627 old := time.Now().UTC().Add(-100 * 24 * time.Hour).Format(time.RFC3339)
580 _, err := s.db.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, 'old.event', '{}')`, old) 628 _, err := s.db.Exec(`INSERT INTO audit_log(at, tenant, action, detail) VALUES (?, ?, 'old.event', '{}')`, old, testTenant)
581 require.NoError(t, err) 629 require.NoError(t, err)
582 require.NoError(t, s.AppendAudit("new.event", "{}")) 630 require.NoError(t, s.AppendAudit(testTenant, "new.event", "{}"))
583 631
584 n, err := s.PruneAudit(90 * 24 * time.Hour) 632 n, err := s.PruneAudit(90 * 24 * time.Hour)
585 require.NoError(t, err) 633 require.NoError(t, err)
586 assert.Equal(t, int64(1), n, "exactly the old row pruned") 634 assert.Equal(t, int64(1), n, "exactly the old row pruned")
587 635
588 rows, err := s.ListAudit(10) 636 rows, err := s.ListAudit(testTenant, 10)
589 require.NoError(t, err) 637 require.NoError(t, err)
590 require.Len(t, rows, 1) 638 require.Len(t, rows, 1)
591 assert.Equal(t, "new.event", rows[0].Action) 639 assert.Equal(t, "new.event", rows[0].Action)
@@ -595,22 +643,27 @@ func TestPruneAuditRemovesOnlyOldRows(t *testing.T) {
595 // zero/negative retention must never delete anything. 643 // zero/negative retention must never delete anything.
596 func TestPruneAuditGuardsNonPositiveWindow(t *testing.T) { 644 func TestPruneAuditGuardsNonPositiveWindow(t *testing.T) {
597 s := newStore(t) 645 s := newStore(t)
598 require.NoError(t, s.AppendAudit("keep.me", "{}")) 646 require.NoError(t, s.AppendAudit(testTenant, "keep.me", "{}"))
599 for _, d := range []time.Duration{0, -time.Hour} { 647 for _, d := range []time.Duration{0, -time.Hour} {
600 n, err := s.PruneAudit(d) 648 n, err := s.PruneAudit(d)
601 require.NoError(t, err) 649 require.NoError(t, err)
602 assert.Zero(t, n) 650 assert.Zero(t, n)
603 } 651 }
604 rows, err := s.ListAudit(5) 652 rows, err := s.ListAudit(testTenant, 5)
605 require.NoError(t, err) 653 require.NoError(t, err)
606 assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window") 654 assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window")
607 } 655 }
608 656
609 func TestOpenSeedsDefaultTenant(t *testing.T) { 657 func TestOpenSeedsNoTenants(t *testing.T) {
610 s := newStore(t) 658 // A fresh database starts with ZERO tenants: the only creation path is JIT
611 var name string 659 // provisioning on first sign-in. (Open the store directly — newStore
612 require.NoError(t, s.db.QueryRow(`SELECT name FROM tenants WHERE id=?`, DefaultTenant).Scan(&name)) 660 // provisions one.)
613 assert.Equal(t, "default", name) 661 s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16")
662 require.NoError(t, err)
663 t.Cleanup(func() { s.Close() })
664 var n int
665 require.NoError(t, s.db.QueryRow(`SELECT count(*) FROM tenants`).Scan(&n))
666 assert.Equal(t, 0, n)
614 // enrollment_tokens carries the tenant the enrolling host will join. 667 // enrollment_tokens carries the tenant the enrolling host will join.
615 var tenantCol int 668 var tenantCol int
616 require.NoError(t, s.db.QueryRow( 669 require.NoError(t, s.db.QueryRow(
@@ -665,7 +718,7 @@ func TestVMByTenantNameIsScoped(t *testing.T) {
665 assert.Equal(t, "v1", got.ID) 718 assert.Equal(t, "v1", got.ID)
666 _, err = s.VMByTenantName("t3", "web") 719 _, err = s.VMByTenantName("t3", "web")
667 assert.ErrorIs(t, err, sql.ErrNoRows, "resolution must not cross tenants") 720 assert.ErrorIs(t, err, sql.ErrNoRows, "resolution must not cross tenants")
668 _, err = s.VMByTenantName(DefaultTenant, "web") 721 _, err = s.VMByTenantName(testTenant, "web")
669 assert.ErrorIs(t, err, sql.ErrNoRows) 722 assert.ErrorIs(t, err, sql.ErrNoRows)
670 } 723 }
671 724
internal/server/syncsvc/syncsvc.go
Old New
@@ -386,8 +386,20 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) {
386 // Track whether any delete succeeded so we can poke the agent once after the 386 // Track whether any delete succeeded so we can poke the agent once after the
387 // loop — otherwise the agent holds a stale snapshot containing the tombstone 387 // loop — otherwise the agent holds a stale snapshot containing the tombstone
388 // and re-acks every tick forever (log spam) until some other edit pokes it. 388 // and re-acks every tick forever (log spam) until some other edit pokes it.
389 // A reaped VM shares its host's tenant; resolve it once so the terminal
390 // vm.reap row is tenant-scoped like the rest of the VM's timeline. The host
391 // row still exists during graceful reap (RemoveHost waits for the drain);
392 // fall back to the system audit scope only for the defensive race where it
393 // is gone.
394 destroyed := rep.GetDestroyed()
395 reapTenant := store.SystemTenant
396 if len(destroyed) > 0 {
397 if h, err := s.st.GetHost(hostID); err == nil {
398 reapTenant = h.Tenant
399 }
400 }
389 anyDeleted := false 401 anyDeleted := false
390 for _, id := range rep.GetDestroyed() { 402 for _, id := range destroyed {
391 // Prune the dedup cache: this VM's row is being hard-deleted, so its 403 // Prune the dedup cache: this VM's row is being hard-deleted, so its
392 // tracked status is dead weight (and a future id reuse must not inherit 404 // tracked status is dead weight (and a future id reuse must not inherit
393 // a stale cached triple — ids are unique, but forgetting is the correct, 405 // a stale cached triple — ids are unique, but forgetting is the correct,
@@ -403,7 +415,7 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) {
403 detail, _ := json.Marshal(map[string]string{ 415 detail, _ := json.Marshal(map[string]string{
404 "vm_id": id, "host_id": hostID, "reason": "destroyed after tombstone grace", 416 "vm_id": id, "host_id": hostID, "reason": "destroyed after tombstone grace",
405 }) 417 })
406 if err := s.st.AppendAudit("vm.reap", string(detail)); err != nil { 418 if err := s.st.AppendAudit(reapTenant, "vm.reap", string(detail)); err != nil {
407 slog.Warn("audit vm.reap failed", "vm", id, "host", hostID, "err", err) 419 slog.Warn("audit vm.reap failed", "vm", id, "host", hostID, "err", err)
408 } 420 }
409 } 421 }
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -38,12 +38,24 @@ func setup(t *testing.T) *fixture {
38 return setupWithWriteTimeout(t, 0) // 0 → production default 38 return setupWithWriteTimeout(t, 0) // 0 → production default
39 } 39 }
40 40
41 // testTenant is the tenant the test builders provision — through the real JIT
42 // path, the only way tenants are born. The name has no significance.
43 const testTenant = "default"
44
45 // seedTestTenant JIT-provisions testTenant on a fresh store.
46 func seedTestTenant(t *testing.T, st *store.Store) {
47 t.Helper()
48 _, err := st.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
49 require.NoError(t, err)
50 }
51
41 func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture { 52 func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture {
42 t.Helper() 53 t.Helper()
43 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 54 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
44 require.NoError(t, err) 55 require.NoError(t, err)
45 t.Cleanup(func() { st.Close() }) 56 t.Cleanup(func() { st.Close() })
46 tok, _ := st.CreateEnrollmentToken(store.DefaultTenant) 57 seedTestTenant(t, st)
58 tok, _ := st.CreateEnrollmentToken(testTenant)
47 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 59 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
48 require.NoError(t, err) 60 require.NoError(t, err)
49 61
@@ -453,7 +465,8 @@ func TestExpiredCredentialRejectedWhenMaxAgeSet(t *testing.T) {
453 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 465 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
454 require.NoError(t, err) 466 require.NoError(t, err)
455 t.Cleanup(func() { st.Close() }) 467 t.Cleanup(func() { st.Close() })
456 tok, _ := st.CreateEnrollmentToken(store.DefaultTenant) 468 seedTestTenant(t, st)
469 tok, _ := st.CreateEnrollmentToken(testTenant)
457 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 470 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
458 require.NoError(t, err) 471 require.NoError(t, err)
459 472
@@ -495,7 +508,8 @@ func TestMaxAgeEnforcedMidSession(t *testing.T) {
495 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") 508 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
496 require.NoError(t, err) 509 require.NoError(t, err)
497 t.Cleanup(func() { st.Close() }) 510 t.Cleanup(func() { st.Close() })
498 tok, _ := st.CreateEnrollmentToken(store.DefaultTenant) 511 seedTestTenant(t, st)
512 tok, _ := st.CreateEnrollmentToken(testTenant)
499 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 513 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "")
500 require.NoError(t, err) 514 require.NoError(t, err)
501 515
internal/shape/classify.go
Old New
@@ -44,6 +44,7 @@ func classify(rel string) Plane {
44 strings.HasPrefix(rel, "internal/mcpserver"), 44 strings.HasPrefix(rel, "internal/mcpserver"),
45 strings.HasPrefix(rel, "internal/covsnap"), 45 strings.HasPrefix(rel, "internal/covsnap"),
46 strings.HasPrefix(rel, "internal/gateclient"), 46 strings.HasPrefix(rel, "internal/gateclient"),
47 strings.HasPrefix(rel, "internal/oidcprovider"),
47 strings.HasPrefix(rel, "internal/site"), 48 strings.HasPrefix(rel, "internal/site"),
48 strings.HasPrefix(rel, "internal/shape"), 49 strings.HasPrefix(rel, "internal/shape"),
49 strings.HasPrefix(rel, "internal/cli"): 50 strings.HasPrefix(rel, "internal/cli"):
internal/site/site.go
Old New
@@ -13,6 +13,7 @@ import (
13 // Build hard-fails if any listed doc is missing. 13 // Build hard-fails if any listed doc is missing.
14 var pages = []string{ 14 var pages = []string{
15 "quickstart", 15 "quickstart",
16 "byo-idp",
16 "ssh-access", 17 "ssh-access",
17 "mcp", 18 "mcp",
18 "upgrade", 19 "upgrade",
scripts/coverage.sh
Old New
@@ -24,10 +24,10 @@ declare -A FLOOR=(
24 [internal/agent/netenv]=76 24 [internal/agent/netenv]=76
25 [internal/agent/cloudhv]=40 25 [internal/agent/cloudhv]=40
26 [internal/agent/syncclient]=74 26 [internal/agent/syncclient]=74
27 [internal/server/api]=73 27 [internal/server/api]=76
28 [internal/server/api/client]=89 28 [internal/server/api/client]=89
29 [internal/server/api/spec]=91 29 [internal/server/api/spec]=91
30 [internal/server/store]=73 30 [internal/server/store]=76
31 [internal/server/registry]=95 31 [internal/server/registry]=95
32 [internal/server/release]=90 32 [internal/server/release]=90
33 [internal/agent/selfupdate]=65 33 [internal/agent/selfupdate]=65
@@ -40,6 +40,7 @@ declare -A FLOOR=(
40 [internal/shape]=88 40 [internal/shape]=88
41 [internal/site]=80 41 [internal/site]=80
42 [internal/cli]=60 42 [internal/cli]=60
43 [internal/oidcprovider]=78
43 ) 44 )
44 45
45 profile="$(mktemp)" 46 profile="$(mktemp)"
scripts/deploy.env.example
Old New
@@ -9,8 +9,20 @@
9 SERVER_BIN="$HOME/eitri-deploy/bin/eitri-server" # where the running binary lives 9 SERVER_BIN="$HOME/eitri-deploy/bin/eitri-server" # where the running binary lives
10 SERVER_CONFIG="$HOME/eitri-deploy/server.json" # --config passed to it 10 SERVER_CONFIG="$HOME/eitri-deploy/server.json" # --config passed to it
11 SERVER_LOG="$HOME/eitri-deploy/logs/server.log" # relaunch appends here 11 SERVER_LOG="$HOME/eitri-deploy/logs/server.log" # relaunch appends here
12 SERVER_URL="http://127.0.0.1:8080" # http_listen, for health checks 12 SERVER_URL="http://127.0.0.1:8080" # http_listen, for health checks + console callback base
13 ADMIN_TOKEN_FILE="$HOME/eitri-deploy/admin-token" # optional: enables API verification 13
14 # ── Console sign-in / boot-gate credentials ───────────────────────────────────
15 # The admin token is gone: the server authenticates the console via OIDC, and
16 # the boot-gate signs in as the deploy machine identity in the bundled eitri-oidc
17 # issuer (installed + configured by deploy.sh on this box) to mint a short-lived
18 # PAT that proves the sign-in chain. The `default` tenant is human-owned (the
19 # operator claimed it on first sign-in), so the VM-lifecycle half of the gate
20 # runs on an operator-minted PAT read from CI_PAT_FILE instead. Defaults shown;
21 # the password file is generated on first deploy, and CI_PAT_FILE is an optional
22 # override for where you saved the console "deploy" PAT.
23 # CI_USER="deploy@eitri.local"
24 # CI_PASSWORD_FILE="$HOME/eitri-deploy/oidc/deploy-password"
25 # CI_PAT_FILE="$HOME/eitri-deploy/oidc/deploy-pat"
14 26
15 # ── Hosts (remote eitri-agent) ──────────────────────────────────────────────── 27 # ── Hosts (remote eitri-agent) ────────────────────────────────────────────────
16 # Space-separated list of ssh targets, each "user@host[:port]" (port defaults 22). 28 # Space-separated list of ssh targets, each "user@host[:port]" (port defaults 22).
@@ -39,7 +51,6 @@ FIRMWARE="/usr/share/eitri/CLOUDHV.fd"
39 # dialable from the deploy host (its host must match the gate's host-cert 51 # dialable from the deploy host (its host must match the gate's host-cert
40 # principal). Leave the rest unset to use the defaults shown. 52 # principal). Leave the rest unset to use the defaults shown.
41 # SMOKE_GATE="127.0.0.1:2223" 53 # SMOKE_GATE="127.0.0.1:2223"
42 # SMOKE_TENANT="default"
43 # SMOKE_VM_USER="ubuntu" 54 # SMOKE_VM_USER="ubuntu"
44 # SMOKE_USER_CA_FILE="$HOME/eitri-deploy/smoke_user_ca" 55 # SMOKE_USER_CA_FILE="$HOME/eitri-deploy/smoke_user_ca"
45 56
scripts/deploy.sh
Old New
@@ -42,13 +42,34 @@ AGENT_GOCOVERDIR="${AGENT_GOCOVERDIR:-$AGENT_STATE_DIR/coverage}"
42 # SSH-CA gate boot-check config. The smoke proves guest access through the jump 42 # SSH-CA gate boot-check config. The smoke proves guest access through the jump
43 # gate (a hard gate). SMOKE_GATE defaults to the server's ssh_listen (the 43 # gate (a hard gate). SMOKE_GATE defaults to the server's ssh_listen (the
44 # host:port the gate accepts, whose host is the gate host-cert principal); 44 # host:port the gate accepts, whose host is the gate host-cert principal);
45 # override in deploy.env if that address isn't dialable from here. SMOKE_TENANT 45 # override in deploy.env if that address isn't dialable from here. SMOKE_VM_USER
46 # and SMOKE_VM_USER fall back to the smoke's own defaults (default / ubuntu) when 46 # falls back to the smoke's own default (ubuntu) when unset. The smoke's user CA
47 # unset. The smoke's user CA is load-or-created at SMOKE_USER_CA_FILE and 47 # is load-or-created at SMOKE_USER_CA_FILE and registered with the tenant (the
48 # registered with the tenant before the throwaway VM is created. 48 # operator PAT's, derived via Me()) before the throwaway VM is created.
49 SMOKE_GATE="${SMOKE_GATE:-$(python3 -c "import json; print(json.load(open('$SERVER_CONFIG')).get('ssh_listen',''))" 2>/dev/null || true)}" 49 SMOKE_GATE="${SMOKE_GATE:-$(python3 -c "import json; print(json.load(open('$SERVER_CONFIG')).get('ssh_listen',''))" 2>/dev/null || true)}"
50 SMOKE_USER_CA_FILE="${SMOKE_USER_CA_FILE:-$HOME/eitri-deploy/smoke_user_ca}" 50 SMOKE_USER_CA_FILE="${SMOKE_USER_CA_FILE:-$HOME/eitri-deploy/smoke_user_ca}"
51 51
52 # Sign-in replaces the admin token: the boot-gate authenticates as the deploy
53 # machine identity in the bundled eitri-oidc issuer, driving the real code flow
54 # to mint a short-lived PAT (spec §3) that proves the credential chain. That
55 # identity does NOT own the fleet — the fleet's hosts belong to the operator's
56 # tenant, so the machine identity lands in its own empty JIT tenant and the
57 # VM-lifecycle half of the gate runs on an operator PAT (CI_PAT_FILE) instead. eitri-oidc runs as a sibling of eitri-server on THIS
58 # (control-plane) box. Everything the issuer owns on this box lives under ONE
59 # directory — ~/eitri-deploy/oidc/ — so the deploy dir doesn't accumulate loose
60 # per-service files; only the binary sits in the shared bin/.
61 OIDC_BIN="$HOME/eitri-deploy/bin/eitri-oidc"
62 OIDC_DIR="$HOME/eitri-deploy/oidc"
63 OIDC_CONFIG="$OIDC_DIR/eitri-oidc.json"
64 OIDC_LOG="$OIDC_DIR/eitri-oidc.log"
65 CI_USER="${CI_USER:-deploy@eitri.local}"
66 CI_PASSWORD_FILE="${CI_PASSWORD_FILE:-$OIDC_DIR/deploy-password}"
67 # The machine identity only proves the credential chain (its JIT tenant owns no
68 # hosts — those belong to the operator's tenant). The VM-lifecycle half of the
69 # boot-gate authenticates with an OPERATOR-minted, tenant-scoped console PAT
70 # the deployer saves here once (see the boot-gate).
71 CI_PAT_FILE="${CI_PAT_FILE:-$OIDC_DIR/deploy-pat}"
72
52 bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } 73 bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
53 short() { sha256sum "$1" | cut -c1-12; } 74 short() { sha256sum "$1" | cut -c1-12; }
54 75
@@ -77,6 +98,39 @@ make web
77 go build "${GO_LDFLAGS[@]}" -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-server ./cmd/eitri-server 98 go build "${GO_LDFLAGS[@]}" -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-server ./cmd/eitri-server
78 go build "${GO_LDFLAGS[@]}" -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-agent ./cmd/eitri-agent 99 go build "${GO_LDFLAGS[@]}" -cover -covermode=atomic -coverpkg=github.com/a73x/eitri/... -o bin/eitri-agent ./cmd/eitri-agent
79 go build "${GO_LDFLAGS[@]}" -o bin/eitri-smoke ./cmd/eitri-smoke 100 go build "${GO_LDFLAGS[@]}" -o bin/eitri-smoke ./cmd/eitri-smoke
101 # The bundled OIDC issuer (sibling of the server; built plain — it is not part of
102 # the coverage-instrumented fleet). Installed and configured in §1.5 below.
103 go build "${GO_LDFLAGS[@]}" -o bin/eitri-oidc ./cmd/eitri-oidc
104
105 # ── 0a. Pre-flight: server.json must carry the oidc block ──────────────────────
106 # The server is a pure OIDC relying party now: it will not boot without
107 # oidc.issuer. Deploy scripts never rewrite server.json (site-specific config is
108 # the operator's; see the header) — so if the block is missing, fail EARLY with
109 # the exact JSON to paste rather than bouncing the server into a crash loop.
110 if ! jq -e '.oidc.issuer' "$SERVER_CONFIG" >/dev/null 2>&1; then
111 cat >&2 <<EOF
112
113 deploy: $SERVER_CONFIG is missing the required "oidc" block.
114
115 The server authenticates the console via OIDC; admin_token is gone. Add this
116 top-level block to server.json (issuer points at the bundled eitri-oidc this
117 script installs on 127.0.0.1:9111):
118
119 "oidc": {
120 "issuer": "http://127.0.0.1:9111",
121 "client_id": "eitri-console",
122 "public_url": "$SERVER_URL"
123 }
124
125 public_url must match the redirect this script registers with eitri-oidc
126 ($SERVER_URL/auth/callback), so keep public_url == $SERVER_URL for the bundled
127 single-box issuer. If you front the console at a routable address instead, set
128 public_url to it AND edit clients[0].redirect_url in $OIDC_CONFIG to match.
129
130 Then re-run make deploy.
131 EOF
132 exit 1
133 fi
80 134
81 # ── 1. Control plane (local eitri-server) ───────────────────────────────────── 135 # ── 1. Control plane (local eitri-server) ─────────────────────────────────────
82 bold "Rolling server -> $SERVER_BIN" 136 bold "Rolling server -> $SERVER_BIN"
@@ -107,6 +161,73 @@ if ! curl -fsS -o /dev/null "$SERVER_URL/livez" 2>/dev/null; then
107 fi 161 fi
108 echo "server up: $(short "$SERVER_BIN") listening at $SERVER_URL" 162 echo "server up: $(short "$SERVER_BIN") listening at $SERVER_URL"
109 163
164 # ── 1.5 Bundled OIDC issuer (eitri-oidc) + ci user ────────────────────────────
165 # Same user-space convention as the server: this fleet runs everything from
166 # ~/eitri-deploy as the deploy user — no sudo, no system units (the systemd
167 # unit ships in the release tarball for hosts installed per the quickstart).
168 # Stop first (ETXTBSY), swap, relaunch detached; the issuer is stateless
169 # beyond its key+users files, so an unconditional bounce is harmless (sessions
170 # live in eitri-server, not here).
171 bold "Rolling eitri-oidc issuer -> $OIDC_BIN"
172 pkill -TERM -f "eitri-oidc -config $OIDC_CONFIG" 2>/dev/null || true
173 for _ in $(seq 1 20); do pgrep -x eitri-oidc >/dev/null || break; sleep 0.5; done
174 if pgrep -x eitri-oidc >/dev/null; then
175 echo "deploy: eitri-oidc did not stop; aborting before swap" >&2
176 exit 1
177 fi
178 install -m 0755 bin/eitri-oidc "$OIDC_BIN"
179 mkdir -p "$OIDC_DIR" && chmod 700 "$OIDC_DIR"
180 if [[ ! -f "$OIDC_CONFIG" ]]; then
181 # redirect_url must equal <the server's oidc.public_url>/auth/callback — the
182 # server builds its RedirectURL from public_url, and the issuer exact-matches
183 # it. Derive it from server.json (public_url is not a secret) so the two can
184 # never disagree; SERVER_URL is only the fallback.
185 callback_base="$(jq -r '.oidc.public_url // empty' "$SERVER_CONFIG")"
186 callback_base="${callback_base:-$SERVER_URL}"
187 ( umask 077; printf '%s\n' "{
188 \"listen\": \"127.0.0.1:9111\",
189 \"issuer\": \"http://127.0.0.1:9111\",
190 \"users_file\": \"$OIDC_DIR/users.json\",
191 \"signing_key\": \"$OIDC_DIR/signing.key\",
192 \"clients\": [
193 {\"id\": \"eitri-console\", \"redirect_url\": \"$callback_base/auth/callback\"}
194 ]
195 }" > "$OIDC_CONFIG" )
196 echo "wrote $OIDC_CONFIG (single-box loopback issuer)"
197 else
198 echo "$OIDC_CONFIG present — left as-is"
199 fi
200 setsid "$OIDC_BIN" -config "$OIDC_CONFIG" </dev/null >>"$OIDC_LOG" 2>&1 &
201 # Liveness gate mirrors the server's: the discovery doc serves as soon as the
202 # mux is up.
203 for _ in $(seq 1 30); do
204 curl -fsS -o /dev/null "http://127.0.0.1:9111/.well-known/openid-configuration" 2>/dev/null && break
205 sleep 0.5
206 done
207 if ! curl -fsS -o /dev/null "http://127.0.0.1:9111/.well-known/openid-configuration" 2>/dev/null; then
208 echo "deploy: eitri-oidc did not come up (see $OIDC_LOG)" >&2
209 exit 1
210 fi
211 echo "eitri-oidc up (issuer http://127.0.0.1:9111)"
212
213 # The deploy identity drives the boot-gate's headless sign-in. Generate its
214 # password once (kept beside the other deploy secrets, 0600), then (re-)register
215 # it — the flat-file add is idempotent-by-replace and keeps the user's stable
216 # subject.
217 if [[ ! -f "$CI_PASSWORD_FILE" ]]; then
218 ( umask 077; openssl rand -hex 16 >"$CI_PASSWORD_FILE" )
219 chmod 600 "$CI_PASSWORD_FILE"
220 echo "generated deploy-identity password: $CI_PASSWORD_FILE"
221 fi
222 # Flags MUST precede the positional email: Go's flag package stops parsing at
223 # the first non-flag argument, so `user add <email> --flags` would swallow the
224 # flags as positionals and fail.
225 "$OIDC_BIN" user add --config "$OIDC_CONFIG" --password-file "$CI_PASSWORD_FILE" "$CI_USER"
226 echo "deploy identity registered: $CI_USER"
227
228 # The identity's tenant is JIT-provisioned on its first sign-in (nothing to
229 # assert here); the gate's VM-lifecycle PAT comes from CI_PAT_FILE.
230
110 # ── 2. Hosts (remote eitri-agent) ───────────────────────────────────────────── 231 # ── 2. Hosts (remote eitri-agent) ─────────────────────────────────────────────
111 # Shared agent launch flags. TOMBSTONE_GRACE / VANISH_GRACE / EXTRA are optional. 232 # Shared agent launch flags. TOMBSTONE_GRACE / VANISH_GRACE / EXTRA are optional.
112 agent_flags="--state-dir $AGENT_STATE_DIR --ch-bin $CH_BIN --firmware $FIRMWARE" 233 agent_flags="--state-dir $AGENT_STATE_DIR --ch-bin $CH_BIN --firmware $FIRMWARE"
@@ -177,31 +298,15 @@ else
177 # curl -f makes a 503 a non-zero exit, so a stuck-unready server lands here. 298 # curl -f makes a 503 a non-zero exit, so a stuck-unready server lands here.
178 echo "WARNING: server not ready after deploy: $(curl -s "$SERVER_URL/readyz" 2>/dev/null)" >&2 299 echo "WARNING: server not ready after deploy: $(curl -s "$SERVER_URL/readyz" 2>/dev/null)" >&2
179 fi 300 fi
180 if [[ -n "${ADMIN_TOKEN_FILE:-}" && -f "$ADMIN_TOKEN_FILE" ]]; then 301 # Authenticated API verification (hosts-online, lifecycle field) used to run here
181 tok="$(cat "$ADMIN_TOKEN_FILE")" 302 # with the admin token. That token is gone, and re-implementing a headless
182 up=0 total=0 303 # sign-in in shell would be fragile — so the authenticated proof now lives in the
183 # Give agents a moment to redial the freshly-restarted server. 304 # boot-gate below: eitri-smoke signs in through the real OIDC flow, lists hosts,
184 for _ in $(seq 1 15); do 305 # and creates a VM (exercising the lifecycle field) with a minted PAT. A failing
185 online="$(curl -fsS -H "Authorization: Bearer $tok" "$SERVER_URL/api/v1/hosts" 2>/dev/null \ 306 # authed API surfaces as a boot-gate failure, which fails the deploy.
186 | python3 -c 'import sys,json; hs=json.load(sys.stdin); print(sum(1 for h in hs if h["online"]), len(hs))' 2>/dev/null || echo "0 0")"
187 read -r up total <<<"$online"
188 [[ "$total" -gt 0 && "$up" == "$total" ]] && break
189 sleep 2
190 done
191 echo "hosts online: $up/$total"
192 # Confirm the new server serves the derived lifecycle field.
193 if curl -fsS -H "Authorization: Bearer $tok" "$SERVER_URL/api/v1/vms" 2>/dev/null \
194 | python3 -c 'import sys,json; vs=json.load(sys.stdin); sys.exit(0 if not vs or "lifecycle" in vs[0] else 1)'; then
195 echo "server serving lifecycle field: yes"
196 else
197 echo "WARNING: server not serving lifecycle field" >&2
198 fi
199 else
200 echo "(no ADMIN_TOKEN_FILE set — skipping API verification)"
201 fi
202 307
203 # ── 4. Boot gate ────────────────────────────────────────────────────────────── 308 # ── 4. Boot gate ──────────────────────────────────────────────────────────────
204 # readyz + hosts-online prove the CONTROL PLANE is up, but a deploy can still 309 # readyz proves the CONTROL PLANE is up, but a deploy can still
205 # ship a change that breaks VM BOOT (e.g. an unknown cloud-hypervisor --disk 310 # ship a change that breaks VM BOOT (e.g. an unknown cloud-hypervisor --disk
206 # option that makes CH refuse to start — exactly how the image_type=raw 311 # option that makes CH refuse to start — exactly how the image_type=raw
207 # regression reached the fleet). The only way to catch that is to boot a real 312 # regression reached the fleet). The only way to catch that is to boot a real
@@ -209,21 +314,55 @@ fi
209 # to bypass for a server-only / docs-only change. 314 # to bypass for a server-only / docs-only change.
210 if [[ "${SKIP_BOOT_SMOKE:-0}" == "1" ]]; then 315 if [[ "${SKIP_BOOT_SMOKE:-0}" == "1" ]]; then
211 echo "(boot gate skipped: SKIP_BOOT_SMOKE=1)" 316 echo "(boot gate skipped: SKIP_BOOT_SMOKE=1)"
212 elif [[ -n "${ADMIN_TOKEN_FILE:-}" ]]; then 317 else
213 bold "Boot gate: create a throwaway VM and confirm it actually boots" 318 bold "Boot gate: prove the credential chain, then create a throwaway VM and confirm it boots"
214 # eitri-smoke reads its config from the environment, so export what it needs; 319 # Like a browser, the OIDC flow only works when the client visits the server
320 # at exactly oidc.public_url (the state cookie is host-scoped and the issuer
321 # redirects to the registered public_url callback), so the gate targets
322 # public_url rather than the loopback health-check URL.
323 smoke_server_url="$(jq -r '.oidc.public_url // empty' "$SERVER_CONFIG")"
324 smoke_server_url="${smoke_server_url:-$SERVER_URL}"
325
326 # The lifecycle half needs an operator-minted, tenant-scoped PAT: the fleet's
327 # hosts belong to the operator's tenant, so the machine identity's JIT tenant
328 # owns none. Minting the PAT is a one-time human step — fail with instructions
329 # rather than a confusing 403 deep in the gate.
330 if [[ ! -f "$CI_PAT_FILE" ]]; then
331 cat >&2 <<-EOF
332
333 DEPLOY BLOCKED: operator PAT not found at $CI_PAT_FILE
334
335 The boot-gate's VM-lifecycle check runs as the tenant operator, not as the
336 machine identity. Mint that PAT once:
337
338 1. Sign in at $smoke_server_url in a browser as the operator whose
339 tenant owns the fleet's hosts.
340 2. Open Settings and mint a NON-EXPIRING personal access token named
341 "deploy".
342 3. Save its value to $CI_PAT_FILE and lock it down:
343 umask 077; printf %s '<token>' > $CI_PAT_FILE; chmod 600 $CI_PAT_FILE
344 4. Re-run: make deploy
345
346 EOF
347 exit 1
348 fi
349 # eitri-smoke reads its config from the environment, so export what it needs.
350 # Phase 1 signs in as the machine identity through the real OIDC flow
351 # (CI_USER/CI_PASSWORD_FILE), mints a short-lived PAT, and proves it resolves
352 # to a tenant. Phase 2 authenticates the VM lifecycle with the operator PAT in
353 # CI_PAT_FILE and derives its tenant via Me() — no admin token, no hardcoded
354 # tenant.
215 # COVER_OUT triggers the post-gate coverage merge (server+agent). 355 # COVER_OUT triggers the post-gate coverage merge (server+agent).
216 export SERVER_URL ADMIN_TOKEN_FILE AGENT_HOSTS AGENT_STATE_DIR SERVER_GOCOVERDIR AGENT_GOCOVERDIR 356 export SERVER_URL="$smoke_server_url"
217 export SMOKE_GATE SMOKE_TENANT SMOKE_VM_USER SMOKE_USER_CA_FILE 357 export CI_USER CI_PASSWORD_FILE CI_PAT_FILE AGENT_HOSTS AGENT_STATE_DIR SERVER_GOCOVERDIR AGENT_GOCOVERDIR
358 export SMOKE_GATE SMOKE_VM_USER SMOKE_USER_CA_FILE
218 if COVER_OUT="$REPO_ROOT/coverage/integration" "$REPO_ROOT/bin/eitri-smoke"; then 359 if COVER_OUT="$REPO_ROOT/coverage/integration" "$REPO_ROOT/bin/eitri-smoke"; then
219 echo "boot gate: PASS" 360 echo "boot gate: PASS"
220 else 361 else
221 echo "DEPLOY FAILED boot gate: a VM did not boot after this deploy." >&2 362 echo "DEPLOY FAILED boot gate: sign-in or VM boot failed after this deploy." >&2
222 echo " The fleet is likely serving a boot-breaking change — investigate before relying on it." >&2 363 echo " The fleet is likely serving a boot-breaking change — investigate before relying on it." >&2
223 exit 1 364 exit 1
224 fi 365 fi
225 else
226 echo "(boot gate skipped — needs ADMIN_TOKEN_FILE)"
227 fi 366 fi
228 367
229 bold "Deploy complete: $SHA" 368 bold "Deploy complete: $SHA"
scripts/eitri-oidc.service
Old New
@@ -0,0 +1,22 @@
1 # eitri bundled OIDC issuer systemd unit. Install on the box that hosts it:
2 #
3 # cp eitri-oidc.service /etc/systemd/system/
4 # systemctl daemon-reload && systemctl enable --now eitri-oidc
5 #
6 # Sibling to eitri-server: deploy it only for a bundled-OIDC install; a fleet
7 # fronted by an external IdP never needs this unit. Config and the flat user
8 # file live under /etc/eitri (see docs and `eitri-oidc user add`).
9
10 [Unit]
11 Description=eitri bundled OIDC issuer
12 Documentation=https://eitri.sh
13 After=network-online.target
14 Wants=network-online.target
15
16 [Service]
17 ExecStart=/usr/local/bin/eitri-oidc -config /etc/eitri/eitri-oidc.json
18 Restart=on-failure
19 RestartSec=5
20
21 [Install]
22 WantedBy=multi-user.target
scripts/release.sh
Old New
@@ -2,6 +2,8 @@
2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/: 2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/:
3 # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit 3 # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit
4 # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin 4 # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin
5 # eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit
6 # (optional sidecar; not in manifest.json)
5 # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent 7 # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent
6 # self-updater downloads and sha-verifies 8 # self-updater downloads and sha-verifies
7 # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream 9 # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream
@@ -58,6 +60,22 @@ for arch in amd64 arm64; do
58 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}" 60 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}"
59 done 61 done
60 62
63 # Bundled OIDC issuer — its own tarball (binary + systemd unit) so running with
64 # or without local OIDC is a pure deployment choice: a fleet fronted by an
65 # external IdP never downloads it. Like eitri-cli, it is not an agent/runtime
66 # artifact, so it stays out of manifest.json (BuildManifest matches only the
67 # bare eitri-agent/cloud-hypervisor binaries and CLOUDHV.fd).
68 for arch in amd64 arm64; do
69 bundle="eitri-oidc_${VERSION}_linux_${arch}"
70 stage="$STAGE_ROOT/oidc-$arch"
71 mkdir -p "$stage/$bundle"
72 echo "==> building eitri-oidc linux/$arch"
73 CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \
74 -o "$stage/$bundle/eitri-oidc" ./cmd/eitri-oidc
75 cp scripts/eitri-oidc.service "$stage/$bundle/"
76 tar -C "$stage" -czf "$OUT/$bundle.tar.gz" "$bundle"
77 done
78
61 # Client CLI, cross-compiled for laptops (pure Go, CGO-free). 79 # Client CLI, cross-compiled for laptops (pure Go, CGO-free).
62 for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do 80 for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
63 os=${platform%%/*} arch=${platform##*/} 81 os=${platform%%/*} arch=${platform##*/}
web/src/lib/api-types.ts
Old New
@@ -377,6 +377,51 @@ export interface paths {
377 patch?: never; 377 patch?: never;
378 trace?: never; 378 trace?: never;
379 }; 379 };
380 "/api/v1/me": {
381 parameters: {
382 query?: never;
383 header?: never;
384 path?: never;
385 cookie?: never;
386 };
387 /** The signed-in identity: the caller's tenant handle and bound email. */
388 get: {
389 parameters: {
390 query?: never;
391 header?: never;
392 path?: never;
393 cookie?: never;
394 };
395 requestBody?: never;
396 responses: {
397 /** @description success */
398 200: {
399 headers: {
400 [name: string]: unknown;
401 };
402 content: {
403 "application/json": components["schemas"]["Me"];
404 };
405 };
406 /** @description error (plain text) */
407 default: {
408 headers: {
409 [name: string]: unknown;
410 };
411 content: {
412 "text/plain": string;
413 };
414 };
415 };
416 };
417 put?: never;
418 post?: never;
419 delete?: never;
420 options?: never;
421 head?: never;
422 patch?: never;
423 trace?: never;
424 };
380 "/api/v1/ssh-ca": { 425 "/api/v1/ssh-ca": {
381 parameters: { 426 parameters: {
382 query?: never; 427 query?: never;
@@ -674,6 +719,129 @@ export interface paths {
674 patch?: never; 719 patch?: never;
675 trace?: never; 720 trace?: never;
676 }; 721 };
722 "/api/v1/tokens": {
723 parameters: {
724 query?: never;
725 header?: never;
726 path?: never;
727 cookie?: never;
728 };
729 /** List the tenant's personal access tokens (metadata only — never the secret), newest first. */
730 get: {
731 parameters: {
732 query?: never;
733 header?: never;
734 path?: never;
735 cookie?: never;
736 };
737 requestBody?: never;
738 responses: {
739 /** @description success */
740 200: {
741 headers: {
742 [name: string]: unknown;
743 };
744 content: {
745 "application/json": components["schemas"]["APIToken"][];
746 };
747 };
748 /** @description error (plain text) */
749 default: {
750 headers: {
751 [name: string]: unknown;
752 };
753 content: {
754 "text/plain": string;
755 };
756 };
757 };
758 };
759 put?: never;
760 /** Mint a personal access token; the secret is returned exactly once. An optional TTL sets expiry (0 = non-expiring). */
761 post: {
762 parameters: {
763 query?: never;
764 header?: never;
765 path?: never;
766 cookie?: never;
767 };
768 requestBody: {
769 content: {
770 "application/json": components["schemas"]["CreateAPITokenRequest"];
771 };
772 };
773 responses: {
774 /** @description success */
775 201: {
776 headers: {
777 [name: string]: unknown;
778 };
779 content: {
780 "application/json": components["schemas"]["CreateAPITokenResponse"];
781 };
782 };
783 /** @description error (plain text) */
784 default: {
785 headers: {
786 [name: string]: unknown;
787 };
788 content: {
789 "text/plain": string;
790 };
791 };
792 };
793 };
794 delete?: never;
795 options?: never;
796 head?: never;
797 patch?: never;
798 trace?: never;
799 };
800 "/api/v1/tokens/{id}": {
801 parameters: {
802 query?: never;
803 header?: never;
804 path?: never;
805 cookie?: never;
806 };
807 get?: never;
808 put?: never;
809 post?: never;
810 /** Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak). */
811 delete: {
812 parameters: {
813 query?: never;
814 header?: never;
815 path: {
816 id: string;
817 };
818 cookie?: never;
819 };
820 requestBody?: never;
821 responses: {
822 /** @description success */
823 204: {
824 headers: {
825 [name: string]: unknown;
826 };
827 content?: never;
828 };
829 /** @description error (plain text) */
830 default: {
831 headers: {
832 [name: string]: unknown;
833 };
834 content: {
835 "text/plain": string;
836 };
837 };
838 };
839 };
840 options?: never;
841 head?: never;
842 patch?: never;
843 trace?: never;
844 };
677 "/api/v1/vms": { 845 "/api/v1/vms": {
678 parameters: { 846 parameters: {
679 query?: never; 847 query?: never;
@@ -977,6 +1145,14 @@ export interface paths {
977 export type webhooks = Record<string, never>; 1145 export type webhooks = Record<string, never>;
978 export interface components { 1146 export interface components {
979 schemas: { 1147 schemas: {
1148 APIToken: {
1149 created_at: string;
1150 expires_at: string;
1151 id: string;
1152 last_used_at: string;
1153 name: string;
1154 revoked_at: string;
1155 };
980 AuditEvent: { 1156 AuditEvent: {
981 action: string; 1157 action: string;
982 /** Format: date-time */ 1158 /** Format: date-time */
@@ -988,6 +1164,16 @@ export interface components {
988 mem_mb: number; 1164 mem_mb: number;
989 vcpus: number; 1165 vcpus: number;
990 }; 1166 };
1167 CreateAPITokenRequest: {
1168 name?: string;
1169 ttl_seconds?: number;
1170 };
1171 CreateAPITokenResponse: {
1172 expires_at: string;
1173 id: string;
1174 name: string;
1175 token: string;
1176 };
991 CreateVMRequest: { 1177 CreateVMRequest: {
992 cloud_init?: string; 1178 cloud_init?: string;
993 disk_gb?: number; 1179 disk_gb?: number;
@@ -1050,6 +1236,10 @@ export interface components {
1050 status: string; 1236 status: string;
1051 virt: string; 1237 virt: string;
1052 }; 1238 };
1239 Me: {
1240 email: string;
1241 tenant: string;
1242 };
1053 Metrics: { 1243 Metrics: {
1054 disk_free_gb: number; 1244 disk_free_gb: number;
1055 disk_used_gb: number; 1245 disk_used_gb: number;
web/src/lib/fleet.svelte.ts
Old New
@@ -22,14 +22,17 @@ export type UserCA = components['schemas']['UserCA'];
22 22
23 export type CreateVMRequest = components['schemas']['CreateVMRequest']; 23 export type CreateVMRequest = components['schemas']['CreateVMRequest'];
24 24
25 const TOKEN_KEY = 'eitri_token'; 25 /** Me is the signed-in identity: the caller's tenant handle and bound email. */
26 export type Me = components['schemas']['Me'];
26 27
27 // The frontend is single-tenant today; this is the one place to change when it 28 /** APIToken is a personal access token's metadata (never the secret). */
28 // grows a tenant selector. Used to build /api/v1/tenants/<tenant>/user-cas. 29 export type APIToken = components['schemas']['APIToken'];
29 export const TENANT = 'default'; 30
31 /** CreateAPITokenResponse carries the freshly minted PAT secret, shown once. */
32 export type CreateAPITokenResponse = components['schemas']['CreateAPITokenResponse'];
30 33
31 export const fleet = $state({ 34 export const fleet = $state({
32 token: typeof localStorage !== 'undefined' ? (localStorage.getItem(TOKEN_KEY) ?? '') : '', 35 me: null as Me | null,
33 hosts: [] as Host[], 36 hosts: [] as Host[],
34 vms: [] as VM[], 37 vms: [] as VM[],
35 userCAs: [] as UserCA[], 38 userCAs: [] as UserCA[],
@@ -39,6 +42,14 @@ export const fleet = $state({
39 latest_version: '' 42 latest_version: ''
40 }); 43 });
41 44
45 /** tenant is the signed-in tenant handle, used to build the per-tenant user-CA
46 * paths. fetchMe() runs before any CA call, so me is set; the throw only ever
47 * fires on a programming slip (a CA call before sign-in resolved). */
48 function tenant(): string {
49 if (!fleet.me) throw new Error('not signed in');
50 return fleet.me.tenant;
51 }
52
42 // clock is a single shared ticking wall-clock (unix seconds). Countdowns read 53 // clock is a single shared ticking wall-clock (unix seconds). Countdowns read
43 // clock.now instead of each spinning its own interval, so every countdown on 54 // clock.now instead of each spinning its own interval, so every countdown on
44 // the page ticks in lockstep and none drifts. One interval feeds them all. 55 // the page ticks in lockstep and none drifts. One interval feeds them all.
@@ -70,16 +81,27 @@ let es: EventSource | null = null;
70 // stay sticky until dismissed. 81 // stay sticky until dismissed.
71 let sseParseError = false; 82 let sseParseError = false;
72 83
73 function authHeaders(): HeadersInit { 84 // redirecting guards against stacked navigations: a batch of concurrent
74 return { Authorization: `Bearer ${fleet.token}`, 'Content-Type': 'application/json' }; 85 // requests (e.g. the startup me + hosts + vms fetches) can all 401 at once,
75 } 86 // but only the first hands off to sign-in.
87 let redirecting = false;
76 88
77 async function req(method: string, path: string, body?: unknown): Promise<Response> { 89 async function req(method: string, path: string, body?: unknown): Promise<Response> {
78 const res = await fetch(path, { 90 const res = await fetch(path, {
79 method, 91 method,
80 headers: authHeaders(), 92 headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
81 body: body === undefined ? undefined : JSON.stringify(body) 93 body: body === undefined ? undefined : JSON.stringify(body)
82 }); 94 });
95 if (res.status === 401) {
96 // Session absent or expired: the same-origin cookie either wasn't sent
97 // or no longer resolves. Hand off to the OIDC flow, which bounces
98 // through the IdP and back — the one and only redirect point.
99 if (typeof window !== 'undefined' && !redirecting) {
100 redirecting = true;
101 window.location.href = '/auth/login';
102 }
103 throw new Error('401: unauthenticated');
104 }
83 if (!res.ok) { 105 if (!res.ok) {
84 const text = await res.text(); 106 const text = await res.text();
85 throw new Error(`${res.status}: ${text.trim() || res.statusText}`); 107 throw new Error(`${res.status}: ${text.trim() || res.statusText}`);
@@ -87,15 +109,15 @@ async function req(method: string, path: string, body?: unknown): Promise<Respon
87 return res; 109 return res;
88 } 110 }
89 111
90 /** setToken persists the admin token and (re)connects the live stream. */ 112 /** fetchMe loads the signed-in identity into fleet.me. Call before connect()
91 export function setToken(t: string) { 113 * (the SSE ticket mint needs the session too). A 401 redirects to sign-in via
92 fleet.token = t.trim(); 114 * req(); the caller treats a throw as "not signed in, redirecting". */
93 if (typeof localStorage !== 'undefined') localStorage.setItem(TOKEN_KEY, fleet.token); 115 export async function fetchMe() {
94 connect(); 116 fleet.me = await (await req('GET', '/api/v1/me')).json();
95 } 117 }
96 118
97 /** mintTicket fetches a one-time stream ticket (SSE + console WS auth): the 119 /** mintTicket fetches a one-time stream ticket (SSE + console WS auth): the
98 * admin token never rides in a URL. Throws on failure. */ 120 * session cookie never rides in a URL. Throws on failure. */
99 export async function mintTicket(): Promise<string> { 121 export async function mintTicket(): Promise<string> {
100 const r = await (await req('POST', '/api/v1/stream-tickets')).json(); 122 const r = await (await req('POST', '/api/v1/stream-tickets')).json();
101 if (typeof r.ticket !== 'string' || !r.ticket) throw new Error('malformed ticket response'); 123 if (typeof r.ticket !== 'string' || !r.ticket) throw new Error('malformed ticket response');
@@ -109,17 +131,16 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
109 // orphan instead of clobbering (or later killing) the current stream. 131 // orphan instead of clobbering (or later killing) the current stream.
110 let connectGen = 0; 132 let connectGen = 0;
111 133
112 /** connect mints a one-time stream ticket (so the admin token never rides in 134 /** connect mints a one-time stream ticket (so the session never rides in a URL)
113 * a URL) and opens the SSE stream. Tickets are single-use, so the browser's 135 * and opens the SSE stream. Tickets are single-use, so the browser's built-in
114 * built-in EventSource retry cannot work — on error we close the stream and 136 * EventSource retry cannot work — on error we close the stream and reconnect
115 * reconnect ourselves with a fresh ticket. */ 137 * ourselves with a fresh ticket. */
116 export async function connect() { 138 export async function connect() {
117 es?.close(); // close first: clearing the token must also stop the stream 139 es?.close();
118 if (reconnectTimer) { 140 if (reconnectTimer) {
119 clearTimeout(reconnectTimer); 141 clearTimeout(reconnectTimer);
120 reconnectTimer = null; 142 reconnectTimer = null;
121 } 143 }
122 if (!fleet.token) return;
123 const gen = ++connectGen; 144 const gen = ++connectGen;
124 let ticket: string; 145 let ticket: string;
125 try { 146 try {
@@ -236,18 +257,39 @@ export async function vmEvents(id: string): Promise<VMEvent[]> {
236 257
237 /** listUserCAs fetches the tenant's registered SSH user CAs. */ 258 /** listUserCAs fetches the tenant's registered SSH user CAs. */
238 export async function listUserCAs(): Promise<UserCA[]> { 259 export async function listUserCAs(): Promise<UserCA[]> {
239 return (await req('GET', `/api/v1/tenants/${TENANT}/user-cas`)).json(); 260 return (await req('GET', `/api/v1/tenants/${tenant()}/user-cas`)).json();
240 } 261 }
241 262
242 /** uploadUserCA registers a CA public key for the tenant. label is optional. */ 263 /** uploadUserCA registers a CA public key for the tenant. label is optional. */
243 export async function uploadUserCA(body: { public_key: string; label?: string }) { 264 export async function uploadUserCA(body: { public_key: string; label?: string }) {
244 await req('POST', `/api/v1/tenants/${TENANT}/user-cas`, body); 265 await req('POST', `/api/v1/tenants/${tenant()}/user-cas`, body);
245 } 266 }
246 267
247 /** deleteUserCA removes a registered CA by its public-key line. The endpoint 268 /** deleteUserCA removes a registered CA by its public-key line. The endpoint
248 * takes a JSON body (unlike deleteVM). */ 269 * takes a JSON body (unlike deleteVM). */
249 export async function deleteUserCA(public_key: string) { 270 export async function deleteUserCA(public_key: string) {
250 await req('DELETE', `/api/v1/tenants/${TENANT}/user-cas`, { public_key }); 271 await req('DELETE', `/api/v1/tenants/${tenant()}/user-cas`, { public_key });
272 }
273
274 /** listTokens fetches the tenant's personal access tokens (metadata only). */
275 export async function listTokens(): Promise<APIToken[]> {
276 return (await req('GET', '/api/v1/tokens')).json();
277 }
278
279 /** createToken mints a PAT; the returned secret is shown once and never again.
280 * ttlSeconds undefined (or 0) mints a non-expiring token. */
281 export async function createToken(
282 name: string,
283 ttlSeconds?: number
284 ): Promise<CreateAPITokenResponse> {
285 const body: components['schemas']['CreateAPITokenRequest'] = { name };
286 if (ttlSeconds !== undefined) body.ttl_seconds = ttlSeconds;
287 return (await req('POST', '/api/v1/tokens', body)).json();
288 }
289
290 /** revokeToken revokes a PAT by id. */
291 export async function revokeToken(id: string) {
292 await req('DELETE', `/api/v1/tokens/${id}`);
251 } 293 }
252 294
253 /** refreshUserCAs loads the tenant's CAs into fleet.userCAs. CAs are NOT part 295 /** refreshUserCAs loads the tenant's CAs into fleet.userCAs. CAs are NOT part
web/src/routes/+layout.svelte
Old New
@@ -1,23 +1,25 @@
1 <script lang="ts"> 1 <script lang="ts">
2 import favicon from '$lib/assets/favicon.svg'; 2 import favicon from '$lib/assets/favicon.svg';
3 import { onMount } from 'svelte'; 3 import { onMount } from 'svelte';
4 import { fleet, setToken, connect, refresh, dismissError, startClock } from '$lib/fleet.svelte'; 4 import { fleet, fetchMe, connect, refresh, dismissError, startClock } from '$lib/fleet.svelte';
5 let { children } = $props(); 5 let { children } = $props();
6 let tokenInput = $state('');
7 6
8 onMount(() => { 7 onMount(async () => {
9 startClock(); 8 startClock();
10 if (fleet.token) { 9 try {
11 tokenInput = fleet.token; 10 // A 401 here redirects to /auth/login (handled in req); a throw means
12 refresh(); 11 // we're on our way out, so render nothing further.
13 connect(); 12 await fetchMe();
13 } catch {
14 return;
14 } 15 }
16 refresh();
17 connect();
15 }); 18 });
16 19
17 function saveToken(e: Event) { 20 async function signOut() {
18 e.preventDefault(); 21 await fetch('/auth/logout', { method: 'POST' });
19 setToken(tokenInput); 22 window.location.href = '/';
20 refresh();
21 } 23 }
22 24
23 // Dismissing the banner removes the focused button from the DOM, which 25 // Dismissing the banner removes the focused button from the DOM, which
@@ -44,14 +46,13 @@
44 <a href="/" class="brand">eitri</a> 46 <a href="/" class="brand">eitri</a>
45 <span class="fleet-label">fleet</span> 47 <span class="fleet-label">fleet</span>
46 <span class="spacer"></span> 48 <span class="spacer"></span>
47 {#if fleet.token} 49 {#if fleet.me}
48 <span class="dot {fleet.connected ? 'on' : 'off'}"></span> 50 <span class="dot {fleet.connected ? 'on' : 'off'}"></span>
49 <span class="conn">{fleet.connected ? 'live' : 'disconnected'}</span> 51 <span class="conn">{fleet.connected ? 'live' : 'disconnected'}</span>
52 <span class="email">{fleet.me.email}</span>
53 <a href="/settings">settings</a>
54 <button type="button" class="ghost" onclick={signOut}>Sign out</button>
50 {/if} 55 {/if}
51 <form onsubmit={saveToken} class="tokenform">
52 <input type="password" placeholder="admin token" bind:value={tokenInput} autocomplete="off" />
53 <button type="submit">set</button>
54 </form>
55 </header> 56 </header>
56 57
57 {#if fleet.error} 58 {#if fleet.error}
@@ -62,10 +63,10 @@
62 {/if} 63 {/if}
63 64
64 <main> 65 <main>
65 {#if !fleet.token} 66 {#if fleet.me}
66 <p class="hint">Enter your admin token above to manage the fleet.</p>
67 {:else}
68 {@render children()} 67 {@render children()}
68 {:else}
69 <p class="hint">Signing in…</p>
69 {/if} 70 {/if}
70 </main> 71 </main>
71 72
@@ -138,9 +139,8 @@
138 color: #9aa0aa; 139 color: #9aa0aa;
139 margin-right: 0.5rem; 140 margin-right: 0.5rem;
140 } 141 }
141 .tokenform { 142 .email {
142 display: flex; 143 color: #9aa0aa;
143 gap: 0.3rem;
144 } 144 }
145 :global(input, button, select, textarea) { 145 :global(input, button, select, textarea) {
146 background: #0c0d10; 146 background: #0c0d10;
web/src/routes/+page.svelte
Old New
@@ -1,5 +1,4 @@
1 <script lang="ts"> 1 <script lang="ts">
2 import { onMount } from 'svelte';
3 import { 2 import {
4 fleet, 3 fleet,
5 action, 4 action,
@@ -13,9 +12,6 @@
13 vmPower, 12 vmPower,
14 vmIP, 13 vmIP,
15 vmIsRunning, 14 vmIsRunning,
16 uploadUserCA,
17 deleteUserCA,
18 refreshUserCAs,
19 upgradeAgent, 15 upgradeAgent,
20 type CreateVMRequest 16 type CreateVMRequest
21 } from '$lib/fleet.svelte'; 17 } from '$lib/fleet.svelte';
@@ -86,31 +82,6 @@
86 82
87 let form = $state<CreateVMRequest>({ host_id: '' }); 83 let form = $state<CreateVMRequest>({ host_id: '' });
88 84
89 let caForm = $state<{ public_key: string; label: string }>({ public_key: '', label: '' });
90 let caBusy = $state(false);
91
92 onMount(refreshUserCAs);
93
94 async function submitUploadCA(e: Event) {
95 e.preventDefault();
96 caBusy = true;
97 if (
98 await action(() =>
99 uploadUserCA({ public_key: caForm.public_key.trim(), label: caForm.label.trim() || undefined })
100 )
101 ) {
102 caForm = { public_key: '', label: '' };
103 await refreshUserCAs();
104 }
105 caBusy = false;
106 }
107
108 async function removeCA(pubkey: string) {
109 if (!confirm('Remove this SSH CA? New connections signed by it will be rejected; existing VMs keep trusting it until recreated.'))
110 return;
111 if (await action(() => deleteUserCA(pubkey))) await refreshUserCAs();
112 }
113
114 function openCreate() { 85 function openCreate() {
115 form = { host_id: fleet.hosts[0]?.id ?? '' }; 86 form = { host_id: fleet.hosts[0]?.id ?? '' };
116 showCreate = true; 87 showCreate = true;
@@ -185,21 +156,49 @@
185 <section> 156 <section>
186 <div class="row"> 157 <div class="row">
187 <h2>Hosts ({needle ? `${shownHosts.length}/${fleet.hosts.length}` : fleet.hosts.length})</h2> 158 <h2>Hosts ({needle ? `${shownHosts.length}/${fleet.hosts.length}` : fleet.hosts.length})</h2>
188 <button class="ghost" onclick={addHost}>+ Add host</button> 159 {#if fleet.hosts.length > 0}
160 <button class="ghost" onclick={addHost}>+ Add host</button>
161 {/if}
189 </div> 162 </div>
190 163
191 {#if joinBlob} 164 {#if fleet.hosts.length === 0}
192 <div class="enroll"> 165 <div class="onboard">
193 Run on the new host: 166 <h3>Add your first host</h3>
194 <code>eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code> 167 <p class="hint">
168 A host is any Linux box that runs your VMs — the server's own box counts. Mint a one-time
169 join token, then run the printed command on the box; it comes online here the moment it
170 enrolls.
171 </p>
172 {#if joinBlob}
173 <p>From the unpacked host bundle, run on the new host:</p>
174 <div class="enroll">
175 <code>sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent</code>
176 <code>sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service</code>
177 <code>sudo eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code>
178 <code>sudo systemctl daemon-reload</code>
179 <code>sudo systemctl enable --now eitri-agent</code>
180 </div>
181 {:else}
182 <button onclick={addHost}>Add your first host</button>
183 {/if}
195 </div> 184 </div>
185 {:else if joinBlob || shownHosts.length === 0}
186 {#if joinBlob}
187 <div class="enroll">
188 From the unpacked host bundle, run on the new host:
189 <code>sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent</code>
190 <code>sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service</code>
191 <code>sudo eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code>
192 <code>sudo systemctl daemon-reload</code>
193 <code>sudo systemctl enable --now eitri-agent</code>
194 </div>
195 {/if}
196 {#if shownHosts.length === 0}
197 <p class="hint">No hosts match “{filter}”.</p>
198 {/if}
196 {/if} 199 {/if}
197 200
198 {#if fleet.hosts.length === 0} 201 {#if fleet.hosts.length > 0 && shownHosts.length > 0}
199 <p class="hint">No hosts enrolled yet.</p>
200 {:else if shownHosts.length === 0}
201 <p class="hint">No hosts match “{filter}”.</p>
202 {:else}
203 <table> 202 <table>
204 <thead> 203 <thead>
205 <tr><th>Name</th><th>Status</th><th>OS</th><th>Version</th><th>Load</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr> 204 <tr><th>Name</th><th>Status</th><th>OS</th><th>Version</th><th>Load</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr>
@@ -302,43 +301,6 @@
302 {/if} 301 {/if}
303 </section> 302 </section>
304 303
305 <section>
306 <div class="row">
307 <h2>SSH Access ({fleet.userCAs.length})</h2>
308 </div>
309
310 {#if fleet.userCAs.length === 0}
311 <p class="hint">No SSH CA registered — a VM cannot be created until the tenant has one.</p>
312 <div class="enroll">
313 Generate a CA, paste its public key below, then connect:
314 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
315 <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code>
316 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh &lt;vm-name&gt;</code>
317 </div>
318 {:else}
319 <table>
320 <thead>
321 <tr><th>Fingerprint</th><th>Label</th><th></th></tr>
322 </thead>
323 <tbody>
324 {#each fleet.userCAs as ca (ca.pubkey)}
325 <tr>
326 <td>{ca.fingerprint}</td>
327 <td>{ca.label || '—'}</td>
328 <td><button class="danger" onclick={() => removeCA(ca.pubkey)}>Remove</button></td>
329 </tr>
330 {/each}
331 </tbody>
332 </table>
333 {/if}
334
335 <form class="ca-add" onsubmit={submitUploadCA}>
336 <label>SSH CA public key<input bind:value={caForm.public_key} placeholder="ssh-ed25519 AAAA… (contents of your ~/.ssh/eitri_user_ca.pub)" /></label>
337 <label>Label (optional)<input bind:value={caForm.label} placeholder="laptop" /></label>
338 <button type="submit" disabled={caBusy || !caForm.public_key.trim()}>{caBusy ? 'Adding…' : 'Add CA'}</button>
339 </form>
340 </section>
341
342 {#if showCreate} 304 {#if showCreate}
343 <div class="modal" role="dialog"> 305 <div class="modal" role="dialog">
344 <form class="card" onsubmit={submitCreate}> 306 <form class="card" onsubmit={submitCreate}>
@@ -520,18 +482,18 @@
520 color: #f0b429; 482 color: #f0b429;
521 font-weight: 600; 483 font-weight: 600;
522 } 484 }
523 .ca-add { 485 .onboard {
524 display: flex; 486 background: #15171c;
525 gap: 0.5rem; 487 border: 1px solid #2a2e37;
526 align-items: flex-end; 488 border-radius: 8px;
527 flex-wrap: wrap; 489 padding: 1.2rem;
528 margin-top: 0.5rem; 490 margin: 0.5rem 0;
491 max-width: 620px;
529 } 492 }
530 .ca-add label { 493 .onboard h3 {
531 flex: 1; 494 margin: 0 0 0.4rem;
532 min-width: 200px;
533 } 495 }
534 .ca-add input { 496 .onboard .enroll {
535 width: 100%; 497 margin-bottom: 0;
536 } 498 }
537 </style> 499 </style>
web/src/routes/settings/+page.svelte
Old New
@@ -0,0 +1,332 @@
1 <script lang="ts">
2 import { onMount } from 'svelte';
3 import {
4 fleet,
5 action,
6 createToken,
7 listTokens,
8 revokeToken,
9 uploadUserCA,
10 deleteUserCA,
11 refreshUserCAs,
12 type APIToken,
13 type CreateAPITokenResponse
14 } from '$lib/fleet.svelte';
15
16 // PAT state ---------------------------------------------------------------
17 let tokens = $state<APIToken[]>([]);
18 // TTL presets: label → seconds; 0 mints a non-expiring token.
19 const ttlPresets: { label: string; seconds: number }[] = [
20 { label: 'never', seconds: 0 },
21 { label: '1 hour', seconds: 3600 },
22 { label: '30 days', seconds: 2592000 },
23 { label: '90 days', seconds: 7776000 }
24 ];
25 let patForm = $state<{ name: string; ttl: number }>({ name: '', ttl: 0 });
26 let patBusy = $state(false);
27 // minted holds the freshly created token's secret — shown exactly once, then
28 // dismissed (it is never fetchable again).
29 let minted = $state<CreateAPITokenResponse | null>(null);
30 let copied = $state(false);
31 // confirmRevoke is the id of the token whose Revoke button is in its second
32 // (confirm) step — an inline two-step replaces a blocking confirm() dialog.
33 let confirmRevoke = $state('');
34
35 async function refreshTokens() {
36 try {
37 tokens = await listTokens();
38 } catch (err) {
39 fleet.error = String(err);
40 }
41 }
42
43 onMount(() => {
44 refreshTokens();
45 refreshUserCAs();
46 });
47
48 async function submitCreateToken(e: Event) {
49 e.preventDefault();
50 patBusy = true;
51 const name = patForm.name.trim();
52 // ttl 0 = non-expiring: pass undefined so the request omits ttl_seconds.
53 const res = await action(async () => {
54 minted = await createToken(name, patForm.ttl || undefined);
55 });
56 if (res) {
57 copied = false;
58 patForm = { name: '', ttl: 0 };
59 await refreshTokens();
60 }
61 patBusy = false;
62 }
63
64 async function copySecret() {
65 if (!minted) return;
66 try {
67 // navigator.clipboard only exists in secure contexts; a console
68 // served over plain http on a LAN origin doesn't get it. Fall back
69 // to selecting the token so a manual Ctrl-C works in one keystroke.
70 if (navigator.clipboard?.writeText) {
71 await navigator.clipboard.writeText(minted.token);
72 copied = true;
73 return;
74 }
75 const el = document.getElementById('minted-token');
76 if (el) {
77 window.getSelection()?.selectAllChildren(el);
78 fleet.error = 'Clipboard unavailable over http — token selected, press Ctrl-C';
79 }
80 } catch (err) {
81 fleet.error = String(err);
82 }
83 }
84
85 async function doRevoke(id: string) {
86 confirmRevoke = '';
87 if (await action(() => revokeToken(id))) await refreshTokens();
88 }
89
90 // fmtTime renders an RFC3339 timestamp, or a fallback when the column is
91 // NULL (empty string): non-expiring tokens, never-used tokens.
92 function fmtTime(s: string, fallback: string): string {
93 if (!s) return fallback;
94 const d = new Date(s);
95 return isNaN(d.getTime()) ? s : d.toLocaleString();
96 }
97
98 // SSH user-CA state (moved from the fleet page — this is tenant settings) ---
99 let caForm = $state<{ public_key: string; label: string }>({ public_key: '', label: '' });
100 let caBusy = $state(false);
101
102 async function submitUploadCA(e: Event) {
103 e.preventDefault();
104 caBusy = true;
105 if (
106 await action(() =>
107 uploadUserCA({ public_key: caForm.public_key.trim(), label: caForm.label.trim() || undefined })
108 )
109 ) {
110 caForm = { public_key: '', label: '' };
111 await refreshUserCAs();
112 }
113 caBusy = false;
114 }
115
116 async function removeCA(pubkey: string) {
117 if (!confirm('Remove this SSH CA? New connections signed by it will be rejected; existing VMs keep trusting it until recreated.'))
118 return;
119 if (await action(() => deleteUserCA(pubkey))) await refreshUserCAs();
120 }
121 </script>
122
123 <div class="row">
124 <h2>Settings</h2>
125 </div>
126
127 <section>
128 <div class="row">
129 <h2>Identity</h2>
130 </div>
131 {#if fleet.me}
132 <table>
133 <tbody>
134 <tr><th>Email</th><td>{fleet.me.email}</td></tr>
135 <tr><th>Tenant</th><td>{fleet.me.tenant}</td></tr>
136 </tbody>
137 </table>
138 {/if}
139 </section>
140
141 <section>
142 <div class="row">
143 <h2>Personal access tokens ({tokens.length})</h2>
144 </div>
145 <p class="hint">
146 Use a PAT to authenticate the CLI and automation over the <code>EITRI_TOKEN</code> env var. The
147 secret is shown once at creation and stored only as a hash.
148 </p>
149
150 {#if minted}
151 <div class="secret-box">
152 <div class="secret-head">
153 <strong>Token “{minted.name}” created</strong>
154 <button type="button" class="ghost" onclick={() => (minted = null)}>Dismiss</button>
155 </div>
156 <p class="warn">Copy it now — you won't be able to see this token again.</p>
157 <div class="secret-value">
158 <code id="minted-token">{minted.token}</code>
159 <button type="button" onclick={copySecret}>{copied ? 'Copied' : 'Copy'}</button>
160 </div>
161 </div>
162 {/if}
163
164 <form class="pat-form" onsubmit={submitCreateToken}>
165 <label>Name<input bind:value={patForm.name} placeholder="laptop-cli" /></label>
166 <label
167 >Expires
168 <select bind:value={patForm.ttl}>
169 {#each ttlPresets as p (p.seconds)}
170 <option value={p.seconds}>{p.label}</option>
171 {/each}
172 </select>
173 </label>
174 <button type="submit" disabled={patBusy || !patForm.name.trim()}>{patBusy ? 'Creating…' : 'Create token'}</button>
175 </form>
176
177 {#if tokens.length > 0}
178 <table>
179 <thead>
180 <tr><th>Name</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
181 </thead>
182 <tbody>
183 {#each tokens as t (t.id)}
184 <tr>
185 <td>{t.name}</td>
186 <td>{fmtTime(t.created_at, '—')}</td>
187 <td>{fmtTime(t.expires_at, 'never')}</td>
188 <td>{fmtTime(t.last_used_at, 'never')}</td>
189 <td class="actions">
190 {#if t.revoked_at}
191 <span class="hint">revoked</span>
192 {:else if confirmRevoke === t.id}
193 <button class="danger" onclick={() => doRevoke(t.id)}>Confirm</button>
194 <button class="ghost" onclick={() => (confirmRevoke = '')}>Cancel</button>
195 {:else}
196 <button class="danger" onclick={() => (confirmRevoke = t.id)}>Revoke</button>
197 {/if}
198 </td>
199 </tr>
200 {/each}
201 </tbody>
202 </table>
203 {/if}
204 </section>
205
206 <section>
207 <div class="row">
208 <h2>SSH Access ({fleet.userCAs.length})</h2>
209 </div>
210
211 {#if fleet.userCAs.length === 0}
212 <p class="hint">No SSH CA registered — a VM cannot be created until the tenant has one.</p>
213 <div class="enroll">
214 Generate a CA, paste its public key below, then connect:
215 <code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
216 <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code>
217 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh &lt;vm-name&gt;</code>
218 </div>
219 {:else}
220 <table>
221 <thead>
222 <tr><th>Fingerprint</th><th>Label</th><th></th></tr>
223 </thead>
224 <tbody>
225 {#each fleet.userCAs as ca (ca.pubkey)}
226 <tr>
227 <td>{ca.fingerprint}</td>
228 <td>{ca.label || '—'}</td>
229 <td><button class="danger" onclick={() => removeCA(ca.pubkey)}>Remove</button></td>
230 </tr>
231 {/each}
232 </tbody>
233 </table>
234 {/if}
235
236 <form class="ca-add" onsubmit={submitUploadCA}>
237 <label>SSH CA public key<input bind:value={caForm.public_key} placeholder="ssh-ed25519 AAAA… (contents of your ~/.ssh/eitri_user_ca.pub)" /></label>
238 <label>Label (optional)<input bind:value={caForm.label} placeholder="laptop" /></label>
239 <button type="submit" disabled={caBusy || !caForm.public_key.trim()}>{caBusy ? 'Adding…' : 'Add CA'}</button>
240 </form>
241 </section>
242
243 <style>
244 .row {
245 display: flex;
246 align-items: center;
247 gap: 0.8rem;
248 }
249 h2 {
250 font-size: 14px;
251 margin: 1rem 0 0;
252 }
253 .actions {
254 display: flex;
255 gap: 0.3rem;
256 }
257 .enroll {
258 background: #15171c;
259 border: 1px solid #2a2e37;
260 border-radius: 4px;
261 padding: 0.5rem;
262 margin: 0.5rem 0;
263 }
264 .enroll code {
265 display: block;
266 margin-top: 0.3rem;
267 color: #8fe3a0;
268 word-break: break-all;
269 }
270 .pat-form {
271 display: flex;
272 gap: 0.5rem;
273 align-items: flex-end;
274 flex-wrap: wrap;
275 margin-top: 0.5rem;
276 }
277 .pat-form label {
278 display: flex;
279 flex-direction: column;
280 gap: 0.2rem;
281 color: #9aa0aa;
282 }
283 .secret-box {
284 background: #15171c;
285 border: 1px solid #2a5a35;
286 border-radius: 6px;
287 padding: 0.8rem;
288 margin: 0.6rem 0;
289 }
290 .secret-head {
291 display: flex;
292 align-items: center;
293 justify-content: space-between;
294 gap: 0.6rem;
295 }
296 .secret-box .warn {
297 color: #f0b429;
298 margin: 0.4rem 0;
299 }
300 .secret-value {
301 display: flex;
302 align-items: center;
303 gap: 0.5rem;
304 }
305 .secret-value code {
306 flex: 1;
307 background: #0c0d10;
308 border: 1px solid #2a2e37;
309 border-radius: 4px;
310 padding: 0.3rem 0.5rem;
311 color: #8fe3a0;
312 word-break: break-all;
313 }
314 .ca-add {
315 display: flex;
316 gap: 0.5rem;
317 align-items: flex-end;
318 flex-wrap: wrap;
319 margin-top: 0.5rem;
320 }
321 .ca-add label {
322 flex: 1;
323 min-width: 200px;
324 display: flex;
325 flex-direction: column;
326 gap: 0.2rem;
327 color: #9aa0aa;
328 }
329 .ca-add input {
330 width: 100%;
331 }
332 </style>