Files
pulse/internal/api/resources_mutation_scope_test.go
T
rcourtman 9718555e31 Stop token callers widening or bypassing their scopes
Two authorization gaps where a token could do more than its scopes allow.

POST /api/security/regenerate-token replaces the whole token inventory,
wildcard tokens included, and issues a full-access credential (nil scopes
normalise to "*"). A token holding only settings:write could therefore
wipe every other token and mint an unrestricted one, which the normal
create and update paths refuse because a caller can only grant scopes it
holds. A token caller now needs full-access scope and must not be bound
to a non-default organization; anything narrower gets 403 and is pointed at
POST /api/security/tokens/{id}/rotate, which replaces one token. Admin
sessions are unchanged.

/api/resources/ is one prefix route gated on monitoring:read, but it also
dispatches POST link, unlink and report-merge, which persist resource
identity changes. A read-only token could change them. Anything other
than GET or HEAD on that prefix now needs monitoring:write; the more
specific registrations (operator-state, maintenance) keep their own gates.

Tests pin both boundaries and each fails when its fix is reverted. Two
existing regenerate tests that used a settings:write caller now use a
full-access one, since their subject is owner inheritance and rollback.

Contract-Neutral: Behavioural fix that enforces the documented token scope model; no new API surface or contract.
2026-10-11 15:57:35 +01:00

65 lines
2.6 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// /api/resources/ is one prefix route. Its POST sub-paths (link, unlink,
// report-merge) persist resource identity changes, so a read-only token must
// be refused while reads stay available to it.
func TestResourceRoutesRequireWriteScopeForMutations(t *testing.T) {
const readToken = "resources-read-only-scope.12345678"
const writeToken = "resources-write-scope.12345678"
cfg := newTestConfigWithTokens(t,
newTokenRecord(t, readToken, []string{config.ScopeMonitoringRead}, nil),
newTokenRecord(t, writeToken, []string{config.ScopeMonitoringWrite}, nil),
)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
do := func(token, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
return rec
}
mutations := []struct{ path, body string }{
{"/api/resources/vm-1/link", `{"targetId":"agent-1"}`},
{"/api/resources/vm-1/unlink", `{"targetId":"agent-1"}`},
{"/api/resources/vm-1/report-merge", `{}`},
}
for _, m := range mutations {
rec := do(readToken, http.MethodPost, m.path, m.body)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) {
t.Errorf("read-only token POST %s = %d %q, want 403 naming %s", m.path, rec.Code, rec.Body.String(), config.ScopeMonitoringWrite)
}
rec = do(writeToken, http.MethodPost, m.path, m.body)
if rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized {
t.Errorf("write-scoped token POST %s refused with %d: %s", m.path, rec.Code, rec.Body.String())
}
}
// Reads on the same prefix stay available to a read-only token.
rec := do(readToken, http.MethodGet, "/api/resources/vm-1", "")
if rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized {
t.Errorf("read-only token GET refused with %d: %s", rec.Code, rec.Body.String())
}
// The operator-state registration is more specific than the prefix and
// has its own write requirement; this also refuses the read-only token.
rec = do(readToken, http.MethodPut, "/api/resources/vm-1/operator-state", `{}`)
if rec.Code != http.StatusForbidden {
t.Errorf("read-only token PUT operator-state = %d, want 403", rec.Code)
}
// A write-only token has no read scope, so reads stay refused.
rec = do(writeToken, http.MethodGet, "/api/resources/vm-1", "")
if rec.Code != http.StatusForbidden {
t.Errorf("write-only token GET = %d, want 403", rec.Code)
}
}