feat: accept Vault identity JWTs for writes, add set-environment route
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

This commit is contained in:
2026-10-23 21:35:57 +11:00
parent 835e207c9b
commit 8c1bfda4c5
19 changed files with 724 additions and 38 deletions
+1 -1
View File
@@ -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 ./...
+19 -4
View File
@@ -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 <token>`, 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. `<vault>/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:
+9 -3
View File
@@ -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)
+3
View File
@@ -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
+6
View File
@@ -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=
+123
View File
@@ -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
}
+210
View File
@@ -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")
}
}
+2 -1
View File
@@ -37,6 +37,7 @@ Usage:
encapi-cli node list
encapi-cli node get <certname>
encapi-cli node set <certname> --role <r> --env <e> [--param k=v ...]
encapi-cli node set-env <certname> <environment>
encapi-cli node delete <certname>
encapi-cli role list
encapi-cli role get <name>
@@ -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.
+17 -1
View File
@@ -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")
+12 -1
View File
@@ -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 <list|get|set|delete> ...")
fmt.Fprintln(stderr, "usage: encapi-cli node <list|get|set|set-env|delete> ...")
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 <certname> <environment>")
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 <certname>")
+11
View File
@@ -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
}
+4 -1
View File
@@ -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) {
+113
View File
@@ -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)
}
}
+31
View File
@@ -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)
+63 -14
View File
@@ -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 <token>" 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 {
+20 -10
View File
@@ -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)
+2 -2
View File
@@ -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(`{}`))
+73
View File
@@ -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
}
+5
View File
@@ -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)
}