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

## 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>
This commit was merged in pull request #2.
This commit is contained in:
2026-08-30 14:45:04 +10:00
committed by BenVincent
parent c3205dde45
commit b1b11330a6
8 changed files with 329 additions and 4 deletions
+57
View File
@@ -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) {
b, s := getTestBackend(t)
m := newMockArrproxy(t)