b4b8915d3e
A Vault/OpenBao secrets engine that manages BIND TSIG keys via the bind-operator companion API (Vault -> HTTP API -> BindTSIGKey CRs). - backend + cmd entry point (plugin.ServeMultiplex), modelled on vault-plugin-secrets-litellm - config path: companion API url/token/tls + defaults - static-roles/static-creds: stable named key with managed rotation - roles/creds: dynamic, lease-bound keys (revoke deletes the CR) - tsig_key secret type with revoke/renew - HTTP client for the companion API contract (/v1/keys CRUD + rotate) - Makefile, Woodpecker CI (pre-commit/build/test + tag release RPMs), nfpm packaging (vault + openbao flavours) - e2e: mock companion API + Vault + OpenBao in docker-compose, full lifecycle per engine; unit tests for the dynamic + static flows
157 lines
4.5 KiB
Go
157 lines
4.5 KiB
Go
package bindtsig
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
func getTestBackend(t *testing.T) (*bindTSIGBackend, logical.Storage) {
|
|
t.Helper()
|
|
config := logical.TestBackendConfig()
|
|
config.StorageView = &logical.InmemStorage{}
|
|
config.System = logical.TestSystemView()
|
|
|
|
b, err := Factory(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("creating backend: %v", err)
|
|
}
|
|
return b.(*bindTSIGBackend), config.StorageView
|
|
}
|
|
|
|
// mockAPI is an in-memory companion API implementing the /v1/keys contract.
|
|
type mockAPI struct {
|
|
server *httptest.Server
|
|
mu sync.Mutex
|
|
keys map[string]*tsigKey
|
|
}
|
|
|
|
func newMockAPI(t *testing.T) *mockAPI {
|
|
t.Helper()
|
|
m := &mockAPI{keys: map[string]*tsigKey{}}
|
|
m.server = httptest.NewServer(http.HandlerFunc(m.handle))
|
|
t.Cleanup(m.server.Close)
|
|
return m
|
|
}
|
|
|
|
func (m *mockAPI) handle(w http.ResponseWriter, r *http.Request) {
|
|
name := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/keys"), "/")
|
|
rotate := strings.HasSuffix(name, "/rotate")
|
|
name = strings.TrimSuffix(name, "/rotate")
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
switch {
|
|
case r.Method == http.MethodPost && name == "":
|
|
var req tsigKey
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
k := &tsigKey{Name: req.Name, Algorithm: "hmac-sha256", Secret: "s3cr3t-" + req.Name, KeyName: req.Name, ClusterRef: req.ClusterRef}
|
|
m.keys[req.Name] = k
|
|
_ = json.NewEncoder(w).Encode(k)
|
|
case r.Method == http.MethodPost && rotate:
|
|
k := m.keys[name]
|
|
if k == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
k.Secret = k.Secret + "-rot"
|
|
_ = json.NewEncoder(w).Encode(k)
|
|
case r.Method == http.MethodGet:
|
|
k := m.keys[name]
|
|
if k == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(k)
|
|
case r.Method == http.MethodDelete:
|
|
delete(m.keys, name)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
func TestDynamicCredentialLifecycle(t *testing.T) {
|
|
b, s := getTestBackend(t)
|
|
ctx := context.Background()
|
|
m := newMockAPI(t)
|
|
|
|
// config
|
|
if _, err := b.HandleRequest(ctx, &logical.Request{
|
|
Operation: logical.CreateOperation, Path: "config", Storage: s,
|
|
Data: map[string]interface{}{"api_url": m.server.URL, "default_cluster_ref": "bind-authoritative"},
|
|
}); err != nil {
|
|
t.Fatalf("config: %v", err)
|
|
}
|
|
|
|
// role
|
|
if _, err := b.HandleRequest(ctx, &logical.Request{
|
|
Operation: logical.CreateOperation, Path: "roles/ephemeral", Storage: s,
|
|
Data: map[string]interface{}{"ttl": "1h", "max_ttl": "24h"},
|
|
}); err != nil {
|
|
t.Fatalf("role: %v", err)
|
|
}
|
|
|
|
// mint
|
|
resp, err := b.HandleRequest(ctx, &logical.Request{Operation: logical.ReadOperation, Path: "creds/ephemeral", Storage: s})
|
|
if err != nil || resp == nil || resp.Secret == nil {
|
|
t.Fatalf("creds: err=%v resp=%v", err, resp)
|
|
}
|
|
name := resp.Data["name"].(string)
|
|
if !strings.HasPrefix(name, "dyn-ephemeral-") {
|
|
t.Fatalf("unexpected dynamic name %q", name)
|
|
}
|
|
if _, ok := m.keys[name]; !ok {
|
|
t.Fatalf("key %q not created in companion API", name)
|
|
}
|
|
if resp.Data["secret"].(string) == "" {
|
|
t.Fatal("empty secret returned")
|
|
}
|
|
|
|
// revoke -> deleted
|
|
if _, err := b.HandleRequest(ctx, &logical.Request{
|
|
Operation: logical.RevokeOperation, Path: "creds/ephemeral", Storage: s, Secret: resp.Secret,
|
|
}); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if _, ok := m.keys[name]; ok {
|
|
t.Fatalf("key %q still present after revoke", name)
|
|
}
|
|
}
|
|
|
|
func TestStaticRoleProvisionAndRead(t *testing.T) {
|
|
b, s := getTestBackend(t)
|
|
ctx := context.Background()
|
|
m := newMockAPI(t)
|
|
|
|
if _, err := b.HandleRequest(ctx, &logical.Request{
|
|
Operation: logical.CreateOperation, Path: "config", Storage: s,
|
|
Data: map[string]interface{}{"api_url": m.server.URL},
|
|
}); err != nil {
|
|
t.Fatalf("config: %v", err)
|
|
}
|
|
if _, err := b.HandleRequest(ctx, &logical.Request{
|
|
Operation: logical.CreateOperation, Path: "static-roles/client-update", Storage: s,
|
|
Data: map[string]interface{}{"rotation_period": "720h"},
|
|
}); err != nil {
|
|
t.Fatalf("static-role: %v", err)
|
|
}
|
|
if _, ok := m.keys["client-update"]; !ok {
|
|
t.Fatal("static key not provisioned in companion API")
|
|
}
|
|
resp, err := b.HandleRequest(ctx, &logical.Request{Operation: logical.ReadOperation, Path: "static-creds/client-update", Storage: s})
|
|
if err != nil || resp == nil {
|
|
t.Fatalf("static-creds: err=%v resp=%v", err, resp)
|
|
}
|
|
if resp.Data["secret"].(string) == "" {
|
|
t.Fatal("static-creds returned empty secret")
|
|
}
|
|
if resp.Data["key_name"].(string) != "client-update" {
|
|
t.Fatalf("unexpected key_name %v", resp.Data["key_name"])
|
|
}
|
|
}
|