Files
vault-plugin-secrets-arrstack/path_roles_test.go
T
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

313 lines
8.3 KiB
Go

package arrstack
import (
"context"
"strings"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
func TestRole_WriteReadListDelete(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/media",
Storage: s,
Data: map[string]interface{}{
"apps": "sonarr,radarr",
"ttl": "1h",
"max_ttl": "24h",
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "roles/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("read role: err=%v resp=%v", err, resp)
}
apps := resp.Data["apps"].([]string)
// Stored de-duplicated and sorted.
if len(apps) != 2 || apps[0] != "radarr" || apps[1] != "sonarr" {
t.Fatalf("unexpected apps: %v", apps)
}
if resp.Data["ttl"].(int64) != 3600 {
t.Fatalf("unexpected ttl: %v", resp.Data["ttl"])
}
if resp.Data["max_ttl"].(int64) != 86400 {
t.Fatalf("unexpected max_ttl: %v", resp.Data["max_ttl"])
}
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ListOperation,
Path: "roles/",
Storage: s,
})
if err != nil {
t.Fatalf("list roles: %v", err)
}
keys := resp.Data["keys"].([]string)
if len(keys) != 1 || keys[0] != "media" {
t.Fatalf("unexpected role list: %v", keys)
}
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.DeleteOperation,
Path: "roles/media",
Storage: s,
}); err != nil {
t.Fatalf("delete role: %v", err)
}
role, _ := b.getRole(ctx, s, "media")
if role != nil {
t.Fatal("expected role to be gone after delete")
}
}
func TestRole_AllThreeApps(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/all",
Storage: s,
Data: map[string]interface{}{"apps": "sonarr,radarr,prowlarr"},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
role, _ := b.getRole(ctx, s, "all")
if len(role.Apps) != 3 {
t.Fatalf("expected 3 apps, got %v", role.Apps)
}
}
func TestRole_UnknownAppRejected(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Storage: s,
Data: map[string]interface{}{"apps": "sonarr,lidarr"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error for an app outside sonarr/radarr/prowlarr")
}
}
func TestRole_EmptyAppsRejected(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/empty",
Storage: s,
Data: map[string]interface{}{"ttl": "1h"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error when apps is empty")
}
}
func TestRole_TTLGreaterThanMaxTTLRejected(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Storage: s,
Data: map[string]interface{}{
"apps": "sonarr",
"ttl": "48h",
"max_ttl": "1h",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error when ttl > max_ttl")
}
}
func TestRole_MethodsStoredNormalized(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/readonly",
Storage: s,
Data: map[string]interface{}{
"apps": "sonarr",
"methods": "get, head ,GET",
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "roles/readonly",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("read role: err=%v resp=%v", err, resp)
}
methods := resp.Data["methods"].([]string)
// Uppercased, de-duplicated and sorted.
if strings.Join(methods, ",") != "GET,HEAD" {
t.Fatalf("unexpected methods: %v", methods)
}
}
func TestRole_MethodsDefaultEmpty(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/unrestricted",
Storage: s,
Data: map[string]interface{}{"apps": "sonarr"},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
role, _ := b.getRole(ctx, s, "unrestricted")
if len(role.Methods) != 0 {
t.Fatalf("expected no method scope by default, got %v", role.Methods)
}
}
func TestRole_UnknownMethodRejected(t *testing.T) {
b, s := getTestBackend(t)
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Storage: s,
Data: map[string]interface{}{"apps": "sonarr", "methods": "GET,FETCH"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error for a method outside the known HTTP set")
}
role, _ := b.getRole(context.Background(), s, "bad")
if role != nil {
t.Fatal("expected the role not to be stored when methods are invalid")
}
}
func TestRole_MethodsClearedOnUpdate(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "methods": "GET"})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.UpdateOperation,
Path: "roles/media",
Storage: s,
Data: map[string]interface{}{"methods": ""},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("update role: err=%v resp=%v", err, resp)
}
role, _ := b.getRole(ctx, s, "media")
if len(role.Methods) != 0 {
t.Fatalf("expected methods cleared, got %v", role.Methods)
}
if len(role.Apps) != 1 {
t.Fatalf("expected apps preserved across the update, got %v", role.Apps)
}
}
func TestValidateMethods(t *testing.T) {
cases := []struct {
name string
in []string
want []string
wantErr bool
}{
{"empty is unrestricted", nil, nil, false},
{"lowercase normalized", []string{"get"}, []string{"GET"}, false},
{"trimmed dedup and sort", []string{" head ", "GET", "head"}, []string{"GET", "HEAD"}, false},
{"all known", []string{"OPTIONS", "DELETE", "PATCH", "PUT", "POST", "HEAD", "GET"},
[]string{"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"}, false},
{"unknown", []string{"GET", "FETCH"}, nil, true},
{"empty entry", []string{""}, nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := validateMethods(tc.in)
if (err != nil) != tc.wantErr {
t.Fatalf("validateMethods err=%v wantErr=%v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
}
func TestValidateApps(t *testing.T) {
cases := []struct {
name string
in []string
want []string
wantErr bool
}{
{"single", []string{"sonarr"}, []string{"sonarr"}, false},
{"dedup and sort", []string{"radarr", "sonarr", "radarr"}, []string{"radarr", "sonarr"}, false},
{"all three", []string{"prowlarr", "sonarr", "radarr"}, []string{"prowlarr", "radarr", "sonarr"}, false},
{"empty", nil, nil, true},
{"unknown", []string{"sonarr", "lidarr"}, nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := validateApps(tc.in)
if (err != nil) != tc.wantErr {
t.Fatalf("validateApps err=%v wantErr=%v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if len(got) != len(tc.want) {
t.Fatalf("got %v want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("got %v want %v", got, tc.want)
}
}
})
}
}