Files
unkin-agent b1b11330a6
ci/woodpecker/tag/release Pipeline was successful
Add per-role HTTP method scoping to minted tokens (#2)
## Why

Every token this engine mints is as powerful as the apps it can reach, so a read-only integration can still write to the *arr. arrproxy now accepts a method scope at mint time, and the engine has no way to ask for one.

## How

- Add an optional `methods` role field, uppercase-normalized and de-duplicated.
- Reject a method outside GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS at role write.
- Forward the role's scope on the arrproxy mint request and echo it in the creds response.
- Omit the field when a role has no scope, so unscoped roles behave exactly as before.
- Cover normalization, rejection, pass-through and the unscoped case.

Requires arrproxy >= v0.5.0 deployed.

Reviewed-on: #2
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 14:45:04 +10:00

272 lines
8.2 KiB
Go

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_MethodsForwardedAndEchoed(t *testing.T) {
b, s := getTestBackend(t)
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "readonly", map[string]interface{}{
"apps": "sonarr",
"methods": "head,get",
"ttl": "1h",
})
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/readonly",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
// The role's normalized method scope reached arrproxy.
if strings.Join(m.lastRequest.Methods, ",") != "GET,HEAD" {
t.Fatalf("expected methods GET,HEAD forwarded, got %v", m.lastRequest.Methods)
}
// And is echoed back to the caller alongside apps/subject.
if strings.Join(resp.Data["methods"].([]string), ",") != "GET,HEAD" {
t.Fatalf("expected methods echoed in creds response, got %v", resp.Data["methods"])
}
}
func TestCredentials_NoMethodsOmitsTheField(t *testing.T) {
b, s := getTestBackend(t)
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h"})
resp, err := b.HandleRequest(context.Background(), &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)
}
// An unscoped role sends no methods key at all, so an arrproxy predating
// method scoping sees the request exactly as before.
if strings.Contains(string(m.lastBody), "methods") {
t.Fatalf("expected methods omitted from the mint body, got %s", m.lastBody)
}
if len(m.lastRequest.Methods) != 0 {
t.Fatalf("expected no methods forwarded, got %v", m.lastRequest.Methods)
}
if methods, ok := resp.Data["methods"].([]string); ok && len(methods) != 0 {
t.Fatalf("expected an empty method scope in the response, got %v", methods)
}
}
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)
}
}