feat: accept Vault identity JWTs for writes, add set-environment route
This commit is contained in:
@@ -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 ./...
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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>")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(`{}`))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user