mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
9718555e31
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.
65 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|