Add per-role HTTP method scoping to minted tokens
Every token this engine mints is as powerful as the apps it can reach: a read-only integration can still write to the *arr. arrproxy v0.5.0 accepts a method scope at mint time, so let a role pin its tokens to it. - Add an optional role field methods, uppercase-normalized, de-duplicated and validated against the known HTTP methods at role write. - Forward the role's scope as methods on the arrproxy mint request and echo it in the creds response alongside apps/subject. - Omit the field entirely when a role has no scope, so an arrproxy predating method scoping sees an unchanged request. - Cover normalization, rejection, pass-through and the unscoped case.
This commit is contained in:
@@ -13,6 +13,8 @@ bearer-gated **admin API** (`/api/admin/tokens`) instead, so Terraform-driven
|
|||||||
Each minted token is:
|
Each minted token is:
|
||||||
|
|
||||||
- **scoped to a subset of apps** (`apps`: any of `sonarr`, `radarr`, `prowlarr`)
|
- **scoped to a subset of apps** (`apps`: any of `sonarr`, `radarr`, `prowlarr`)
|
||||||
|
- **optionally scoped to HTTP methods** (`methods`, e.g. `GET,HEAD` for a
|
||||||
|
read-only consumer; empty means unrestricted)
|
||||||
- **subject-namespaced** as `vault:arrstack:<role>` (arrproxy requires the prefix)
|
- **subject-namespaced** as `vault:arrstack:<role>` (arrproxy requires the prefix)
|
||||||
- **bound to a Vault lease** — revoking the lease disables the token in arrproxy
|
- **bound to a Vault lease** — revoking the lease disables the token in arrproxy
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ vault write arrstack/config \
|
|||||||
ca_cert=@traefik-external-ca.pem # optional
|
ca_cert=@traefik-external-ca.pem # optional
|
||||||
|
|
||||||
# 3. Define a role: which apps, what TTLs
|
# 3. Define a role: which apps, what TTLs
|
||||||
|
# (add methods="GET,HEAD" to pin the token to those HTTP methods)
|
||||||
vault write arrstack/roles/media \
|
vault write arrstack/roles/media \
|
||||||
apps="sonarr,radarr" \
|
apps="sonarr,radarr" \
|
||||||
ttl=1h \
|
ttl=1h \
|
||||||
@@ -61,6 +64,7 @@ vault read arrstack/creds/media
|
|||||||
# lease_id arrstack/creds/media/AbC...
|
# lease_id arrstack/creds/media/AbC...
|
||||||
# lease_duration 1h
|
# lease_duration 1h
|
||||||
# apps [radarr sonarr]
|
# apps [radarr sonarr]
|
||||||
|
# methods []
|
||||||
# id tok-...
|
# id tok-...
|
||||||
# subject vault:arrstack:media
|
# subject vault:arrstack:media
|
||||||
# token arr_...
|
# token arr_...
|
||||||
@@ -108,9 +112,30 @@ vault write arrstack/roles/all apps="sonarr,radarr,prowlarr" ttl=1h max_t
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| --------- | -------- | ------------------------------------------------------------------ |
|
| --------- | -------- | ------------------------------------------------------------------ |
|
||||||
| `apps` | list | Non-empty subset of `sonarr`/`radarr`/`prowlarr` |
|
| `apps` | list | Non-empty subset of `sonarr`/`radarr`/`prowlarr` |
|
||||||
|
| `methods` | list | Optional HTTP methods the token is limited to; empty = unrestricted |
|
||||||
| `ttl` | duration | Default lease TTL |
|
| `ttl` | duration | Default lease TTL |
|
||||||
| `max_ttl` | duration | Maximum lease TTL |
|
| `max_ttl` | duration | Maximum lease TTL |
|
||||||
|
|
||||||
|
## Method scoping
|
||||||
|
|
||||||
|
A role may pin its tokens to a set of HTTP methods, so a read-only integration
|
||||||
|
cannot write to the *arr even on an app it is allowed to reach. Entries are
|
||||||
|
uppercase-normalized and must name one of `GET`, `HEAD`, `POST`, `PUT`,
|
||||||
|
`PATCH`, `DELETE`, `OPTIONS` — an unknown method is rejected at role write, not
|
||||||
|
at mint. The scope is sent to arrproxy at mint time and enforced there: a
|
||||||
|
request outside it is refused with `405` before any upstream *arr is reached.
|
||||||
|
|
||||||
|
Leaving `methods` unset (the default) leaves the token unrestricted, exactly as
|
||||||
|
before this field existed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# A token that can read Sonarr but never change it.
|
||||||
|
vault write arrstack/roles/sonarr-ro apps="sonarr" methods="GET,HEAD" ttl=1h max_ttl=24h
|
||||||
|
|
||||||
|
# Drop the scope again on an existing role.
|
||||||
|
vault write arrstack/roles/sonarr-ro methods=""
|
||||||
|
```
|
||||||
|
|
||||||
## TTL and renewal
|
## TTL and renewal
|
||||||
|
|
||||||
At mint time the effective initial lease TTL is sent to arrproxy as
|
At mint time the effective initial lease TTL is sent to arrproxy as
|
||||||
|
|||||||
+31
-2
@@ -3,6 +3,7 @@ package arrstack
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -33,6 +34,7 @@ func getTestBackend(t *testing.T) (*arrstackBackend, logical.Storage) {
|
|||||||
type mockToken struct {
|
type mockToken struct {
|
||||||
Subject string
|
Subject string
|
||||||
Apps []string
|
Apps []string
|
||||||
|
Methods []string
|
||||||
Label string
|
Label string
|
||||||
ExpiresAt *time.Time
|
ExpiresAt *time.Time
|
||||||
Disabled bool
|
Disabled bool
|
||||||
@@ -49,9 +51,13 @@ type mockArrproxy struct {
|
|||||||
counter int
|
counter int
|
||||||
adminToken string
|
adminToken string
|
||||||
apps map[string]bool
|
apps map[string]bool
|
||||||
|
methods map[string]bool
|
||||||
|
|
||||||
mintErr bool
|
mintErr bool
|
||||||
|
// lastRequest is the decoded mint payload; lastBody is the raw JSON, so a
|
||||||
|
// test can assert a field was omitted rather than sent empty.
|
||||||
lastRequest mintTokenRequest
|
lastRequest mintTokenRequest
|
||||||
|
lastBody []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMockArrproxy(t *testing.T) *mockArrproxy {
|
func newMockArrproxy(t *testing.T) *mockArrproxy {
|
||||||
@@ -60,6 +66,10 @@ func newMockArrproxy(t *testing.T) *mockArrproxy {
|
|||||||
tokens: make(map[string]*mockToken),
|
tokens: make(map[string]*mockToken),
|
||||||
adminToken: "arrproxy-admin-secret",
|
adminToken: "arrproxy-admin-secret",
|
||||||
apps: map[string]bool{"sonarr": true, "radarr": true, "prowlarr": true},
|
apps: map[string]bool{"sonarr": true, "radarr": true, "prowlarr": true},
|
||||||
|
methods: map[string]bool{
|
||||||
|
"GET": true, "HEAD": true, "POST": true, "PUT": true,
|
||||||
|
"PATCH": true, "DELETE": true, "OPTIONS": true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -108,12 +118,18 @@ func (m *mockArrproxy) handleMint(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
var req mintTokenRequest
|
var req mintTokenRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.lastRequest = req
|
m.lastRequest = req
|
||||||
|
m.lastBody = body
|
||||||
|
|
||||||
if !strings.HasPrefix(req.Subject, "vault:arrstack:") || strings.TrimSpace(strings.TrimPrefix(req.Subject, "vault:arrstack:")) == "" {
|
if !strings.HasPrefix(req.Subject, "vault:arrstack:") || strings.TrimSpace(strings.TrimPrefix(req.Subject, "vault:arrstack:")) == "" {
|
||||||
http.Error(w, "subject must be namespaced with vault:arrstack:", http.StatusBadRequest)
|
http.Error(w, "subject must be namespaced with vault:arrstack:", http.StatusBadRequest)
|
||||||
@@ -129,6 +145,13 @@ func (m *mockArrproxy) handleMint(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An absent or empty methods list is unrestricted.
|
||||||
|
for _, meth := range req.Methods {
|
||||||
|
if !m.methods[meth] {
|
||||||
|
http.Error(w, "methods must name known HTTP methods", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.TTLSeconds < 0 {
|
if req.TTLSeconds < 0 {
|
||||||
http.Error(w, "ttl_seconds must not be negative", http.StatusBadRequest)
|
http.Error(w, "ttl_seconds must not be negative", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -141,9 +164,14 @@ func (m *mockArrproxy) handleMint(w http.ResponseWriter, r *http.Request) {
|
|||||||
exp := time.Now().UTC().Add(time.Duration(req.TTLSeconds) * time.Second)
|
exp := time.Now().UTC().Add(time.Duration(req.TTLSeconds) * time.Second)
|
||||||
expiresAt = &exp
|
expiresAt = &exp
|
||||||
}
|
}
|
||||||
|
methods := req.Methods
|
||||||
|
if methods == nil {
|
||||||
|
methods = []string{}
|
||||||
|
}
|
||||||
m.tokens[id] = &mockToken{
|
m.tokens[id] = &mockToken{
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Apps: req.Apps,
|
Apps: req.Apps,
|
||||||
|
Methods: methods,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
}
|
}
|
||||||
@@ -151,6 +179,7 @@ func (m *mockArrproxy) handleMint(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"token": "arr_" + id + "_plaintext",
|
"token": "arr_" + id + "_plaintext",
|
||||||
|
"methods": methods,
|
||||||
"expires_at": expiresAt,
|
"expires_at": expiresAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ func newClient(config *arrstackConfig) (*arrproxyClient, error) {
|
|||||||
// mintTokenRequest is the payload for POST /api/admin/tokens. The subject MUST
|
// mintTokenRequest is the payload for POST /api/admin/tokens. The subject MUST
|
||||||
// carry the "vault:arrstack:" prefix or arrproxy rejects the request.
|
// carry the "vault:arrstack:" prefix or arrproxy rejects the request.
|
||||||
type mintTokenRequest struct {
|
type mintTokenRequest struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Apps []string `json:"apps"`
|
Apps []string `json:"apps"`
|
||||||
|
// Methods limits the token to those HTTP methods. Omitted when empty so
|
||||||
|
// arrproxy versions predating method scoping see the request unchanged.
|
||||||
|
Methods []string `json:"methods,omitempty"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
TTLSeconds int64 `json:"ttl_seconds"`
|
TTLSeconds int64 `json:"ttl_seconds"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package arrstack
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +36,38 @@ func TestClient_MintToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClient_MintToken_ForwardsMethods(t *testing.T) {
|
||||||
|
m := newMockArrproxy(t)
|
||||||
|
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
|
||||||
|
|
||||||
|
if _, err := client.MintToken(context.Background(), mintTokenRequest{
|
||||||
|
Subject: "vault:arrstack:readonly",
|
||||||
|
Apps: []string{"sonarr"},
|
||||||
|
Methods: []string{"GET", "HEAD"},
|
||||||
|
TTLSeconds: 60,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("MintToken: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Join(m.lastRequest.Methods, ",") != "GET,HEAD" {
|
||||||
|
t.Fatalf("expected methods forwarded, got %v", m.lastRequest.Methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_MintToken_RejectsUnknownMethod(t *testing.T) {
|
||||||
|
m := newMockArrproxy(t)
|
||||||
|
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
|
||||||
|
|
||||||
|
_, err := client.MintToken(context.Background(), mintTokenRequest{
|
||||||
|
Subject: "vault:arrstack:readonly",
|
||||||
|
Apps: []string{"sonarr"},
|
||||||
|
Methods: []string{"FETCH"},
|
||||||
|
TTLSeconds: 60,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected arrproxy to reject an unknown HTTP method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClient_MintToken_RejectsBadSubject(t *testing.T) {
|
func TestClient_MintToken_RejectsBadSubject(t *testing.T) {
|
||||||
m := newMockArrproxy(t)
|
m := newMockArrproxy(t)
|
||||||
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
|
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func (b *arrstackBackend) mintToken(ctx context.Context, req *logical.Request, r
|
|||||||
minted, err := client.MintToken(ctx, mintTokenRequest{
|
minted, err := client.MintToken(ctx, mintTokenRequest{
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Apps: role.Apps,
|
Apps: role.Apps,
|
||||||
|
Methods: role.Methods,
|
||||||
Label: label,
|
Label: label,
|
||||||
TTLSeconds: int64(ttl.Seconds()),
|
TTLSeconds: int64(ttl.Seconds()),
|
||||||
})
|
})
|
||||||
@@ -98,6 +99,7 @@ func (b *arrstackBackend) mintToken(ctx context.Context, req *logical.Request, r
|
|||||||
"token": minted.Token,
|
"token": minted.Token,
|
||||||
"id": minted.ID,
|
"id": minted.ID,
|
||||||
"apps": role.Apps,
|
"apps": role.Apps,
|
||||||
|
"methods": role.Methods,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"expires_at": expiresAt.Format(time.RFC3339),
|
"expires_at": expiresAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,63 @@ func TestCredentials_MintAndRevoke(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestCredentials_UnknownRole(t *testing.T) {
|
||||||
b, s := getTestBackend(t)
|
b, s := getTestBackend(t)
|
||||||
m := newMockArrproxy(t)
|
m := newMockArrproxy(t)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package arrstack
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/vault/sdk/framework"
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
@@ -20,10 +22,21 @@ var knownApps = map[string]bool{
|
|||||||
"prowlarr": true,
|
"prowlarr": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// knownMethods mirrors arrproxy's accepted HTTP method scope. Validating here
|
||||||
|
// rejects a typo at role write instead of at mint.
|
||||||
|
var knownMethods = map[string]bool{
|
||||||
|
http.MethodGet: true, http.MethodHead: true, http.MethodPost: true,
|
||||||
|
http.MethodPut: true, http.MethodPatch: true, http.MethodDelete: true,
|
||||||
|
http.MethodOptions: true,
|
||||||
|
}
|
||||||
|
|
||||||
// arrstackRole constrains the machine tokens minted from the creds/<name> path.
|
// arrstackRole constrains the machine tokens minted from the creds/<name> path.
|
||||||
type arrstackRole struct {
|
type arrstackRole struct {
|
||||||
// Apps is the subset of sonarr/radarr/prowlarr a minted token may reach.
|
// Apps is the subset of sonarr/radarr/prowlarr a minted token may reach.
|
||||||
Apps []string `json:"apps"`
|
Apps []string `json:"apps"`
|
||||||
|
// Methods, when non-empty, limits a minted token to those HTTP methods.
|
||||||
|
// Empty means unrestricted.
|
||||||
|
Methods []string `json:"methods,omitempty"`
|
||||||
// TTL is the default lease duration for tokens issued from this role.
|
// TTL is the default lease duration for tokens issued from this role.
|
||||||
TTL time.Duration `json:"ttl"`
|
TTL time.Duration `json:"ttl"`
|
||||||
// MaxTTL is the maximum lease duration for tokens issued from this role.
|
// MaxTTL is the maximum lease duration for tokens issued from this role.
|
||||||
@@ -33,6 +46,7 @@ type arrstackRole struct {
|
|||||||
func (r *arrstackRole) toResponseData() map[string]interface{} {
|
func (r *arrstackRole) toResponseData() map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"apps": r.Apps,
|
"apps": r.Apps,
|
||||||
|
"methods": r.Methods,
|
||||||
"ttl": int64(r.TTL.Seconds()),
|
"ttl": int64(r.TTL.Seconds()),
|
||||||
"max_ttl": int64(r.MaxTTL.Seconds()),
|
"max_ttl": int64(r.MaxTTL.Seconds()),
|
||||||
}
|
}
|
||||||
@@ -59,6 +73,28 @@ func validateApps(requested []string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateMethods uppercase-normalizes, de-duplicates and sorts a requested
|
||||||
|
// method scope. An empty request yields a nil scope, meaning unrestricted.
|
||||||
|
func validateMethods(requested []string) ([]string, error) {
|
||||||
|
if len(requested) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(requested))
|
||||||
|
for _, m := range requested {
|
||||||
|
m = strings.ToUpper(strings.TrimSpace(m))
|
||||||
|
if !knownMethods[m] {
|
||||||
|
return nil, fmt.Errorf("unknown method %q: methods must be a subset of GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS", m)
|
||||||
|
}
|
||||||
|
if !seen[m] {
|
||||||
|
seen[m] = true
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func pathRole(b *arrstackBackend) *framework.Path {
|
func pathRole(b *arrstackBackend) *framework.Path {
|
||||||
return &framework.Path{
|
return &framework.Path{
|
||||||
Pattern: "roles/" + framework.GenericNameRegex("name"),
|
Pattern: "roles/" + framework.GenericNameRegex("name"),
|
||||||
@@ -77,6 +113,10 @@ func pathRole(b *arrstackBackend) *framework.Path {
|
|||||||
Description: "Comma-separated subset of sonarr/radarr/prowlarr a minted token may reach.",
|
Description: "Comma-separated subset of sonarr/radarr/prowlarr a minted token may reach.",
|
||||||
Required: true,
|
Required: true,
|
||||||
},
|
},
|
||||||
|
"methods": {
|
||||||
|
Type: framework.TypeCommaStringSlice,
|
||||||
|
Description: "Optional comma-separated HTTP methods a minted token is limited to (GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS). Empty means unrestricted.",
|
||||||
|
},
|
||||||
"ttl": {
|
"ttl": {
|
||||||
Type: framework.TypeDurationSecond,
|
Type: framework.TypeDurationSecond,
|
||||||
Description: "Default lease TTL for tokens minted from this role.",
|
Description: "Default lease TTL for tokens minted from this role.",
|
||||||
@@ -171,6 +211,13 @@ func (b *arrstackBackend) pathRoleWrite(ctx context.Context, req *logical.Reques
|
|||||||
}
|
}
|
||||||
role.Apps = apps
|
role.Apps = apps
|
||||||
}
|
}
|
||||||
|
if v, ok := data.GetOk("methods"); ok {
|
||||||
|
methods, err := validateMethods(v.([]string))
|
||||||
|
if err != nil {
|
||||||
|
return logical.ErrorResponse(err.Error()), nil
|
||||||
|
}
|
||||||
|
role.Methods = methods
|
||||||
|
}
|
||||||
if v, ok := data.GetOk("ttl"); ok {
|
if v, ok := data.GetOk("ttl"); ok {
|
||||||
role.TTL = time.Duration(v.(int)) * time.Second
|
role.TTL = time.Duration(v.(int)) * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package arrstack
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/hashicorp/vault/sdk/logical"
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
@@ -148,6 +149,134 @@ func TestRole_TTLGreaterThanMaxTTLRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestValidateApps(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user