diff --git a/Makefile b/Makefile index 2c44fd7..b1180f5 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ test: check-go # Fast suite: skips the database package's container-backed tests. test-short: check-go - go test -race -count=1 ./internal/config/... ./internal/enc/... ./internal/distro/... ./internal/server/... ./internal/cli/... ./migrations/... ./pkg/... + go test -race -count=1 ./internal/auth/... ./internal/config/... ./internal/enc/... ./internal/distro/... ./internal/server/... ./internal/cli/... ./migrations/... ./pkg/... lint: check-go go vet ./... diff --git a/README.md b/README.md index 8ca7088..3eb0878 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,23 @@ See [`docs/cutover.md`](docs/cutover.md) for the migration from Cobbler. ## API -Reads are open. Writes require `Authorization: Bearer $ENCAPI_WRITE_TOKEN` -(set the token in the server's environment; manage it in Vault). +Reads are open. Writes require `Authorization: Bearer `, where the +token is either the static `ENCAPI_WRITE_TOKEN` or a Vault identity JWT from +`ENCAPI_OIDC_ISSUER`: + +- aud `encapi` (`ENCAPI_OIDC_AUDIENCE`): full write access, like the static token. +- aud `encapi-testing` (`ENCAPI_OIDC_TESTING_AUDIENCE`): only + `PUT /api/v1/nodes/{certname}/environment` with `{"environment":"testing"}`; + any other environment or write route returns 403. + +An unreachable issuer returns 503 for JWTs; the static token keeps working. ``` GET /healthz GET /api/v1/nodes list nodes GET /api/v1/nodes/{certname} get node PUT /api/v1/nodes/{certname} upsert node (token) +PUT /api/v1/nodes/{certname}/environment set only the environment, {"environment":"…"}; 404 if unknown (token, or testing JWT for "testing") DELETE /api/v1/nodes/{certname} delete node (token) GET /api/v1/nodes/{certname}/enc reshaped ENC (YAML) GET /cblr/svc/op/puppet/hostname/{h} cobbler-wire ENC (YAML) @@ -84,18 +93,24 @@ GET /api/v1/statuses ... /statuses/{name} (PUT/DELETE token) |------------------------|---------------|------------------------------------------| | `LISTEN_ADDR` | `:8000` | listen address | | `DBHOST/DBPORT/DBUSER/DBPASS/DBNAME/DBSSL` | localhost/5432/encapi/encapi/encapi/disable | Postgres | -| `ENCAPI_WRITE_TOKEN` | *(unset)* | bearer token for writes; unset = read-only | +| `ENCAPI_WRITE_TOKEN` | *(unset)* | static bearer token for writes | +| `ENCAPI_OIDC_ISSUER` | *(unset)* | OIDC issuer URL for JWT writes (e.g. `/v1/identity/oidc`); unset = JWTs disabled | +| `ENCAPI_OIDC_AUDIENCE` | `encapi` | JWT audience granting full write access | +| `ENCAPI_OIDC_TESTING_AUDIENCE` | `encapi-testing` | JWT audience allowed only to set a node's environment to `testing` | | `ENCAPI_DISTRO_API_URL`| *(unset)* | optional kickstart/distro API for provisioning params | +With neither `ENCAPI_WRITE_TOKEN` nor `ENCAPI_OIDC_ISSUER` set, writes are refused (read-only). + ## CLI ```bash export ENCAPI_URL=https://encapi.k8s.syd1.au.unkin.net -export ENCAPI_WRITE_TOKEN=… # only needed for writes +export ENCAPI_WRITE_TOKEN=… # static token or Vault identity JWT; only needed for writes encapi-cli status set production encapi-cli role set roles::infra::storage::minio --param 'replicas=4' --param 'epel="9"' encapi-cli node set ausyd1nxvm2100.main.unkin.net --role roles::infra::storage::minio --env production +encapi-cli node set-env ausyd1nxvm2100.main.unkin.net testing encapi-cli classify ausyd1nxvm2100.main.unkin.net # one-shot migration from the live Cobbler estate: diff --git a/cmd/encapi/main.go b/cmd/encapi/main.go index 882ed03..834e6f3 100644 --- a/cmd/encapi/main.go +++ b/cmd/encapi/main.go @@ -9,6 +9,7 @@ import ( "os/signal" "syscall" + "git.unkin.net/unkin/encapi/internal/auth" "git.unkin.net/unkin/encapi/internal/config" "git.unkin.net/unkin/encapi/internal/database" "git.unkin.net/unkin/encapi/internal/distro" @@ -37,11 +38,16 @@ func main() { } defer db.Close() - if cfg.WriteToken == "" { - slog.Warn("ENCAPI_WRITE_TOKEN is not set; write endpoints are disabled") + if cfg.WriteToken == "" && cfg.OIDCIssuer == "" { + slog.Warn("neither ENCAPI_WRITE_TOKEN nor ENCAPI_OIDC_ISSUER is set; write endpoints are disabled") } - srv := server.New(db, distro.New(cfg.DistroAPIURL), cfg.WriteToken) + srv := server.New(db, distro.New(cfg.DistroAPIURL), server.Auth{ + WriteToken: cfg.WriteToken, + OIDC: auth.NewBearerOIDC(cfg.OIDCIssuer, []string{cfg.OIDCAudience, cfg.OIDCTestingAudience}), + Audience: cfg.OIDCAudience, + TestingAudience: cfg.OIDCTestingAudience, + }) if err := srv.ListenAndServe(ctx, cfg.ListenAddr); err != nil { slog.Error("server", "err", err) diff --git a/go.mod b/go.mod index 42b96be..135b885 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.25.9 require ( git.unkin.net/unkin/golib v0.1.0 + github.com/coreos/go-oidc/v3 v3.21.0 github.com/go-chi/chi/v5 v5.3.0 + github.com/go-jose/go-jose/v4 v4.1.6 github.com/jackc/pgx/v5 v5.10.0 github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 @@ -63,6 +65,7 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect golang.org/x/crypto v0.54.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect diff --git a/go.sum b/go.sum index 9654fcd..46890cb 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -39,6 +41,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -138,6 +142,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..ae052e5 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,123 @@ +// Package auth verifies OIDC bearer JWTs (Vault identity tokens) for encapi writes. +package auth + +import ( + "context" + "errors" + "log/slog" + "net/http" + "slices" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// BearerOIDC verifies JWTs against an OIDC issuer's JWKS. Discovery is lazy so +// startup never depends on the issuer being reachable; a failed discovery is +// not retried until the backoff elapses. +type BearerOIDC struct { + issuer string + audiences []string + timeout time.Duration + backoff time.Duration + discover func(context.Context, string) (*oidc.Provider, error) + + mu sync.Mutex + verifier *oidc.IDTokenVerifier + inflight chan struct{} + failedAt time.Time +} + +// ErrIssuerUnavailable means discovery failed; callers should answer 503. +var ErrIssuerUnavailable = errors.New("oidc issuer unavailable") + +// ErrAudience means the token is valid but carries none of the allowed audiences. +var ErrAudience = errors.New("audience not allowed") + +// NewBearerOIDC returns nil when issuer is empty, meaning OIDC is disabled. +func NewBearerOIDC(issuer string, audiences []string) *BearerOIDC { + if issuer == "" { + return nil + } + return &BearerOIDC{issuer: issuer, audiences: audiences, timeout: 10 * time.Second, backoff: 15 * time.Second, discover: oidc.NewProvider} +} + +// Verify checks raw's signature, issuer, expiry and that it carries at least +// one allowed audience. +func (b *BearerOIDC) Verify(ctx context.Context, raw string) (*oidc.IDToken, error) { + v, err := b.getVerifier(ctx) + if err != nil { + return nil, ErrIssuerUnavailable + } + tok, err := v.Verify(ctx, raw) + if err != nil { + return nil, err + } + if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) { + return nil, ErrAudience + } + return tok, nil +} + +// getVerifier runs at most one discovery at a time without holding the lock +// across network I/O; concurrent callers wait for it, and only callers within +// the post-failure backoff fail fast. +func (b *BearerOIDC) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) { + b.mu.Lock() + if v := b.verifier; v != nil { + b.mu.Unlock() + return v, nil + } + if done := b.inflight; done != nil { + b.mu.Unlock() + select { + case <-done: + case <-ctx.Done(): + return nil, ctx.Err() + } + b.mu.Lock() + defer b.mu.Unlock() + if b.verifier == nil { + return nil, ErrIssuerUnavailable + } + return b.verifier, nil + } + if time.Since(b.failedAt) < b.backoff { + b.mu.Unlock() + return nil, ErrIssuerUnavailable + } + done := make(chan struct{}) + b.inflight = done + b.mu.Unlock() + + v, err := b.discoverOnce(done) + if err != nil { + slog.Error("oidc discovery failed", "issuer", b.issuer, "error", err) + return nil, err + } + return v, nil +} + +// discoverOnce releases inflight and waiters on every exit, a panic included, +// recording failedAt unless discovery succeeded. +func (b *BearerOIDC) discoverOnce(done chan struct{}) (v *oidc.IDTokenVerifier, err error) { + defer func() { + b.mu.Lock() + b.inflight = nil + if v == nil { + b.failedAt = time.Now() + } + b.verifier = v + b.mu.Unlock() + close(done) + }() + // The provider keeps this context and client for JWKS refreshes, so the + // deadline lives on the client rather than the context. + client := &http.Client{Timeout: b.timeout} + p, err := b.discover(oidc.ClientContext(context.Background(), client), b.issuer) + if err != nil { + return nil, err + } + return p.Verifier(&oidc.Config{SkipClientIDCheck: true}), nil +} diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go new file mode 100644 index 0000000..8eacfae --- /dev/null +++ b/internal/auth/oidc_test.go @@ -0,0 +1,210 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "git.unkin.net/unkin/encapi/internal/testsupport" +) + +func claims(iss string) jwt.Claims { + now := time.Now() + return jwt.Claims{ + Issuer: iss, + Subject: "ci", + Audience: jwt.Audience{"encapi"}, + IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)), + Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + } +} + +func hs256Token(t *testing.T, c jwt.Claims) string { + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: []byte("0123456789abcdef0123456789abcdef")}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "k1")) + if err != nil { + t.Fatal(err) + } + tok, err := jwt.Signed(sig).Claims(c).Serialize() + if err != nil { + t.Fatal(err) + } + return tok +} + +func noneToken(iss string) string { + enc := base64.RawURLEncoding.EncodeToString + return enc([]byte(`{"alg":"none","typ":"JWT","kid":"k1"}`)) + "." + + enc([]byte(`{"iss":"`+iss+`","sub":"ci","aud":"encapi","exp":`+strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)+`}`)) + "." +} + +func verify(b *BearerOIDC, tok string) error { + _, err := b.Verify(context.Background(), tok) + return err +} + +func TestVerify(t *testing.T) { + iss := testsupport.NewOIDCIssuer() + defer iss.Close() + b := NewBearerOIDC(iss.URL, []string{"encapi", "encapi-testing"}) + + tok, err := b.Verify(context.Background(), iss.Token(nil, "encapi-testing", "agent", time.Hour)) + if err != nil { + t.Fatalf("valid: %v", err) + } + if tok.Subject != "agent" || tok.Audience[0] != "encapi-testing" { + t.Errorf("token = %+v", tok) + } + + otherKey, _ := rsa.GenerateKey(rand.Reader, 2048) + bad := map[string]string{ + "garbage": "not.a.jwt", + "bad signature": iss.Token(otherKey, "encapi", "ci", time.Hour), + "expired": iss.Token(nil, "encapi", "ci", -time.Hour), + "wrong iss": iss.Sign(nil, claims("https://evil.example")), + "hs256": hs256Token(t, claims(iss.URL)), + "alg none": noneToken(iss.URL), + } + for name, tok := range bad { + if err := verify(b, tok); err == nil || errors.Is(err, ErrIssuerUnavailable) { + t.Errorf("%s: err = %v, want rejection", name, err) + } + } + if err := verify(b, iss.Token(nil, "nope", "ci", time.Hour)); !errors.Is(err, ErrAudience) { + t.Errorf("wrong aud: err = %v, want ErrAudience", err) + } +} + +func TestVerifyHangingIssuerBacksOff(t *testing.T) { + var hits atomic.Int32 + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + select { + case <-r.Context().Done(): + case <-release: + } + })) + defer srv.Close() + defer close(release) + + b := NewBearerOIDC(srv.URL, nil) + b.timeout = 100 * time.Millisecond + b.backoff = time.Hour + + start := time.Now() + if err := verify(b, "x.y.z"); !errors.Is(err, ErrIssuerUnavailable) { + t.Fatalf("hanging issuer: err = %v", err) + } + if d := time.Since(start); d > 2*time.Second { + t.Fatalf("hanging issuer: took %s, timeout not applied", d) + } + if err := verify(b, "x.y.z"); !errors.Is(err, ErrIssuerUnavailable) { + t.Fatalf("during backoff: err = %v", err) + } + if n := hits.Load(); n != 1 { + t.Fatalf("issuer hit %d times, want 1", n) + } +} + +func TestVerifyPanickingDiscoveryBacksOff(t *testing.T) { + b := NewBearerOIDC("http://issuer.invalid", nil) + b.backoff = time.Hour + b.discover = func(context.Context, string) (*oidc.Provider, error) { panic("boom") } + + func() { + defer func() { _ = recover() }() + _ = verify(b, "x.y.z") + }() + + got := make(chan error, 1) + go func() { got <- verify(b, "x.y.z") }() + select { + case err := <-got: + if !errors.Is(err, ErrIssuerUnavailable) { + t.Fatalf("after panic: err = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("after panic: request hung on stale inflight discovery") + } +} + +func TestVerifyConcurrentFirstRequestsWaitForDiscovery(t *testing.T) { + iss := testsupport.NewOIDCIssuer() + defer iss.Close() + var hits atomic.Int32 + inner := iss.Config.Handler + iss.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/openid-configuration" { + hits.Add(1) + time.Sleep(200 * time.Millisecond) + } + inner.ServeHTTP(w, r) + }) + + b := NewBearerOIDC(iss.URL, nil) + tok := iss.Token(nil, "encapi", "ci", time.Hour) + var wg sync.WaitGroup + errs := make([]error, 20) + for i := range errs { + wg.Add(1) + go func() { + defer wg.Done() + errs[i] = verify(b, tok) + }() + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Errorf("request %d: %v", i, err) + } + } + if n := hits.Load(); n != 1 { + t.Fatalf("discovery hit %d times, want 1", n) + } +} + +func TestVerifyRetriesAfterBackoff(t *testing.T) { + iss := testsupport.NewOIDCIssuer() + defer iss.Close() + b := NewBearerOIDC(iss.URL, nil) + b.failedAt = time.Now() + b.backoff = 50 * time.Millisecond + tok := iss.Token(nil, "encapi", "ci", time.Hour) + if err := verify(b, tok); !errors.Is(err, ErrIssuerUnavailable) { + t.Fatalf("within backoff: err = %v", err) + } + time.Sleep(60 * time.Millisecond) + if err := verify(b, tok); err != nil { + t.Fatalf("after backoff: %v", err) + } +} + +func TestVerifyIssuerDown(t *testing.T) { + iss := testsupport.NewOIDCIssuer() + b := NewBearerOIDC(iss.URL, []string{"encapi"}) + tok := iss.Token(nil, "encapi", "ci", time.Hour) + iss.Close() + if err := verify(b, tok); !errors.Is(err, ErrIssuerUnavailable) { + t.Errorf("issuer down: err = %v", err) + } +} + +func TestDisabled(t *testing.T) { + if NewBearerOIDC("", nil) != nil { + t.Error("empty issuer must disable OIDC") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1e1a781..1986fe9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -37,6 +37,7 @@ Usage: encapi-cli node list encapi-cli node get encapi-cli node set --role --env [--param k=v ...] + encapi-cli node set-env encapi-cli node delete encapi-cli role list encapi-cli role get @@ -55,7 +56,7 @@ Params: Environment: ENCAPI_URL encapi base URL (default http://localhost:8000) - ENCAPI_WRITE_TOKEN bearer token, required for writes + ENCAPI_WRITE_TOKEN bearer token (static token or Vault identity JWT), required for writes ` // Run executes the CLI and returns a process exit code. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 70a8282..5e82e66 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -115,7 +115,7 @@ func run(t *testing.T, env Env, args ...string) (int, string, string) { func serverEnv(t *testing.T) (Env, *memStore) { t.Helper() store := newMem() - srv := server.New(store, distro.Noop{}, "tok") + srv := server.New(store, distro.Noop{}, server.Auth{WriteToken: "tok"}) ts := httptest.NewServer(srv.Router()) t.Cleanup(ts.Close) return Env{URL: ts.URL, Token: "tok"}, store @@ -158,6 +158,22 @@ func TestNodeSetAndClassify(t *testing.T) { } } +func TestNodeSetEnvKeepsRole(t *testing.T) { + env, store := serverEnv(t) + if code, _, errb := run(t, env, "node", "set", "web1.example", "--role", "roles::base", "--env", "production"); code != 0 { + t.Fatalf("node set exit %d: %s", code, errb) + } + if code, _, errb := run(t, env, "node", "set-env", "web1.example", "testing"); code != 0 { + t.Fatalf("set-env exit %d: %s", code, errb) + } + if n := store.nodes["web1.example"]; n.Environment != "testing" || n.Role != "roles::base" { + t.Errorf("node = %+v", n) + } + if code, _, _ := run(t, env, "node", "set-env", "ghost", "testing"); code == 0 { + t.Error("set-env on unknown node should fail") + } +} + func TestNodeSetRequiresFlags(t *testing.T) { env, _ := serverEnv(t) code, _, _ := run(t, env, "node", "set", "h") diff --git a/internal/cli/resources.go b/internal/cli/resources.go index 722908d..cdcbbb6 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -12,7 +12,7 @@ import ( func nodeCmd(ctx context.Context, c *client.Client, args []string, stdout, stderr io.Writer) int { if len(args) < 1 { - fmt.Fprintln(stderr, "usage: encapi-cli node ...") + fmt.Fprintln(stderr, "usage: encapi-cli node ...") return 2 } switch args[0] { @@ -36,6 +36,17 @@ func nodeCmd(ctx context.Context, c *client.Client, args []string, stdout, stder return 0 case "set": return nodeSet(ctx, c, args[1:], stdout, stderr) + case "set-env": + if len(args) != 3 { + fmt.Fprintln(stderr, "usage: encapi-cli node set-env ") + return 2 + } + n, err := c.SetNodeEnvironment(ctx, args[1], args[2]) + if err != nil { + return fail(stderr, err) + } + printYAML(stdout, n) + return 0 case "delete": if len(args) != 2 { fmt.Fprintln(stderr, "usage: encapi-cli node delete ") diff --git a/internal/config/config.go b/internal/config/config.go index bb53117..1adf23e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,13 @@ type Config struct { // When empty, writes are refused entirely (fail-closed). WriteToken string + // OIDCIssuer enables Vault identity JWTs as write credentials. Tokens + // with OIDCAudience get full write access; tokens with + // OIDCTestingAudience may only set a node's environment to "testing". + OIDCIssuer string + OIDCAudience string + OIDCTestingAudience string + // DistroAPIURL, when set, points encapi at an external kickstart/distro // API that resolves per-host provisioning params (epel, os release, ...). // Left empty, no distro params are injected into ENC output. @@ -54,6 +61,10 @@ func Load() (*Config, error) { DBSSL: getenv("DBSSL", "disable"), WriteToken: os.Getenv("ENCAPI_WRITE_TOKEN"), DistroAPIURL: os.Getenv("ENCAPI_DISTRO_API_URL"), + + OIDCIssuer: os.Getenv("ENCAPI_OIDC_ISSUER"), + OIDCAudience: getenv("ENCAPI_OIDC_AUDIENCE", "encapi"), + OIDCTestingAudience: getenv("ENCAPI_OIDC_TESTING_AUDIENCE", "encapi-testing"), }, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 776ded3..2267712 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,7 +3,7 @@ package config import "testing" func TestLoadDefaults(t *testing.T) { - for _, k := range []string{"LISTEN_ADDR", "DBHOST", "DBPORT", "DBUSER", "DBPASS", "DBNAME", "DBSSL", "ENCAPI_WRITE_TOKEN", "ENCAPI_DISTRO_API_URL"} { + for _, k := range []string{"LISTEN_ADDR", "DBHOST", "DBPORT", "DBUSER", "DBPASS", "DBNAME", "DBSSL", "ENCAPI_WRITE_TOKEN", "ENCAPI_DISTRO_API_URL", "ENCAPI_OIDC_ISSUER", "ENCAPI_OIDC_AUDIENCE", "ENCAPI_OIDC_TESTING_AUDIENCE"} { t.Setenv(k, "") } c, err := Load() @@ -19,6 +19,9 @@ func TestLoadDefaults(t *testing.T) { if c.WriteToken != "" { t.Errorf("WriteToken = %q, want empty", c.WriteToken) } + if c.OIDCIssuer != "" || c.OIDCAudience != "encapi" || c.OIDCTestingAudience != "encapi-testing" { + t.Errorf("OIDC defaults = %q %q %q", c.OIDCIssuer, c.OIDCAudience, c.OIDCTestingAudience) + } } func TestLoadOverrides(t *testing.T) { diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go new file mode 100644 index 0000000..caee576 --- /dev/null +++ b/internal/server/auth_test.go @@ -0,0 +1,113 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.unkin.net/unkin/encapi/internal/auth" + "git.unkin.net/unkin/encapi/internal/testsupport" + "git.unkin.net/unkin/encapi/pkg/models" +) + +func oidcServer(t *testing.T) (*httptest.Server, *fakeStore, *testsupport.OIDCIssuer) { + t.Helper() + iss := testsupport.NewOIDCIssuer() + t.Cleanup(iss.Close) + f := newFake() + f.nodes["h1.example"] = models.Node{Certname: "h1.example", Role: "roles::base", Environment: "production", Params: map[string]any{"k": "v"}} + srv := New(f, nil, Auth{ + WriteToken: "s3cret", + OIDC: auth.NewBearerOIDC(iss.URL, []string{"encapi", "encapi-testing"}), + Audience: "encapi", + TestingAudience: "encapi-testing", + }) + ts := httptest.NewServer(srv.Router()) + t.Cleanup(ts.Close) + return ts, f, iss +} + +func put(t *testing.T, url, token, body string) int { + t.Helper() + req, _ := http.NewRequest(http.MethodPut, url, strings.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp.StatusCode +} + +func TestOIDCWriteScopes(t *testing.T) { + ts, f, iss := oidcServer(t) + full := iss.Token(nil, "encapi", "terraform-enc", time.Hour) + testingTok := iss.Token(nil, "encapi-testing", "agent", time.Hour) + other := iss.Token(nil, "artifactapi", "x", time.Hour) + nodeURL := ts.URL + "/api/v1/nodes/h1.example" + envURL := nodeURL + "/environment" + nodeBody := `{"role":"roles::base","environment":"develop"}` + + cases := []struct { + name, url, token, body string + want int + }{ + {"full aud PUT node", ts.URL + "/api/v1/nodes/h2", full, nodeBody, http.StatusOK}, + {"full aud PUT role", ts.URL + "/api/v1/roles/r", full, `{}`, http.StatusOK}, + {"full aud set develop", envURL, full, `{"environment":"develop"}`, http.StatusOK}, + {"static token set develop", envURL, "s3cret", `{"environment":"develop"}`, http.StatusOK}, + {"static token PUT node", ts.URL + "/api/v1/nodes/h3", "s3cret", nodeBody, http.StatusOK}, + {"testing aud set testing", envURL, testingTok, `{"environment":"testing"}`, http.StatusOK}, + {"testing aud set develop", envURL, testingTok, `{"environment":"develop"}`, http.StatusForbidden}, + {"testing aud PUT node", nodeURL, testingTok, nodeBody, http.StatusForbidden}, + {"testing aud PUT role", ts.URL + "/api/v1/roles/r", testingTok, `{}`, http.StatusForbidden}, + {"other aud set testing", envURL, other, `{"environment":"testing"}`, http.StatusUnauthorized}, + {"no token set testing", envURL, "", `{"environment":"testing"}`, http.StatusUnauthorized}, + {"unknown node", ts.URL + "/api/v1/nodes/ghost/environment", full, `{"environment":"testing"}`, http.StatusNotFound}, + {"empty env", envURL, full, `{}`, http.StatusBadRequest}, + } + for _, c := range cases { + if got := put(t, c.url, c.token, c.body); got != c.want { + t.Errorf("%s: status %d, want %d", c.name, got, c.want) + } + } + + req, _ := http.NewRequest(http.MethodDelete, nodeURL, nil) + req.Header.Set("Authorization", "Bearer "+testingTok) + resp, _ := http.DefaultClient.Do(req) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("testing aud DELETE node: status %d, want 403", resp.StatusCode) + } + if _, ok := f.nodes["h1.example"]; !ok { + t.Fatal("node deleted by testing token") + } +} + +func TestSetEnvironmentPreservesRoleAndParams(t *testing.T) { + ts, f, iss := oidcServer(t) + tok := iss.Token(nil, "encapi-testing", "agent", time.Hour) + if got := put(t, ts.URL+"/api/v1/nodes/h1.example/environment", tok, `{"environment":"testing"}`); got != http.StatusOK { + t.Fatalf("status %d", got) + } + n := f.nodes["h1.example"] + if n.Environment != "testing" || n.Role != "roles::base" || n.Params["k"] != "v" { + t.Errorf("node = %+v", n) + } +} + +func TestOIDCIssuerDown503(t *testing.T) { + ts, _, iss := oidcServer(t) + tok := iss.Token(nil, "encapi", "x", time.Hour) + iss.Close() + if got := put(t, ts.URL+"/api/v1/nodes/h1.example/environment", tok, `{"environment":"testing"}`); got != http.StatusServiceUnavailable { + t.Errorf("status %d, want 503", got) + } + if got := put(t, ts.URL+"/api/v1/nodes/h1.example/environment", "s3cret", `{"environment":"testing"}`); got != http.StatusOK { + t.Errorf("static token with issuer down: status %d, want 200", got) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 1d6313d..6483b5d 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -214,6 +214,37 @@ func (s *Server) putNode(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, node) } +// putNodeEnvironment changes only a node's environment. Testing-scoped tokens +// may only move a node to "testing". +func (s *Server) putNodeEnvironment(w http.ResponseWriter, r *http.Request) { + var body struct { + Environment string `json:"environment"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if body.Environment == "" { + writeError(w, http.StatusBadRequest, "environment is required") + return + } + if r.Context().Value(scopeKey{}) != scopeFull && body.Environment != "testing" { + writeError(w, http.StatusForbidden, "token may only set environment to testing") + return + } + node, err := s.store.GetNode(r.Context(), chi.URLParam(r, "certname")) + if err != nil { + mapErr(w, err) + return + } + node.Environment = body.Environment + if err := s.store.UpsertNode(r.Context(), node); err != nil { + mapErr(w, err) + return + } + writeJSON(w, http.StatusOK, node) +} + func (s *Server) deleteNode(w http.ResponseWriter, r *http.Request) { if err := s.store.DeleteNode(r.Context(), chi.URLParam(r, "certname")); err != nil { mapErr(w, err) diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 243eb5d..bbc56ee 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -1,31 +1,80 @@ package server import ( + "context" "crypto/subtle" + "errors" "log/slog" "net/http" + "slices" "strings" "time" "github.com/go-chi/chi/v5/middleware" + + "git.unkin.net/unkin/encapi/internal/auth" ) -// requireToken enforces a static bearer token on mutating endpoints. The token -// is accepted either as "Authorization: Bearer " or a bare "token" -// header. When no server token is configured, all writes are refused. -func (s *Server) requireToken(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.writeToken == "" { - writeError(w, http.StatusServiceUnavailable, "writes disabled: ENCAPI_WRITE_TOKEN not set") - return +type scope int + +const ( + scopeTesting scope = iota + 1 + scopeFull +) + +type scopeKey struct{} + +// requireWrite authenticates a write: the static token or a JWT with the full +// audience grants scopeFull, a JWT with the testing audience grants +// scopeTesting, which only routes registered with allowTesting accept. +func (s *Server) requireWrite(allowTesting bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sc, ok := s.authenticate(w, r) + if !ok { + return + } + if sc == scopeTesting && !allowTesting { + writeError(w, http.StatusForbidden, "token audience not permitted on this route") + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), scopeKey{}, sc))) + }) + } +} + +func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (scope, bool) { + a := s.auth + if a.WriteToken == "" && a.OIDC == nil { + writeError(w, http.StatusServiceUnavailable, "writes disabled: neither ENCAPI_WRITE_TOKEN nor ENCAPI_OIDC_ISSUER set") + return 0, false + } + presented := bearer(r) + if presented == "" { + writeError(w, http.StatusUnauthorized, "invalid or missing write token") + return 0, false + } + if a.WriteToken != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(a.WriteToken)) == 1 { + return scopeFull, true + } + if a.OIDC != nil { + tok, err := a.OIDC.Verify(r.Context(), presented) + if errors.Is(err, auth.ErrIssuerUnavailable) { + writeError(w, http.StatusServiceUnavailable, "token issuer unavailable") + return 0, false } - presented := bearer(r) - if presented == "" || subtle.ConstantTimeCompare([]byte(presented), []byte(s.writeToken)) != 1 { - writeError(w, http.StatusUnauthorized, "invalid or missing write token") - return + if err == nil { + sc := scopeTesting + if slices.Contains(tok.Audience, a.Audience) { + sc = scopeFull + } + slog.Info("bearer token accepted", "sub", tok.Subject, "aud", tok.Audience, "method", r.Method, "path", r.URL.Path) + return sc, true } - next.ServeHTTP(w, r) - }) + slog.Info("bearer token rejected", "error", err) + } + writeError(w, http.StatusUnauthorized, "invalid or missing write token") + return 0, false } func bearer(r *http.Request) string { diff --git a/internal/server/server.go b/internal/server/server.go index 6f2ebf4..f68e779 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "git.unkin.net/unkin/encapi/internal/auth" "git.unkin.net/unkin/encapi/internal/distro" "git.unkin.net/unkin/encapi/pkg/models" ) @@ -34,20 +35,28 @@ type Store interface { DeleteNode(ctx context.Context, certname string) error } -// Server holds handler dependencies. -type Server struct { - store Store - resolver distro.Resolver - writeToken string +// Auth configures write authentication. With neither WriteToken nor OIDC set, +// all writes fail closed. OIDC must be built with both audiences allowed. +type Auth struct { + WriteToken string + OIDC *auth.BearerOIDC + Audience string + TestingAudience string } -// New builds a Server. writeToken guards mutating endpoints; an empty token -// fails all writes closed. -func New(store Store, resolver distro.Resolver, writeToken string) *Server { +// Server holds handler dependencies. +type Server struct { + store Store + resolver distro.Resolver + auth Auth +} + +// New builds a Server. +func New(store Store, resolver distro.Resolver, a Auth) *Server { if resolver == nil { resolver = distro.Noop{} } - return &Server{store: store, resolver: resolver, writeToken: writeToken} + return &Server{store: store, resolver: resolver, auth: a} } // Router returns the fully-wired HTTP handler. @@ -75,8 +84,9 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/nodes/{certname}", s.getNode) // --- writes (token-guarded) --- + r.With(s.requireWrite(true)).Put("/api/v1/nodes/{certname}/environment", s.putNodeEnvironment) r.Group(func(r chi.Router) { - r.Use(s.requireToken) + r.Use(s.requireWrite(false)) r.Put("/api/v1/roles/{name}", s.putRole) r.Delete("/api/v1/roles/{name}", s.deleteRole) r.Put("/api/v1/statuses/{name}", s.putStatus) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index cfce54e..3cdd3c8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -111,7 +111,7 @@ func testServer() (*httptest.Server, *fakeStore) { f.statuses["production"] = models.Status{Name: "production"} f.roles["roles::infra::storage::vault"] = models.Role{Name: "roles::infra::storage::vault"} f.nodes["h1.example"] = models.Node{Certname: "h1.example", Role: "roles::infra::storage::vault", Environment: "testing"} - srv := New(f, nil, "s3cret") + srv := New(f, nil, Auth{WriteToken: "s3cret"}) return httptest.NewServer(srv.Router()), f } @@ -164,7 +164,7 @@ func TestWriteBareTokenHeader(t *testing.T) { func TestWritesDisabledWithoutServerToken(t *testing.T) { f := newFake() - srv := New(f, nil, "") // no token configured + srv := New(f, nil, Auth{}) // no auth configured ts := httptest.NewServer(srv.Router()) defer ts.Close() req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/roles/r", strings.NewReader(`{}`)) diff --git a/internal/testsupport/oidc.go b/internal/testsupport/oidc.go new file mode 100644 index 0000000..0acf546 --- /dev/null +++ b/internal/testsupport/oidc.go @@ -0,0 +1,73 @@ +package testsupport + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// OIDCIssuer is a minimal OIDC issuer serving discovery and JWKS, signing +// RS256 tokens with a generated key. +type OIDCIssuer struct { + *httptest.Server + key *rsa.PrivateKey +} + +func NewOIDCIssuer() *OIDCIssuer { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + iss := &OIDCIssuer{key: key} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": iss.URL, + "jwks_uri": iss.URL + "/keys", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ + {Key: &key.PublicKey, KeyID: "k1", Algorithm: "RS256", Use: "sig"}, + }}) + }) + iss.Server = httptest.NewServer(mux) + return iss +} + +// Token signs a token for aud and sub. Pass a nil key to use the issuer's own; +// expiry is relative to now. +func (o *OIDCIssuer) Token(key *rsa.PrivateKey, aud, sub string, expiry time.Duration) string { + now := time.Now() + return o.Sign(key, jwt.Claims{ + Issuer: o.URL, + Subject: sub, + Audience: jwt.Audience{aud}, + IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)), + Expiry: jwt.NewNumericDate(now.Add(expiry)), + }) +} + +// Sign signs arbitrary claims with RS256. Pass a nil key to use the issuer's own. +func (o *OIDCIssuer) Sign(key *rsa.PrivateKey, claims jwt.Claims) string { + if key == nil { + key = o.key + } + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "k1")) + if err != nil { + panic(err) + } + tok, err := jwt.Signed(sig).Claims(claims).Serialize() + if err != nil { + panic(err) + } + return tok +} diff --git a/pkg/client/client.go b/pkg/client/client.go index cbe6bcc..374fd51 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -169,6 +169,11 @@ func (c *Client) PutNode(ctx context.Context, n *models.Node) (*models.Node, err var out models.Node return &out, c.do(ctx, http.MethodPut, "/api/v1/nodes/"+url.PathEscape(n.Certname), n, &out) } +func (c *Client) SetNodeEnvironment(ctx context.Context, certname, environment string) (*models.Node, error) { + var out models.Node + body := map[string]string{"environment": environment} + return &out, c.do(ctx, http.MethodPut, "/api/v1/nodes/"+url.PathEscape(certname)+"/environment", body, &out) +} func (c *Client) DeleteNode(ctx context.Context, certname string) error { return c.do(ctx, http.MethodDelete, "/api/v1/nodes/"+url.PathEscape(certname), nil, nil) }