d7e93399
feat: multi-tenant console with OIDC sign-in
a73x 2026-07-29 05:05
Commit message
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 <vm-name></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 <vm-name></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> | ||