Scaffold vault-plugin-secrets-arrstack engine
Mint dynamic arrproxy machine tokens via arrproxy's bearer-gated admin API so Terraform-driven *arr onboarding can issue and revoke per-role tokens non-interactively. - Add backend, config, roles, creds paths and the arrstack_token secret - Call POST/DELETE /api/admin/tokens with a vault:arrstack:<role> subject - Enforce apps as a non-empty subset of sonarr/radarr/prowlarr - Cap lease renewal at the arrproxy token's fixed expiry - Add table-driven unit tests against a fake arrproxy admin server - Add Makefile, nfpm packaging, and pre-commit/build/test/release pipelines
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package arrstack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
func createRole(t *testing.T, b *arrstackBackend, s logical.Storage, name string, data map[string]interface{}) {
|
||||
t.Helper()
|
||||
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||
Operation: logical.CreateOperation,
|
||||
Path: "roles/" + name,
|
||||
Storage: s,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil || (resp != nil && resp.IsError()) {
|
||||
t.Fatalf("create role %s: err=%v resp=%v", name, err, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentials_MintAndRevoke(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
ctx := context.Background()
|
||||
m := newMockArrproxy(t)
|
||||
|
||||
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
|
||||
createRole(t, b, s, "media", map[string]interface{}{
|
||||
"apps": "sonarr,radarr",
|
||||
"ttl": "1h",
|
||||
"max_ttl": "24h",
|
||||
})
|
||||
|
||||
resp, err := b.HandleRequest(ctx, &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "creds/media",
|
||||
Storage: s,
|
||||
})
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
|
||||
}
|
||||
if resp.Secret == nil {
|
||||
t.Fatal("expected a secret in the response")
|
||||
}
|
||||
token, _ := resp.Data["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatal("expected a non-empty token in response data")
|
||||
}
|
||||
if resp.Data["id"].(string) == "" {
|
||||
t.Fatal("expected a non-empty id in response data")
|
||||
}
|
||||
if resp.Secret.TTL != time.Hour {
|
||||
t.Fatalf("expected TTL 1h, got %s", resp.Secret.TTL)
|
||||
}
|
||||
if !resp.Secret.Renewable {
|
||||
t.Fatal("expected the lease to be renewable")
|
||||
}
|
||||
if m.tokenCount() != 1 {
|
||||
t.Fatalf("expected 1 token on server, got %d", m.tokenCount())
|
||||
}
|
||||
|
||||
// The subject carries the required prefix and the role name.
|
||||
if m.lastRequest.Subject != "vault:arrstack:media" {
|
||||
t.Fatalf("expected subject vault:arrstack:media, got %q", m.lastRequest.Subject)
|
||||
}
|
||||
// The role's apps (sorted) were forwarded.
|
||||
if strings.Join(m.lastRequest.Apps, ",") != "radarr,sonarr" {
|
||||
t.Fatalf("expected apps radarr,sonarr forwarded, got %v", m.lastRequest.Apps)
|
||||
}
|
||||
// ttl_seconds is derived from the effective initial lease TTL.
|
||||
if m.lastRequest.TTLSeconds != 3600 {
|
||||
t.Fatalf("expected ttl_seconds 3600, got %d", m.lastRequest.TTLSeconds)
|
||||
}
|
||||
// The label is prefixed for attribution.
|
||||
if !strings.HasPrefix(m.lastRequest.Label, "vault-media-") {
|
||||
t.Fatalf("expected label prefixed vault-media-, got %q", m.lastRequest.Label)
|
||||
}
|
||||
|
||||
revokeReq := &logical.Request{
|
||||
Operation: logical.RevokeOperation,
|
||||
Path: "creds/media",
|
||||
Storage: s,
|
||||
Secret: resp.Secret,
|
||||
}
|
||||
if _, err := b.HandleRequest(ctx, revokeReq); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if m.tokenCount() != 0 {
|
||||
t.Fatalf("expected token disabled on revoke, got %d active", m.tokenCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentials_UnknownRole(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
m := newMockArrproxy(t)
|
||||
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
|
||||
|
||||
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "creds/nope",
|
||||
Storage: s,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatal("expected an error response for an unknown role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentials_NotConfigured(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h"})
|
||||
|
||||
_, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "creds/media",
|
||||
Storage: s,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when backend is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentials_TTLClampedToMaxTTL(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
m := newMockArrproxy(t)
|
||||
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
|
||||
|
||||
createRole(t, b, s, "short", map[string]interface{}{"apps": "prowlarr", "max_ttl": "2h"})
|
||||
|
||||
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "creds/short",
|
||||
Storage: s,
|
||||
})
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
|
||||
}
|
||||
if resp.Secret.TTL <= 0 || resp.Secret.TTL > 2*time.Hour {
|
||||
t.Fatalf("expected TTL within (0, 2h], got %s", resp.Secret.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentials_RenewCappedAtTokenExpiry(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
ctx := context.Background()
|
||||
m := newMockArrproxy(t)
|
||||
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
|
||||
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h", "max_ttl": "24h"})
|
||||
|
||||
resp, err := b.HandleRequest(ctx, &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "creds/media",
|
||||
Storage: s,
|
||||
})
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
|
||||
}
|
||||
|
||||
renewResp, err := b.HandleRequest(ctx, &logical.Request{
|
||||
Operation: logical.RenewOperation,
|
||||
Path: "creds/media",
|
||||
Storage: s,
|
||||
Secret: resp.Secret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renew: %v", err)
|
||||
}
|
||||
if renewResp == nil || renewResp.Secret == nil {
|
||||
t.Fatal("expected a secret in the renew response")
|
||||
}
|
||||
// The token's fixed expiry is ~1h out, so the renewed lease can never
|
||||
// exceed that remaining window even though max_ttl is 24h.
|
||||
if renewResp.Secret.TTL <= 0 || renewResp.Secret.TTL > time.Hour {
|
||||
t.Fatalf("expected renewed TTL capped within (0, 1h], got %s", renewResp.Secret.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentials_RenewExpiredTokenNotExtended(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
ctx := context.Background()
|
||||
m := newMockArrproxy(t)
|
||||
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
|
||||
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h", "max_ttl": "24h"})
|
||||
|
||||
resp, err := b.HandleRequest(ctx, &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "creds/media",
|
||||
Storage: s,
|
||||
})
|
||||
if err != nil || resp == nil {
|
||||
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
|
||||
}
|
||||
|
||||
// Simulate the arrproxy token having already reached its fixed expiry.
|
||||
resp.Secret.InternalData["expires_at"] = time.Now().Add(-time.Minute).Format(time.RFC3339)
|
||||
|
||||
renewResp, err := b.HandleRequest(ctx, &logical.Request{
|
||||
Operation: logical.RenewOperation,
|
||||
Path: "creds/media",
|
||||
Storage: s,
|
||||
Secret: resp.Secret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renew: %v", err)
|
||||
}
|
||||
if renewResp.Secret.TTL != 0 {
|
||||
t.Fatalf("expected an expired token not to be extended, got TTL %s", renewResp.Secret.TTL)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user